1use crate::{
2 models::{InsertQueryBuilder, UpdateQueryBuilder},
3 prelude::*,
4};
5use compact_str::ToCompactString;
6use futures_util::StreamExt;
7use garde::Validate;
8use git2::FetchOptions;
9use serde::{Deserialize, Serialize};
10use sqlx::{Row, postgres::PgRow};
11use std::{
12 collections::BTreeMap,
13 path::PathBuf,
14 sync::{Arc, LazyLock},
15};
16use utoipa::ToSchema;
17
18#[derive(Serialize, Deserialize, Clone)]
19pub struct EggRepository {
20 pub uuid: uuid::Uuid,
21
22 pub name: compact_str::CompactString,
23 pub description: Option<compact_str::CompactString>,
24 pub git_repository: compact_str::CompactString,
25
26 pub last_synced: Option<chrono::NaiveDateTime>,
27 pub created: chrono::NaiveDateTime,
28}
29
30impl BaseModel for EggRepository {
31 const NAME: &'static str = "egg_repository";
32
33 #[inline]
34 fn columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
35 let prefix = prefix.unwrap_or_default();
36
37 BTreeMap::from([
38 (
39 "egg_repositories.uuid",
40 compact_str::format_compact!("{prefix}uuid"),
41 ),
42 (
43 "egg_repositories.name",
44 compact_str::format_compact!("{prefix}name"),
45 ),
46 (
47 "egg_repositories.description",
48 compact_str::format_compact!("{prefix}description"),
49 ),
50 (
51 "egg_repositories.git_repository",
52 compact_str::format_compact!("{prefix}git_repository"),
53 ),
54 (
55 "egg_repositories.last_synced",
56 compact_str::format_compact!("{prefix}last_synced"),
57 ),
58 (
59 "egg_repositories.created",
60 compact_str::format_compact!("{prefix}created"),
61 ),
62 ])
63 }
64
65 #[inline]
66 fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
67 let prefix = prefix.unwrap_or_default();
68
69 Ok(Self {
70 uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
71 name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
72 description: row
73 .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
74 git_repository: row
75 .try_get(compact_str::format_compact!("{prefix}git_repository").as_str())?,
76 last_synced: row
77 .try_get(compact_str::format_compact!("{prefix}last_synced").as_str())?,
78 created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
79 })
80 }
81}
82
83impl EggRepository {
84 pub async fn all_with_pagination(
85 database: &crate::database::Database,
86 page: i64,
87 per_page: i64,
88 search: Option<&str>,
89 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
90 let offset = (page - 1) * per_page;
91
92 let rows = sqlx::query(&format!(
93 r#"
94 SELECT {}, COUNT(*) OVER() AS total_count
95 FROM egg_repositories
96 WHERE ($1 IS NULL OR egg_repositories.name ILIKE '%' || $1 || '%')
97 ORDER BY egg_repositories.created
98 LIMIT $2 OFFSET $3
99 "#,
100 Self::columns_sql(None)
101 ))
102 .bind(search)
103 .bind(per_page)
104 .bind(offset)
105 .fetch_all(database.read())
106 .await?;
107
108 Ok(super::Pagination {
109 total: rows
110 .first()
111 .map_or(Ok(0), |row| row.try_get("total_count"))?,
112 per_page,
113 page,
114 data: rows
115 .into_iter()
116 .map(|row| Self::map(None, &row))
117 .try_collect_vec()?,
118 })
119 }
120
121 pub async fn sync(&self, database: &crate::database::Database) -> Result<usize, anyhow::Error> {
122 let git_repository = self.git_repository.clone();
123
124 let exported_eggs = tokio::task::spawn_blocking(
125 move || -> Result<Vec<(PathBuf, super::nest_egg::ExportedNestEgg)>, anyhow::Error> {
126 let mut exported_eggs = Vec::new();
127 let temp_dir = tempfile::tempdir()?;
128 let filesystem = crate::cap::CapFilesystem::new(temp_dir.path().to_path_buf())?;
129
130 let mut fetch_options = FetchOptions::new();
131 fetch_options.depth(1);
132 git2::build::RepoBuilder::new()
133 .fetch_options(fetch_options)
134 .clone(&git_repository, temp_dir.path())?;
135
136 let mut walker = filesystem.walk_dir(".")?;
137 while let Some(Ok((is_dir, entry))) = walker.next_entry() {
138 if is_dir
139 || !matches!(
140 entry.extension().and_then(|s| s.to_str()),
141 Some("json") | Some("yml") | Some("yaml")
142 )
143 {
144 continue;
145 }
146
147 let metadata = match filesystem.metadata(&entry) {
148 Ok(metadata) => metadata,
149 Err(_) => continue,
150 };
151
152 if !metadata.is_file() || metadata.len() > 1024 * 1024 {
154 continue;
155 }
156
157 let file_content = match filesystem.read_to_string(&entry) {
158 Ok(content) => content,
159 Err(_) => continue,
160 };
161 let exported_egg: super::nest_egg::ExportedNestEgg =
162 if entry.extension().and_then(|s| s.to_str()) == Some("json") {
163 match serde_json::from_str(&file_content) {
164 Ok(egg) => egg,
165 Err(_) => continue,
166 }
167 } else {
168 match serde_norway::from_str(&file_content) {
169 Ok(egg) => egg,
170 Err(_) => continue,
171 }
172 };
173
174 exported_eggs.push((entry, exported_egg));
175 }
176
177 Ok(exported_eggs)
178 },
179 )
180 .await??;
181
182 super::egg_repository_egg::EggRepositoryEgg::delete_unused(
183 database,
184 self.uuid,
185 &exported_eggs
186 .iter()
187 .map(|(path, _)| path.to_string_lossy().to_compact_string())
188 .collect::<Vec<_>>(),
189 )
190 .await?;
191
192 let mut futures = Vec::new();
193 futures.reserve_exact(exported_eggs.len());
194
195 for (path, exported_egg) in exported_eggs.iter() {
196 futures.push(super::egg_repository_egg::EggRepositoryEgg::create(
197 database,
198 self.uuid,
199 path.to_string_lossy(),
200 &exported_egg.name,
201 exported_egg.description.as_deref(),
202 &exported_egg.author,
203 exported_egg,
204 ));
205 }
206
207 let mut results_stream = futures_util::stream::iter(futures).buffer_unordered(25);
208 while let Some(result) = results_stream.next().await {
209 result?;
210 }
211
212 sqlx::query(
213 r#"
214 UPDATE egg_repositories
215 SET last_synced = NOW()
216 WHERE egg_repositories.uuid = $1
217 "#,
218 )
219 .bind(self.uuid)
220 .execute(database.write())
221 .await?;
222
223 Ok(exported_eggs.len())
224 }
225
226 #[inline]
227 pub fn into_admin_api_object(self) -> AdminApiEggRepository {
228 AdminApiEggRepository {
229 uuid: self.uuid,
230 name: self.name,
231 description: self.description,
232 git_repository: self.git_repository,
233 last_synced: self.last_synced.map(|dt| dt.and_utc()),
234 created: self.created.and_utc(),
235 }
236 }
237}
238
239#[derive(ToSchema, Deserialize, Validate)]
240pub struct CreateEggRepositoryOptions {
241 #[garde(length(chars, min = 3, max = 255))]
242 #[schema(min_length = 3, max_length = 255)]
243 pub name: compact_str::CompactString,
244 #[garde(length(max = 1024))]
245 #[schema(max_length = 1024)]
246 pub description: Option<compact_str::CompactString>,
247 #[garde(url)]
248 #[schema(example = "https://github.com/example/repo.git", format = "uri")]
249 pub git_repository: compact_str::CompactString,
250}
251
252#[async_trait::async_trait]
253impl CreatableModel for EggRepository {
254 type CreateOptions<'a> = CreateEggRepositoryOptions;
255 type CreateResult = Self;
256
257 fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
258 static CREATE_LISTENERS: LazyLock<CreateListenerList<EggRepository>> =
259 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
260
261 &CREATE_LISTENERS
262 }
263
264 async fn create(
265 state: &crate::State,
266 mut options: Self::CreateOptions<'_>,
267 ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
268 options.validate()?;
269
270 let mut transaction = state.database.write().begin().await?;
271
272 let mut query_builder = InsertQueryBuilder::new("egg_repositories");
273
274 Self::run_create_handlers(&mut options, &mut query_builder, state, &mut transaction)
275 .await?;
276
277 query_builder
278 .set("name", &options.name)
279 .set("description", &options.description)
280 .set("git_repository", &options.git_repository);
281
282 let row = query_builder
283 .returning(&Self::columns_sql(None))
284 .fetch_one(&mut *transaction)
285 .await?;
286 let egg_repository = Self::map(None, &row)?;
287
288 transaction.commit().await?;
289
290 Ok(egg_repository)
291 }
292}
293
294#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
295pub struct UpdateEggRepositoryOptions {
296 #[garde(length(chars, min = 3, max = 255))]
297 #[schema(min_length = 3, max_length = 255)]
298 pub name: Option<compact_str::CompactString>,
299 #[garde(length(max = 1024))]
300 #[schema(max_length = 1024)]
301 #[serde(
302 default,
303 skip_serializing_if = "Option::is_none",
304 with = "::serde_with::rust::double_option"
305 )]
306 pub description: Option<Option<compact_str::CompactString>>,
307 #[garde(url)]
308 #[schema(example = "https://github.com/example/repo.git", format = "uri")]
309 pub git_repository: Option<compact_str::CompactString>,
310}
311
312#[async_trait::async_trait]
313impl UpdatableModel for EggRepository {
314 type UpdateOptions = UpdateEggRepositoryOptions;
315
316 fn get_update_handlers() -> &'static LazyLock<UpdateListenerList<Self>> {
317 static UPDATE_LISTENERS: LazyLock<UpdateListenerList<EggRepository>> =
318 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
319
320 &UPDATE_LISTENERS
321 }
322
323 async fn update(
324 &mut self,
325 state: &crate::State,
326 mut options: Self::UpdateOptions,
327 ) -> Result<(), crate::database::DatabaseError> {
328 options.validate()?;
329
330 let mut transaction = state.database.write().begin().await?;
331
332 let mut query_builder = UpdateQueryBuilder::new("egg_repositories");
333
334 Self::run_update_handlers(
335 self,
336 &mut options,
337 &mut query_builder,
338 state,
339 &mut transaction,
340 )
341 .await?;
342
343 query_builder
344 .set("name", options.name.as_ref())
345 .set(
346 "description",
347 options.description.as_ref().map(|d| d.as_ref()),
348 )
349 .set("git_repository", options.git_repository.as_ref())
350 .where_eq("uuid", self.uuid);
351
352 query_builder.execute(&mut *transaction).await?;
353
354 if let Some(name) = options.name {
355 self.name = name;
356 }
357 if let Some(description) = options.description {
358 self.description = description;
359 }
360 if let Some(git_repository) = options.git_repository {
361 self.git_repository = git_repository;
362 }
363
364 transaction.commit().await?;
365
366 Ok(())
367 }
368}
369
370#[async_trait::async_trait]
371impl ByUuid for EggRepository {
372 async fn by_uuid(
373 database: &crate::database::Database,
374 uuid: uuid::Uuid,
375 ) -> Result<Self, crate::database::DatabaseError> {
376 let row = sqlx::query(&format!(
377 r#"
378 SELECT {}
379 FROM egg_repositories
380 WHERE egg_repositories.uuid = $1
381 "#,
382 Self::columns_sql(None)
383 ))
384 .bind(uuid)
385 .fetch_one(database.read())
386 .await?;
387
388 Self::map(None, &row)
389 }
390}
391
392#[async_trait::async_trait]
393impl DeletableModel for EggRepository {
394 type DeleteOptions = ();
395
396 fn get_delete_handlers() -> &'static LazyLock<DeleteListenerList<Self>> {
397 static DELETE_LISTENERS: LazyLock<DeleteListenerList<EggRepository>> =
398 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
399
400 &DELETE_LISTENERS
401 }
402
403 async fn delete(
404 &self,
405 state: &crate::State,
406 options: Self::DeleteOptions,
407 ) -> Result<(), anyhow::Error> {
408 let mut transaction = state.database.write().begin().await?;
409
410 self.run_delete_handlers(&options, state, &mut transaction)
411 .await?;
412
413 sqlx::query(
414 r#"
415 DELETE FROM egg_repositories
416 WHERE egg_repositories.uuid = $1
417 "#,
418 )
419 .bind(self.uuid)
420 .execute(&mut *transaction)
421 .await?;
422
423 transaction.commit().await?;
424
425 Ok(())
426 }
427}
428
429#[derive(ToSchema, Serialize)]
430#[schema(title = "EggRepository")]
431pub struct AdminApiEggRepository {
432 pub uuid: uuid::Uuid,
433
434 pub name: compact_str::CompactString,
435 pub description: Option<compact_str::CompactString>,
436 pub git_repository: compact_str::CompactString,
437
438 pub last_synced: Option<chrono::DateTime<chrono::Utc>>,
439 pub created: chrono::DateTime<chrono::Utc>,
440}