Skip to main content

shared/models/
egg_repository.rs

1use crate::{
2    models::{InsertQueryBuilder, UpdateQueryBuilder},
3    prelude::*,
4};
5use compact_str::ToCompactString;
6use futures_util::StreamExt;
7use garde::Validate;
8use serde::{Deserialize, Serialize};
9use sqlx::{Row, postgres::PgRow};
10use std::{
11    collections::BTreeMap,
12    path::PathBuf,
13    sync::{Arc, LazyLock},
14};
15use utoipa::ToSchema;
16
17#[derive(Serialize, Deserialize, Clone)]
18pub struct EggRepository {
19    pub uuid: uuid::Uuid,
20
21    pub name: compact_str::CompactString,
22    pub description: Option<compact_str::CompactString>,
23    pub git_repository: compact_str::CompactString,
24
25    pub last_synced: Option<chrono::NaiveDateTime>,
26    pub created: chrono::NaiveDateTime,
27
28    extension_data: super::ModelExtensionData,
29}
30
31impl BaseModel for EggRepository {
32    const NAME: &'static str = "egg_repository";
33
34    fn get_extension_list() -> &'static super::ModelExtensionList {
35        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
36            LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
37
38        &EXTENSIONS
39    }
40
41    fn get_extension_data(&self) -> &super::ModelExtensionData {
42        &self.extension_data
43    }
44
45    #[inline]
46    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
47        let prefix = prefix.unwrap_or_default();
48
49        BTreeMap::from([
50            (
51                "egg_repositories.uuid",
52                compact_str::format_compact!("{prefix}uuid"),
53            ),
54            (
55                "egg_repositories.name",
56                compact_str::format_compact!("{prefix}name"),
57            ),
58            (
59                "egg_repositories.description",
60                compact_str::format_compact!("{prefix}description"),
61            ),
62            (
63                "egg_repositories.git_repository",
64                compact_str::format_compact!("{prefix}git_repository"),
65            ),
66            (
67                "egg_repositories.last_synced",
68                compact_str::format_compact!("{prefix}last_synced"),
69            ),
70            (
71                "egg_repositories.created",
72                compact_str::format_compact!("{prefix}created"),
73            ),
74        ])
75    }
76
77    #[inline]
78    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
79        let prefix = prefix.unwrap_or_default();
80
81        Ok(Self {
82            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
83            name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
84            description: row
85                .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
86            git_repository: row
87                .try_get(compact_str::format_compact!("{prefix}git_repository").as_str())?,
88            last_synced: row
89                .try_get(compact_str::format_compact!("{prefix}last_synced").as_str())?,
90            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
91            extension_data: Self::map_extensions(prefix, row)?,
92        })
93    }
94}
95
96impl EggRepository {
97    pub async fn all_with_pagination(
98        database: &crate::database::Database,
99        page: i64,
100        per_page: i64,
101        search: Option<&str>,
102    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
103        let offset = (page - 1) * per_page;
104
105        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
106            r#"
107            SELECT {}, COUNT(*) OVER() AS total_count
108            FROM egg_repositories
109            WHERE ($1 IS NULL OR egg_repositories.name ILIKE '%' || $1 || '%')
110            ORDER BY egg_repositories.created
111            LIMIT $2 OFFSET $3
112            "#,
113            Self::columns_sql(None)
114        )))
115        .bind(search)
116        .bind(per_page)
117        .bind(offset)
118        .fetch_all(database.read())
119        .await?;
120
121        Ok(super::Pagination {
122            total: rows
123                .first()
124                .map_or(Ok(0), |row| row.try_get("total_count"))?,
125            per_page,
126            page,
127            data: rows
128                .into_iter()
129                .map(|row| Self::map(None, &row))
130                .try_collect_vec()?,
131        })
132    }
133
134    pub async fn sync(&self, database: &crate::database::Database) -> Result<usize, anyhow::Error> {
135        let git_repository = self.git_repository.clone();
136
137        struct FoundEgg {
138            path: PathBuf,
139            readme: Option<String>,
140            exported_egg: super::nest_egg::ExportedNestEgg,
141            updated: chrono::DateTime<chrono::Utc>,
142        }
143
144        let exported_eggs =
145            tokio::task::spawn_blocking(move || -> Result<Vec<FoundEgg>, anyhow::Error> {
146                let mut exported_eggs = Vec::new();
147                let temp_dir = tempfile::tempdir()?;
148                let filesystem = crate::cap::CapFilesystem::new(temp_dir.path().to_path_buf())?;
149
150                let mut prepare_fetch = gix::clone::PrepareFetch::new(
151                    git_repository.as_str(),
152                    temp_dir.path(),
153                    gix::create::Kind::WithWorktree,
154                    Default::default(),
155                    Default::default(),
156                )?;
157
158                let (mut prepare_checkout, _) = prepare_fetch
159                    .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
160                let _ = prepare_checkout
161                    .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
162
163                tracing::info!(
164                    "cloned egg repository {} to temporary directory",
165                    git_repository
166                );
167
168                let mut walker = filesystem.walk_dir(".")?;
169                while let Some(Ok((is_dir, entry))) = walker.next_entry() {
170                    if is_dir
171                        || !matches!(
172                            entry.extension().and_then(|s| s.to_str()),
173                            Some("json") | Some("yml") | Some("yaml")
174                        )
175                    {
176                        continue;
177                    }
178
179                    let metadata = match filesystem.metadata(&entry) {
180                        Ok(metadata) => metadata,
181                        Err(_) => continue,
182                    };
183
184                    // if any egg is larger than 1 MiB, something went horribly wrong in development
185                    if !metadata.is_file() || metadata.len() > 1024 * 1024 {
186                        continue;
187                    }
188
189                    let file_content = match filesystem.read_to_string(&entry) {
190                        Ok(content) => content,
191                        Err(_) => continue,
192                    };
193                    let exported_egg: super::nest_egg::ExportedNestEgg =
194                        if entry.extension().and_then(|s| s.to_str()) == Some("json") {
195                            match serde_json::from_str(&file_content) {
196                                Ok(egg) => egg,
197                                Err(_) => continue,
198                            }
199                        } else {
200                            match serde_norway::from_str(&file_content) {
201                                Ok(egg) => egg,
202                                Err(_) => continue,
203                            }
204                        };
205
206                    let mut readme = None;
207                    let mut current_path = entry.parent();
208                    'readme: while let Some(path) = current_path {
209                        let mut dir = filesystem.read_dir(path)?;
210
211                        while let Some(Ok((is_dir, entry))) = dir.next_entry() {
212                            if is_dir {
213                                continue;
214                            }
215
216                            let path = path.join(&entry);
217
218                            if entry.to_lowercase().contains("readme")
219                                && filesystem
220                                    .metadata(&path)
221                                    .is_ok_and(|m| m.is_file() && m.len() <= 1024 * 1024)
222                                && let Ok(content) = filesystem.read_to_string(&path)
223                            {
224                                readme = Some(content);
225                                break 'readme;
226                            }
227                        }
228
229                        current_path = path.parent();
230                    }
231
232                    exported_eggs.push(FoundEgg {
233                        path: entry,
234                        readme,
235                        exported_egg,
236                        updated: chrono::DateTime::from_timestamp(
237                            metadata
238                                .modified()
239                                .map_or_else(|_| std::time::SystemTime::now(), |t| t.into_std())
240                                .duration_since(std::time::UNIX_EPOCH)
241                                .unwrap_or_default()
242                                .as_secs() as i64,
243                            0,
244                        )
245                        .unwrap_or_else(chrono::Utc::now),
246                    });
247                }
248
249                drop(prepare_fetch);
250
251                Ok(exported_eggs)
252            })
253            .await??;
254
255        super::egg_repository_egg::EggRepositoryEgg::delete_unused(
256            database,
257            self.uuid,
258            &exported_eggs
259                .iter()
260                .map(|egg| egg.path.to_string_lossy().to_compact_string())
261                .collect::<Vec<_>>(),
262        )
263        .await?;
264
265        let mut futures = Vec::new();
266        futures.reserve_exact(exported_eggs.len());
267
268        for egg in exported_eggs.iter() {
269            futures.push(super::egg_repository_egg::EggRepositoryEgg::create(
270                database,
271                self.uuid,
272                egg.path.to_string_lossy(),
273                egg.readme.as_deref(),
274                &egg.exported_egg,
275                egg.updated.naive_utc(),
276            ));
277        }
278
279        let mut results_stream = futures_util::stream::iter(futures).buffer_unordered(25);
280        while let Some(result) = results_stream.next().await {
281            result?;
282        }
283
284        sqlx::query(
285            r#"
286            UPDATE egg_repositories
287            SET last_synced = NOW()
288            WHERE egg_repositories.uuid = $1
289            "#,
290        )
291        .bind(self.uuid)
292        .execute(database.write())
293        .await?;
294
295        Ok(exported_eggs.len())
296    }
297}
298
299#[async_trait::async_trait]
300impl IntoAdminApiObject for EggRepository {
301    type AdminApiObject = AdminApiEggRepository;
302    type ExtraArgs<'a> = ();
303
304    async fn into_admin_api_object<'a>(
305        self,
306        state: &crate::State,
307        _args: Self::ExtraArgs<'a>,
308    ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
309        let api_object = AdminApiEggRepository::init_hooks(&self, state).await?;
310
311        let api_object = finish_extendible!(
312            AdminApiEggRepository {
313                uuid: self.uuid,
314                name: self.name,
315                description: self.description,
316                git_repository: self.git_repository,
317                last_synced: self.last_synced.map(|dt| dt.and_utc()),
318                created: self.created.and_utc(),
319            },
320            api_object,
321            state
322        )?;
323
324        Ok(api_object)
325    }
326}
327
328#[derive(ToSchema, Deserialize, Validate)]
329pub struct CreateEggRepositoryOptions {
330    #[garde(length(chars, min = 1, max = 255))]
331    #[schema(min_length = 1, max_length = 255)]
332    pub name: compact_str::CompactString,
333    #[garde(length(max = 1024))]
334    #[schema(max_length = 1024)]
335    pub description: Option<compact_str::CompactString>,
336    #[garde(url)]
337    #[schema(example = "https://github.com/example/repo.git", format = "uri")]
338    pub git_repository: compact_str::CompactString,
339}
340
341#[async_trait::async_trait]
342impl CreatableModel for EggRepository {
343    type CreateOptions<'a> = CreateEggRepositoryOptions;
344    type CreateResult = Self;
345
346    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
347        static CREATE_LISTENERS: LazyLock<CreateListenerList<EggRepository>> =
348            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
349
350        &CREATE_LISTENERS
351    }
352
353    async fn create_with_transaction(
354        state: &crate::State,
355        mut options: Self::CreateOptions<'_>,
356        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
357    ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
358        options.validate()?;
359
360        let mut query_builder = InsertQueryBuilder::new("egg_repositories");
361
362        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
363
364        query_builder
365            .set("name", &options.name)
366            .set("description", &options.description)
367            .set("git_repository", &options.git_repository);
368
369        let row = query_builder
370            .returning(&Self::columns_sql(None))
371            .fetch_one(&mut **transaction)
372            .await?;
373        let mut egg_repository = Self::map(None, &row)?;
374
375        Self::run_after_create_handlers(&mut egg_repository, &options, state, transaction).await?;
376
377        Ok(egg_repository)
378    }
379}
380
381#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
382pub struct UpdateEggRepositoryOptions {
383    #[garde(length(chars, min = 1, max = 255))]
384    #[schema(min_length = 1, max_length = 255)]
385    pub name: Option<compact_str::CompactString>,
386    #[garde(length(max = 1024))]
387    #[schema(max_length = 1024)]
388    #[serde(
389        default,
390        skip_serializing_if = "Option::is_none",
391        with = "::serde_with::rust::double_option"
392    )]
393    pub description: Option<Option<compact_str::CompactString>>,
394    #[garde(url)]
395    #[schema(example = "https://github.com/example/repo.git", format = "uri")]
396    pub git_repository: Option<compact_str::CompactString>,
397}
398
399#[async_trait::async_trait]
400impl UpdatableModel for EggRepository {
401    type UpdateOptions = UpdateEggRepositoryOptions;
402
403    fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
404        static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<EggRepository>> =
405            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
406
407        &UPDATE_LISTENERS
408    }
409
410    async fn update_with_transaction(
411        &mut self,
412        state: &crate::State,
413        mut options: Self::UpdateOptions,
414        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
415    ) -> Result<(), crate::database::DatabaseError> {
416        options.validate()?;
417
418        let mut query_builder = UpdateQueryBuilder::new("egg_repositories");
419
420        self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
421            .await?;
422
423        query_builder
424            .set("name", options.name.as_ref())
425            .set(
426                "description",
427                options.description.as_ref().map(|d| d.as_ref()),
428            )
429            .set("git_repository", options.git_repository.as_ref())
430            .where_eq("uuid", self.uuid);
431
432        query_builder.execute(&mut **transaction).await?;
433
434        if let Some(name) = options.name {
435            self.name = name;
436        }
437        if let Some(description) = options.description {
438            self.description = description;
439        }
440        if let Some(git_repository) = options.git_repository {
441            self.git_repository = git_repository;
442        }
443
444        self.run_after_update_handlers(state, transaction).await?;
445
446        Ok(())
447    }
448}
449
450#[async_trait::async_trait]
451impl ByUuid for EggRepository {
452    async fn by_uuid(
453        database: &crate::database::Database,
454        uuid: uuid::Uuid,
455    ) -> Result<Self, crate::database::DatabaseError> {
456        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
457            r#"
458            SELECT {}
459            FROM egg_repositories
460            WHERE egg_repositories.uuid = $1
461            "#,
462            Self::columns_sql(None)
463        )))
464        .bind(uuid)
465        .fetch_one(database.read())
466        .await?;
467
468        Self::map(None, &row)
469    }
470
471    async fn by_uuid_with_transaction(
472        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
473        uuid: uuid::Uuid,
474    ) -> Result<Self, crate::database::DatabaseError> {
475        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
476            r#"
477            SELECT {}
478            FROM egg_repositories
479            WHERE egg_repositories.uuid = $1
480            "#,
481            Self::columns_sql(None)
482        )))
483        .bind(uuid)
484        .fetch_one(&mut **transaction)
485        .await?;
486
487        Self::map(None, &row)
488    }
489}
490
491#[async_trait::async_trait]
492impl DeletableModel for EggRepository {
493    type DeleteOptions = ();
494
495    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
496        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<EggRepository>> =
497            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
498
499        &DELETE_LISTENERS
500    }
501
502    async fn delete_with_transaction(
503        &self,
504        state: &crate::State,
505        options: Self::DeleteOptions,
506        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
507    ) -> Result<(), anyhow::Error> {
508        self.run_delete_handlers(&options, state, transaction)
509            .await?;
510
511        sqlx::query(
512            r#"
513            DELETE FROM egg_repositories
514            WHERE egg_repositories.uuid = $1
515            "#,
516        )
517        .bind(self.uuid)
518        .execute(&mut **transaction)
519        .await?;
520
521        self.run_after_delete_handlers(&options, state, transaction)
522            .await?;
523
524        Ok(())
525    }
526}
527
528#[schema_extension_derive::extendible]
529#[init_args(EggRepository, crate::State)]
530#[hook_args(crate::State)]
531#[derive(ToSchema, Serialize)]
532#[schema(title = "EggRepository")]
533pub struct AdminApiEggRepository {
534    pub uuid: uuid::Uuid,
535
536    pub name: compact_str::CompactString,
537    pub description: Option<compact_str::CompactString>,
538    pub git_repository: compact_str::CompactString,
539
540    pub last_synced: Option<chrono::DateTime<chrono::Utc>>,
541    pub created: chrono::DateTime<chrono::Utc>,
542}