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 !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}