1use anyhow::Context;
2use ignore::gitignore::GitignoreBuilder;
3use serde::{Deserialize, Serialize};
4use std::{
5 collections::BTreeMap,
6 io::{Read, Write},
7 path::Path,
8 sync::Arc,
9};
10use utoipa::ToSchema;
11use zip::write::FileOptions;
12
13#[derive(Clone, ToSchema, Deserialize, Serialize)]
14pub struct MetadataToml {
15 pub package_name: String,
16 pub name: String,
17 #[schema(value_type = String)]
18 pub panel_version: semver::VersionReq,
19
20 pub license_text: Option<String>,
21}
22
23impl MetadataToml {
24 #[inline]
29 pub fn get_package_identifier(&self) -> String {
30 Self::convert_package_name_to_identifier(&self.package_name)
31 }
32
33 #[inline]
37 pub fn convert_package_name_to_identifier(package_name: &str) -> String {
38 package_name.replace('.', "_")
39 }
40
41 #[inline]
44 pub fn is_valid_package_identifier(identifier: &str) -> bool {
45 let mut segments = identifier.split('_');
46 let tld = segments.next();
47 let author = segments.next();
48 let ident = segments.next();
49
50 if segments.next().is_some() {
51 return false;
52 }
53
54 let Some(tld) = tld else { return false };
55 let Some(author) = author else { return false };
56 let Some(ident) = ident else { return false };
57
58 if !(2..=6).contains(&tld.len()) {
59 return false;
60 }
61 if !(3..=30).contains(&author.len()) {
62 return false;
63 }
64 if !(4..=30).contains(&ident.len()) {
65 return false;
66 }
67
68 if !tld.chars().all(|c| c.is_ascii_lowercase()) {
69 return false;
70 }
71 if !author
72 .chars()
73 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
74 {
75 return false;
76 }
77 if !ident
78 .chars()
79 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
80 {
81 return false;
82 }
83
84 true
85 }
86
87 #[inline]
91 pub fn convert_identifier_to_package_name(identifier: &str) -> String {
92 identifier.replace('_', ".")
93 }
94}
95
96#[derive(Clone, Deserialize, Serialize)]
97pub struct CargoPackage {
98 pub description: String,
99 pub authors: Vec<String>,
100 pub version: semver::Version,
101}
102
103#[derive(Clone, Deserialize, Serialize)]
104pub struct CargoToml {
105 pub package: CargoPackage,
106 pub dependencies: BTreeMap<String, toml::Value>,
107}
108
109#[derive(Clone, Deserialize, Serialize)]
110pub struct PackageJson {
111 pub dependencies: BTreeMap<String, String>,
112}
113
114#[derive(Clone)]
115pub struct ExtensionMigration {
116 pub id: uuid::Uuid,
117 pub name: String,
118 pub date: chrono::DateTime<chrono::Utc>,
119 pub sql: String,
120 pub sql_down: String,
121}
122
123impl ExtensionMigration {
124 pub fn from_directory_raw(
125 path: &Path,
126 extension_identifier: &str,
127 mut content_up_raw: impl std::io::Read,
128 mut content_down_raw: impl std::io::Read,
129 ) -> Result<Self, std::io::Error> {
130 let mut content_up = String::new();
131 content_up_raw.read_to_string(&mut content_up)?;
132
133 let mut content_down = String::new();
134 content_down_raw.read_to_string(&mut content_down)?;
135
136 let name = path
137 .file_name()
138 .ok_or_else(|| {
139 std::io::Error::new(
140 std::io::ErrorKind::InvalidData,
141 format!(
142 "invalid migration directory name `{}`: unable to extract directory name.",
143 path.display()
144 ),
145 )
146 })?
147 .to_string_lossy()
148 .to_string();
149
150 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
151 return Err(std::io::Error::new(
152 std::io::ErrorKind::InvalidData,
153 format!(
154 "invalid migration directory name `{}`: must be ASCII and contain only alphanumeric characters or underscores.",
155 path.display()
156 ),
157 ));
158 }
159
160 let date = name
161 .split('_')
162 .next()
163 .and_then(|date_str| {
164 chrono::NaiveDateTime::parse_from_str(date_str, "%Y%m%d%H%M%S")
165 .ok()
166 .map(|ndt| ndt.and_utc())
167 })
168 .ok_or_else(|| {
169 std::io::Error::new(
170 std::io::ErrorKind::InvalidData,
171 format!(
172 "invalid migration directory name `{}`: unable to parse date from directory name. expected format `yyyymmddhhmmss_description/`.",
173 path.display()
174 ),
175 )
176 })?;
177
178 let xxh3 = xxhash_rust::xxh3::xxh3_128(
179 format!(
180 "{}:{}:{}",
181 extension_identifier,
182 name,
183 date.timestamp_millis()
184 )
185 .as_bytes(),
186 );
187 let id = uuid::Builder::from_u128(xxh3)
188 .with_variant(uuid::Variant::RFC4122)
189 .with_version(uuid::Version::Custom)
190 .into_uuid();
191
192 Ok(Self {
193 id,
194 name,
195 date,
196 sql: content_up,
197 sql_down: content_down,
198 })
199 }
200}
201
202#[derive(Clone)]
203pub struct ExtensionDistrFile {
204 zip: zip::ZipArchive<Arc<std::fs::File>>,
205
206 pub metadata_toml: MetadataToml,
207 pub cargo_toml: CargoToml,
208 pub package_json: PackageJson,
209}
210
211impl ExtensionDistrFile {
212 pub fn parse_from_reader(file: std::fs::File) -> Result<Self, anyhow::Error> {
213 let mut zip = zip::ZipArchive::new(Arc::new(file))?;
214
215 let mut metadata_toml = zip.by_name("Metadata.toml")?;
216 let mut metadata_toml_bytes = vec![0; metadata_toml.size() as usize];
217 metadata_toml.read_exact(&mut metadata_toml_bytes)?;
218 drop(metadata_toml);
219 let metadata_toml: MetadataToml = toml::from_slice(&metadata_toml_bytes)?;
220
221 let mut cargo_toml = zip.by_name("backend/Cargo.toml")?;
222 let mut cargo_toml_bytes = vec![0; cargo_toml.size() as usize];
223 cargo_toml.read_exact(&mut cargo_toml_bytes)?;
224 drop(cargo_toml);
225
226 let cargo_toml: CargoToml = toml::from_slice(&cargo_toml_bytes)?;
227
228 let mut package_json = zip.by_name("frontend/package.json")?;
229 let mut package_json_bytes = vec![0; package_json.size() as usize];
230 package_json.read_exact(&mut package_json_bytes)?;
231 drop(package_json);
232
233 let package_json: PackageJson = serde_json::from_slice(&package_json_bytes)?;
234
235 let mut this = Self {
236 zip,
237 metadata_toml,
238 cargo_toml,
239 package_json,
240 };
241 this.validate()?;
242
243 Ok(this)
244 }
245
246 pub fn extract_backend(&mut self, path: impl AsRef<Path>) -> Result<(), anyhow::Error> {
247 let filesystem = crate::cap::CapFilesystem::new(path.as_ref().to_path_buf())?;
248
249 let mut i = 0;
250 while let Ok(mut entry) = self.zip.by_index(i) {
251 i += 1;
252
253 if !entry.name().starts_with("backend/") {
254 continue;
255 }
256
257 let clean_path = match entry.enclosed_name() {
258 Some(clean_path) => clean_path,
259 None => continue,
260 };
261 let clean_path = match clean_path.strip_prefix("backend/") {
262 Ok(clean_path) => clean_path,
263 Err(_) => continue,
264 };
265
266 if entry.is_dir() {
267 filesystem.create_dir_all(clean_path)?;
268 } else if entry.is_file() {
269 let mut file = filesystem.create(clean_path)?;
270
271 std::io::copy(&mut entry, &mut file)?;
272 file.flush()?;
273 file.sync_all()?;
274 }
275 }
276
277 filesystem.write(
278 "Metadata.toml",
279 toml::to_string_pretty(&self.metadata_toml)?.into_bytes(),
280 )?;
281
282 Ok(())
283 }
284
285 pub fn extract_frontend(&mut self, path: impl AsRef<Path>) -> Result<(), anyhow::Error> {
286 let filesystem = crate::cap::CapFilesystem::new(path.as_ref().to_path_buf())?;
287
288 let mut i = 0;
289 while let Ok(mut entry) = self.zip.by_index(i) {
290 i += 1;
291
292 if !entry.name().starts_with("frontend/") {
293 continue;
294 }
295
296 let clean_path = match entry.enclosed_name() {
297 Some(clean_path) => clean_path,
298 None => continue,
299 };
300 let clean_path = match clean_path.strip_prefix("frontend/") {
301 Ok(clean_path) => clean_path,
302 Err(_) => continue,
303 };
304
305 if entry.is_dir() {
306 filesystem.create_dir_all(clean_path)?;
307 } else if entry.is_file() {
308 let mut file = filesystem.create(clean_path)?;
309
310 std::io::copy(&mut entry, &mut file)?;
311 file.flush()?;
312 file.sync_all()?;
313 }
314 }
315
316 Ok(())
317 }
318
319 pub fn has_migrations(&mut self) -> bool {
320 self.zip.by_name("migrations/").is_ok()
321 }
322
323 pub fn extract_migrations(&mut self, path: impl AsRef<Path>) -> Result<(), anyhow::Error> {
324 let filesystem = crate::cap::CapFilesystem::new(path.as_ref().to_path_buf())?;
325
326 let mut i = 0;
327 while let Ok(mut entry) = self.zip.by_index(i) {
328 i += 1;
329
330 if !entry.name().starts_with("migrations/") {
331 continue;
332 }
333
334 let clean_path = match entry.enclosed_name() {
335 Some(clean_path) => clean_path,
336 None => continue,
337 };
338 let clean_path = match clean_path.strip_prefix("migrations/") {
339 Ok(clean_path) => clean_path,
340 Err(_) => continue,
341 };
342
343 if entry.is_dir() {
344 filesystem.create_dir_all(clean_path)?;
345 } else if entry.is_file() {
346 let mut file = filesystem.create(clean_path)?;
347
348 std::io::copy(&mut entry, &mut file)?;
349 file.flush()?;
350 file.sync_all()?;
351 }
352 }
353
354 Ok(())
355 }
356
357 pub fn get_migrations(&mut self) -> Result<Vec<ExtensionMigration>, anyhow::Error> {
358 let mut migrations = Vec::new();
359
360 let mut migration_dirs = Vec::new();
361 let mut i = 0;
362 while let Ok(entry) = self.zip.by_index(i) {
363 i += 1;
364
365 let entry_name = entry.name().to_string();
366 if entry_name.starts_with("migrations/")
367 && entry_name.ends_with("/up.sql")
368 && !entry.is_dir()
369 && let Some(dir_name) = entry_name
370 .strip_prefix("migrations/")
371 .and_then(|s| s.strip_suffix("/up.sql"))
372 {
373 migration_dirs.push(dir_name.to_string());
374 }
375 }
376
377 for dir_name in migration_dirs {
378 let up_path = Path::new("migrations").join(&dir_name).join("up.sql");
379 let down_path = Path::new("migrations").join(&dir_name).join("down.sql");
380
381 let mut up_entry = self.zip.by_path(&up_path)?;
382 let mut up_bytes = vec![0; up_entry.size() as usize];
383 up_entry.read_exact(&mut up_bytes)?;
384 drop(up_entry);
385
386 let mut down_entry = self.zip.by_path(&down_path)?;
387 let mut down_bytes = vec![0; down_entry.size() as usize];
388 down_entry.read_exact(&mut down_bytes)?;
389 drop(down_entry);
390
391 let dir_path = Path::new(&dir_name);
392
393 migrations.push(ExtensionMigration::from_directory_raw(
394 dir_path,
395 &self.metadata_toml.get_package_identifier(),
396 &up_bytes[..],
397 &down_bytes[..],
398 )?);
399 }
400
401 Ok(migrations)
402 }
403
404 pub fn validate(&mut self) -> Result<(), anyhow::Error> {
405 const MUST_EXIST_DIRECTORIES: &[&str] =
406 &["backend/", "backend/src/", "frontend/", "frontend/src/"];
407 const MUST_EXIST_FILES: &[&str] = &[
408 "Metadata.toml",
409 "backend/Cargo.toml",
410 "backend/src/lib.rs",
411 "frontend/package.json",
412 ];
413
414 let mut package_segments = self.metadata_toml.package_name.split('.');
415 let tld_segment = package_segments.next().ok_or_else(|| {
416 anyhow::anyhow!("invalid package name in calagopus extension archive. (too few segments, expected 3)")
417 })?;
418 let author_segment = package_segments.next().ok_or_else(|| {
419 anyhow::anyhow!("invalid package name in calagopus extension archive. (too few segments, expected 3)")
420 })?;
421 let identifier_segment = package_segments.next().ok_or_else(|| {
422 anyhow::anyhow!("invalid package name in calagopus extension archive. (too few segments, expected 3)")
423 })?;
424
425 if package_segments.next().is_some() {
426 return Err(anyhow::anyhow!(
427 "invalid package name in calagopus extension archive. (too many segments, expected 3)"
428 ));
429 }
430
431 if tld_segment.len() < 2 || tld_segment.len() > 6 {
432 return Err(anyhow::anyhow!(
433 "invalid tld segment `{}` in calagopus extension archive package name.",
434 tld_segment
435 ));
436 }
437
438 if author_segment.len() < 3 || author_segment.len() > 30 {
439 return Err(anyhow::anyhow!(
440 "invalid author segment `{}` in calagopus extension archive package name.",
441 author_segment
442 ));
443 }
444
445 if identifier_segment.len() < 4 || identifier_segment.len() > 30 {
446 return Err(anyhow::anyhow!(
447 "invalid identifier segment `{}` in calagopus extension archive package name.",
448 identifier_segment
449 ));
450 }
451
452 for c in tld_segment.chars() {
453 if !c.is_ascii_lowercase() {
454 return Err(anyhow::anyhow!(
455 "invalid character `{c}` in tld segment of calagopus extension archive package name."
456 ));
457 }
458 }
459
460 for c in author_segment.chars() {
461 if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
462 return Err(anyhow::anyhow!(
463 "invalid character `{c}` in author segment of calagopus extension archive package name."
464 ));
465 }
466 }
467
468 for c in identifier_segment.chars() {
469 if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
470 return Err(anyhow::anyhow!(
471 "invalid character `{c}` in identifier segment of calagopus extension archive package name."
472 ));
473 }
474 }
475
476 for dir in MUST_EXIST_DIRECTORIES {
477 if self.zip.by_name(dir).ok().is_none_or(|e| !e.is_dir()) {
478 return Err(anyhow::anyhow!(
479 "unable to find directory `{dir}` in calagopus extension archive."
480 ));
481 }
482 }
483
484 for file in MUST_EXIST_FILES {
485 if self.zip.by_name(file).ok().is_none_or(|e| !e.is_file()) {
486 return Err(anyhow::anyhow!(
487 "unable to find file `{file}` in calagopus extension archive."
488 ));
489 }
490 }
491
492 if self.zip.by_name("frontend/src/index.ts").is_err()
493 && self.zip.by_name("frontend/src/index.tsx").is_err()
494 {
495 return Err(anyhow::anyhow!(
496 "unable to find file `frontend/src/index.ts` or `frontend/src/index.tsx` in calagopus extension archive."
497 ));
498 }
499
500 {
501 let mut lib = self.zip.by_name("backend/src/lib.rs")?;
502 let mut lib_string = String::new();
503 lib_string.reserve_exact(lib.size() as usize);
504 lib.read_to_string(&mut lib_string)?;
505 drop(lib);
506
507 if !lib_string.contains("pub struct ExtensionStruct") {
508 return Err(anyhow::anyhow!(
509 "unable to find `pub struct ExtensionStruct` in calagopus extension archive backend/src/lib.rs."
510 ));
511 }
512 }
513
514 {
515 let mut index = if let Ok(index) = self.zip.by_name("frontend/src/index.ts") {
516 index
517 } else {
518 self.zip.by_name("frontend/src/index.tsx")?
519 };
520 let mut index_string = String::new();
521 index_string.reserve_exact(index.size() as usize);
522 index.read_to_string(&mut index_string)?;
523 drop(index);
524
525 if !index_string.contains("export default ") {
526 return Err(anyhow::anyhow!(
527 "unable to find `export default ` in calagopus extension archive frontend/src/index.ts."
528 ));
529 }
530 }
531
532 if self.has_migrations()
533 && let Err(err) = self.get_migrations()
534 {
535 return Err(anyhow::anyhow!(
536 "unable to parse migrations in calagopus extension archive. make sure they are formatted as directories `20260125115245_xxx_xxx/` containing `up.sql` and `down.sql`. {err}"
537 ));
538 }
539
540 Ok(())
541 }
542
543 #[inline]
544 pub fn total_size(&self) -> u128 {
545 self.zip.decompressed_size().unwrap_or_default()
546 }
547}
548
549pub struct SlimExtensionDistrFile {
550 pub metadata_toml: MetadataToml,
551 pub cargo_toml: CargoToml,
552 pub package_json: PackageJson,
553}
554
555impl SlimExtensionDistrFile {
556 pub fn parse_from_directory(path: impl AsRef<Path>) -> Result<Vec<Self>, anyhow::Error> {
557 let filesystem = crate::cap::CapFilesystem::new(path.as_ref().to_path_buf())?;
558 let mut results = Vec::new();
559
560 let mut dir = filesystem.read_dir("backend-extensions")?;
561 while let Some(Ok((is_dir, name))) = dir.next_entry() {
562 if !is_dir || name == "internal-list" {
563 continue;
564 }
565
566 let metadata_toml = filesystem.read_to_string(
567 Path::new("backend-extensions")
568 .join(&name)
569 .join("Metadata.toml"),
570 )?;
571 let metadata_toml: MetadataToml = toml::from_str(&metadata_toml)?;
572
573 let cargo_toml = filesystem.read_to_string(
574 Path::new("backend-extensions")
575 .join(&name)
576 .join("Cargo.toml"),
577 )?;
578 let cargo_toml: CargoToml = toml::from_str(&cargo_toml)?;
579
580 let package_json = filesystem.read_to_string(
581 Path::new("frontend/extensions")
582 .join(&name)
583 .join("package.json"),
584 )?;
585 let package_json: PackageJson = serde_json::from_str(&package_json)?;
586
587 results.push(Self {
588 metadata_toml,
589 cargo_toml,
590 package_json,
591 });
592 }
593
594 Ok(results)
595 }
596}
597
598pub struct ExtensionDistrFileBuilder {
599 zip: zip::ZipWriter<std::fs::File>,
600 wrote_backend: bool,
601 wrote_frontend: bool,
602 wrote_migrations: bool,
603}
604
605impl ExtensionDistrFileBuilder {
606 pub fn new(file: std::fs::File) -> Self {
607 Self {
608 zip: zip::ZipWriter::new(file),
609 wrote_backend: false,
610 wrote_frontend: false,
611 wrote_migrations: false,
612 }
613 }
614
615 pub fn add_backend(mut self, path: impl AsRef<Path>) -> Result<Self, anyhow::Error> {
616 if self.wrote_backend {
617 return Err(anyhow::anyhow!(
618 "Cannot write backend, it has already been written."
619 ));
620 }
621
622 let filesystem = crate::cap::CapFilesystem::new(path.as_ref().to_path_buf())?;
623
624 let metadata_toml = filesystem
625 .read_to_string("Metadata.toml")
626 .context("Failed to read Metadata.toml from backend extension directory.")?;
627 self.zip
628 .start_file("Metadata.toml", FileOptions::<()>::default())?;
629 self.zip.write_all(metadata_toml.as_bytes())?;
630
631 self.zip.add_directory(
632 "backend",
633 FileOptions::<()>::default().compression_level(Some(9)),
634 )?;
635
636 let mut ignore_builder = GitignoreBuilder::new(path.as_ref());
640 ignore_builder.add(path.as_ref().join(".gitignore"));
641 ignore_builder
642 .add_line(None, ".git/")?
643 .add_line(None, "Metadata.toml")?
644 .add_line(None, "/frontend/")?
645 .add_line(None, "/migrations/")?;
646 let ignored = &[ignore_builder.build()?];
647
648 let mut walker = filesystem.walk_dir(path)?.with_ignored(ignored);
649 while let Some(Ok((_, name))) = walker.next_entry() {
650 let metadata = filesystem.metadata(&name)?;
651 let virtual_path = Path::new("backend").join(&name);
652 let virtual_path = virtual_path.to_string_lossy();
653 #[cfg(windows)]
655 let virtual_path = virtual_path.replace('\\', "/");
656
657 let options: FileOptions<()> = FileOptions::default().compression_level(Some(9));
658
659 if metadata.is_dir() {
660 self.zip.add_directory(&*virtual_path, options)?;
661 } else if metadata.is_file() {
662 self.zip.start_file(&*virtual_path, options)?;
663
664 let mut reader = filesystem.open(&name)?;
665 std::io::copy(&mut reader, &mut self.zip)?;
666 }
667 }
668
669 self.wrote_backend = true;
670
671 Ok(self)
672 }
673
674 pub fn add_frontend(mut self, path: impl AsRef<Path>) -> Result<Self, anyhow::Error> {
675 if self.wrote_frontend {
676 return Err(anyhow::anyhow!(
677 "Cannot write frontend, it has already been written."
678 ));
679 }
680
681 let filesystem = crate::cap::CapFilesystem::new(path.as_ref().to_path_buf())?;
682
683 self.zip.add_directory(
684 "frontend",
685 FileOptions::<()>::default().compression_level(Some(9)),
686 )?;
687
688 let mut ignore_builder = GitignoreBuilder::new(path.as_ref());
692 ignore_builder.add(path.as_ref().join(".gitignore"));
693 ignore_builder
696 .add_line(None, ".git/")?
697 .add_line(None, "node_modules")?
698 .add_line(None, "/tsconfig.json")?;
699 let ignored = &[ignore_builder.build()?];
700
701 let mut walker = filesystem.walk_dir(path)?.with_ignored(ignored);
702 while let Some(Ok((_, name))) = walker.next_entry() {
703 let metadata = filesystem.metadata(&name)?;
704 let virtual_path = Path::new("frontend").join(&name);
705 let virtual_path = virtual_path.to_string_lossy();
706 #[cfg(windows)]
708 let virtual_path = virtual_path.replace('\\', "/");
709
710 let options: FileOptions<()> = FileOptions::default().compression_level(Some(9));
711
712 if metadata.is_dir() {
713 self.zip.add_directory(&*virtual_path, options)?;
714 } else if metadata.is_file() {
715 self.zip.start_file(&*virtual_path, options)?;
716
717 let mut reader = filesystem.open(&name)?;
718 std::io::copy(&mut reader, &mut self.zip)?;
719 }
720 }
721
722 self.wrote_frontend = true;
723
724 Ok(self)
725 }
726
727 pub fn add_migrations(mut self, path: impl AsRef<Path>) -> Result<Self, anyhow::Error> {
728 if self.wrote_migrations {
729 return Err(anyhow::anyhow!(
730 "Cannot write migrations, they have already been written."
731 ));
732 }
733
734 let filesystem = crate::cap::CapFilesystem::new(path.as_ref().to_path_buf())?;
735
736 self.zip.add_directory(
737 "migrations",
738 FileOptions::<()>::default().compression_level(Some(9)),
739 )?;
740
741 let mut ignore_builder = GitignoreBuilder::new(path.as_ref());
743 ignore_builder.add(path.as_ref().join(".gitignore"));
744 ignore_builder.add_line(None, ".git/")?;
745 let ignored = &[ignore_builder.build()?];
746
747 let mut walker = filesystem.walk_dir(path)?.with_ignored(ignored);
748 while let Some(Ok((_, name))) = walker.next_entry() {
749 let metadata = filesystem.metadata(&name)?;
750 let virtual_path = Path::new("migrations").join(&name);
751 let virtual_path = virtual_path.to_string_lossy();
752 #[cfg(windows)]
754 let virtual_path = virtual_path.replace('\\', "/");
755
756 let options: FileOptions<()> = FileOptions::default().compression_level(Some(9));
757
758 if metadata.is_dir() {
759 self.zip.add_directory(&*virtual_path, options)?;
760 } else if metadata.is_file() {
761 self.zip.start_file(&*virtual_path, options)?;
762
763 let mut reader = filesystem.open(&name)?;
764 std::io::copy(&mut reader, &mut self.zip)?;
765 }
766 }
767
768 self.wrote_migrations = true;
769
770 Ok(self)
771 }
772
773 pub fn write(mut self) -> std::io::Result<std::fs::File> {
774 if !self.wrote_backend {
775 return Err(std::io::Error::new(
776 std::io::ErrorKind::InvalidData,
777 "Cannot finish writing extension archive: backend files not written.",
778 ));
779 }
780
781 if !self.wrote_frontend {
782 return Err(std::io::Error::new(
783 std::io::ErrorKind::InvalidData,
784 "Cannot finish writing extension archive: frontend files not written.",
785 ));
786 }
787
788 self.zip.set_comment(format!(
789 "this .c7s.zip extension archive has been generated by calagopus@{}",
790 crate::VERSION
791 ))?;
792 let writer = self.zip.finish()?;
793
794 Ok(writer)
795 }
796}
797
798pub fn resync_extension_list() -> Result<(), anyhow::Error> {
799 let internal_list_extension = Path::new("backend-extensions/internal-list");
800 let extensions_path = Path::new("backend-extensions");
801
802 let mut packages = Vec::new();
803
804 for dir in std::fs::read_dir(extensions_path).unwrap().flatten() {
805 if !dir.file_type().unwrap().is_dir() || dir.file_name() == "internal-list" {
806 continue;
807 }
808
809 let metadata_toml = match std::fs::read_to_string(dir.path().join("Metadata.toml")) {
810 Ok(file) => file,
811 Err(_) => continue,
812 };
813
814 let cargo_toml = match std::fs::read_to_string(dir.path().join("Cargo.toml")) {
815 Ok(file) => file,
816 Err(_) => continue,
817 };
818
819 #[derive(Deserialize)]
820 struct MetadataToml {
821 package_name: String,
822 name: String,
823 panel_version: semver::VersionReq,
824 }
825
826 #[derive(Deserialize)]
827 struct CargoToml {
828 package: CargoPackage,
829 }
830
831 #[derive(Deserialize)]
832 struct CargoPackage {
833 description: Option<String>,
834 authors: Option<Vec<String>>,
835 version: semver::Version,
836 }
837
838 let metadata_toml: MetadataToml = toml::from_str(&metadata_toml).unwrap();
839 let cargo_toml: CargoToml = toml::from_str(&cargo_toml).unwrap();
840 packages.push((dir.file_name(), metadata_toml, cargo_toml.package));
841 }
842
843 std::fs::create_dir_all(internal_list_extension).unwrap();
844 std::fs::create_dir_all(internal_list_extension.join("src")).unwrap();
845
846 let mut deps = String::new();
847
848 for (path, metadata, _) in packages.iter() {
849 deps.push_str(&metadata.package_name.replace('.', "_"));
850 deps.push_str(" = { path = \"../");
851 deps.push_str(&path.to_string_lossy());
852 deps.push_str("\" }\n");
853 }
854
855 const CARGO_TEMPLATE_TOML: &str =
856 include_str!("../../../backend-extensions/internal-list/Cargo.template.toml");
857
858 std::fs::write(
859 internal_list_extension.join("Cargo.toml"),
860 format!("{CARGO_TEMPLATE_TOML}{}", deps),
861 )?;
862
863 let mut exts = String::new();
864
865 for (_, metadata, package) in packages {
866 exts.push_str(&format!(
867 r#"
868 ConstructedExtension {{
869 metadata_toml: MetadataToml {{
870 package_name: {}.to_string(),
871 name: {}.to_string(),
872 panel_version: semver::VersionReq::parse({}).unwrap(),
873 license_text: None,
874 }},
875 package_name: {},
876 description: {},
877 authors: &{},
878 version: semver::Version::parse({}).unwrap(),
879 extension: Arc::new({}::ExtensionStruct::default()),
880 }},"#,
881 toml::Value::String(metadata.package_name.clone()),
882 toml::Value::String(metadata.name),
883 toml::Value::String(metadata.panel_version.to_string()),
884 toml::Value::String(metadata.package_name.clone()),
885 toml::Value::String(package.description.unwrap_or_default()),
886 toml::Value::Array(
887 package
888 .authors
889 .unwrap_or_default()
890 .into_iter()
891 .map(toml::Value::String)
892 .collect(),
893 ),
894 toml::Value::String(package.version.to_string()),
895 metadata.package_name.replace('.', "_"),
896 ));
897 }
898
899 let exts_vec = if exts.is_empty() {
900 "vec![]".to_string()
901 } else {
902 format!("vec![{}\n ]", exts)
903 };
904
905 std::fs::write(
906 internal_list_extension.join("src/lib.rs"),
907 format!(
908 r#"#![allow(clippy::default_constructed_unit_structs)]
909#![allow(unused_imports)]
910
911use shared::extensions::{{ConstructedExtension, distr::MetadataToml}};
912use std::sync::Arc;
913
914pub fn list() -> Vec<ConstructedExtension> {{
915 {}
916}}
917"#,
918 exts_vec,
919 ),
920 )?;
921
922 Ok(())
923}