1use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14
15const TRACE_TARGET: &str = "studio_worker::catalog";
18
19use crate::types::{
20 ModelCliDefaults, ModelEngine, ModelFile, ModelFileRole, ModelSource, TaskKind,
21};
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct CatalogModel {
28 pub id: String,
30 pub display_name: String,
32 pub kind: TaskKind,
34 #[serde(default)]
37 pub vram_gb_estimate: f32,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub description: Option<String>,
41 pub source: ModelSource,
43 #[serde(default = "default_true")]
45 pub enabled: bool,
46 #[serde(default = "default_origin")]
51 pub origin: String,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub exclusive_group: Option<String>,
56}
57
58fn default_true() -> bool {
59 true
60}
61fn default_origin() -> String {
62 "local".into()
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct Catalog {
69 #[serde(default)]
70 pub models: Vec<CatalogModel>,
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub dismissed_seeds: Vec<String>,
74}
75
76impl Catalog {
77 pub fn seed() -> Self {
79 Catalog {
80 models: vec![
81 zimage_turbo(),
82 qwen35_08b(),
83 nemotron_stream(),
84 parakeet_eou(),
85 ],
86 dismissed_seeds: Vec::new(),
87 }
88 }
89
90 pub fn ensure_seeds(&mut self) -> bool {
94 let missing: Vec<CatalogModel> = Self::seed()
95 .models
96 .into_iter()
97 .filter(|m| self.get(&m.id).is_none() && !self.dismissed_seeds.contains(&m.id))
98 .collect();
99 for model in &missing {
100 tracing::info!(target: TRACE_TARGET, op = "seed", model = %model.id, "added a seed model the catalogue lacked");
101 }
102 let added = !missing.is_empty();
103 self.models.extend(missing);
104 added
105 }
106
107 pub fn from_json(json: &str) -> serde_json::Result<Self> {
109 serde_json::from_str(json)
110 }
111
112 pub fn to_json(&self) -> serde_json::Result<String> {
114 serde_json::to_string_pretty(self)
115 }
116
117 pub fn load_or_seed(path: &Path) -> std::io::Result<Self> {
128 match std::fs::read_to_string(path) {
129 Ok(contents) => match Self::from_json(&contents) {
130 Ok(mut catalog) => {
131 if catalog.ensure_seeds() {
132 catalog.save(path)?;
133 }
134 Ok(catalog)
135 }
136 Err(parse_err) => {
137 let quarantine = quarantine_path(path);
138 std::fs::rename(path, &quarantine)?;
139 tracing::warn!(
140 target: TRACE_TARGET,
141 op = "load",
142 path = %path.display(),
143 quarantine = %quarantine.display(),
144 error = %parse_err,
145 "catalog is not valid JSON; quarantined the file and reseeded"
146 );
147 let seeded = Self::seed();
148 seeded.save(path)?;
149 Ok(seeded)
150 }
151 },
152 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
153 let seeded = Self::seed();
154 seeded.save(path)?;
155 Ok(seeded)
156 }
157 Err(err) => Err(err),
158 }
159 }
160
161 pub fn load_for_serving(path: Option<PathBuf>) -> (Self, Option<PathBuf>) {
166 match path {
167 Some(path) => match Self::load_or_seed(&path) {
168 Ok(catalog) => (catalog, Some(path)),
169 Err(err) => {
170 tracing::warn!(
171 target: TRACE_TARGET,
172 op = "load",
173 path = %path.display(),
174 error = %err,
175 "catalog unreadable; serving the in-memory seed and \
176 disabling persistence so the file is never clobbered"
177 );
178 (Self::seed(), None)
179 }
180 },
181 None => (Self::seed(), None),
182 }
183 }
184
185 pub fn save(&self, path: &Path) -> std::io::Result<()> {
189 if let Some(parent) = path.parent() {
190 std::fs::create_dir_all(parent)?;
191 }
192 let json = self
193 .to_json()
194 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
195 crate::config::write_atomic(path, json.as_bytes()).map_err(std::io::Error::other)
196 }
197
198 pub fn get(&self, id: &str) -> Option<&CatalogModel> {
200 self.models.iter().find(|m| m.id == id)
201 }
202
203 pub fn list(&self) -> &[CatalogModel] {
205 &self.models
206 }
207
208 pub fn upsert(&mut self, model: CatalogModel) {
210 self.dismissed_seeds.retain(|d| *d != model.id);
211 if let Some(existing) = self.models.iter_mut().find(|m| m.id == model.id) {
212 *existing = model;
213 } else {
214 self.models.push(model);
215 }
216 }
217
218 pub fn sync_studio_model(&mut self, incoming: CatalogModel) -> bool {
226 if let Some(existing) = self.models.iter_mut().find(|m| m.id == incoming.id) {
227 if existing.origin == "local" {
228 return false; }
230 if *existing == incoming {
231 return false; }
233 *existing = incoming;
234 return true;
235 }
236 self.models.push(incoming);
237 true
238 }
239
240 pub fn remove(&mut self, id: &str) -> bool {
242 let before = self.models.len();
243 self.models.retain(|m| m.id != id);
244 let removed = self.models.len() != before;
245 let is_seed = Self::seed().models.iter().any(|m| m.id == id);
246 if removed && is_seed && !self.dismissed_seeds.iter().any(|d| d == id) {
247 self.dismissed_seeds.push(id.to_string());
248 }
249 removed
250 }
251
252 pub fn default_image_model(&self) -> Option<&CatalogModel> {
254 self.default_model_for(TaskKind::Image)
255 }
256
257 pub fn default_model_for(&self, kind: TaskKind) -> Option<&CatalogModel> {
262 self.models.iter().find(|m| m.enabled && m.kind == kind)
263 }
264}
265
266pub(crate) fn quarantine_path(path: &Path) -> PathBuf {
269 let name = path
270 .file_name()
271 .map(|n| n.to_string_lossy().into_owned())
272 .unwrap_or_else(|| "models.json".to_string());
273 let ts = std::time::SystemTime::now()
274 .duration_since(std::time::UNIX_EPOCH)
275 .map(|d| d.as_secs())
276 .unwrap_or(0);
277 path.with_file_name(format!("{name}.corrupt-{ts}"))
278}
279
280fn hf_file(repo: &str, revision: &str, path: &str, bytes: u64, sha256: &str) -> ModelFile {
282 ModelFile {
283 role: ModelFileRole::Model,
284 url: format!("https://huggingface.co/{repo}/resolve/{revision}/{path}"),
285 filename: path.rsplit('/').next().unwrap_or(path).to_string(),
286 approx_bytes: Some(bytes),
287 sha256: Some(sha256.into()),
288 }
289}
290
291const PARAKEET_RS_REVISION: &str = "4d2a8bc71f5c896ec40faa59732e6716295edaf2";
293
294fn qwen35_08b() -> CatalogModel {
299 let mut kwargs = serde_json::Map::new();
300 kwargs.insert("enable_thinking".into(), serde_json::Value::Bool(false));
301 CatalogModel {
302 id: "qwen3.5-0.8b".into(),
303 display_name: "Qwen3.5 0.8B instruct (Q8_0)".into(),
304 kind: TaskKind::Llm,
305 vram_gb_estimate: 1.5,
306 description: Some("Small chat model: reasoning off, 32K context".into()),
307 source: ModelSource {
308 engine: ModelEngine::LlamaCpp,
309 files: vec![hf_file(
310 "unsloth/Qwen3.5-0.8B-GGUF",
311 "6ab461498e2023f6e3c1baea90a8f0fe38ab64d0",
312 "Qwen3.5-0.8B-Q8_0.gguf",
313 811_843_840,
314 "0ad885ffd4bb022fc4f0d33a3308fa108ef8613159d3b3a67e23abca056b7a6c",
315 )],
316 cli_defaults: ModelCliDefaults {
317 context_size: Some(32_768),
318 chat_template_kwargs: Some(kwargs),
319 ..Default::default()
320 },
321 },
322 enabled: true,
323 origin: "local".into(),
324 exclusive_group: None,
325 }
326}
327
328fn nemotron_stream() -> CatalogModel {
331 let dir = "nemotron-3.5-asr-streaming-0.6b-onnx";
332 let f = |name: &str, bytes: u64, sha: &str| {
333 hf_file(
334 "altunenes/parakeet-rs",
335 PARAKEET_RS_REVISION,
336 &format!("{dir}/{name}"),
337 bytes,
338 sha,
339 )
340 };
341 CatalogModel {
342 id: "nemotron-3.5-stream".into(),
343 display_name: "Nemotron 3.5 streaming (0.6B, multilingual)".into(),
344 kind: TaskKind::AudioStt,
345 vram_gb_estimate: 3.5,
346 description: Some("Streaming speech-to-text with punctuation".into()),
347 source: ModelSource {
348 engine: ModelEngine::Parakeet,
349 files: vec![
350 f(
351 "config.json",
352 2_979,
353 "b0289e196d11a17e3c661bbadfe455c87de4baffc1a5e652a5779f5d687c5db0",
354 ),
355 f(
356 "decoder_joint.onnx",
357 97_590_054,
358 "634dfadf24cb4f73c2fae170b36611d68db48186426882cbc8f7e02ed9f2bb29",
359 ),
360 f(
361 "encoder.onnx",
362 42_164_972,
363 "d569fbe78b48fbb04e169d324f5d25463838ceed7b5fc3bfe209872441979bd9",
364 ),
365 f(
366 "encoder.onnx.data",
367 2_454_405_120,
368 "7584f85df76bc9ae6fbdfa53aa8d97b07a842525d1c501d536d77fd9e4f57ac7",
369 ),
370 f(
371 "tokenizer.model",
372 406_554,
373 "ce3895e40806f02a26c3a225161b96ef682d6c0054bae32a245dec4258d7d291",
374 ),
375 ],
376 cli_defaults: ModelCliDefaults::default(),
377 },
378 enabled: true,
379 origin: "local".into(),
380 exclusive_group: Some("stt".into()),
381 }
382}
383
384fn parakeet_eou() -> CatalogModel {
387 let dir = "realtime_eou_120m-v1-onnx";
388 let f = |name: &str, bytes: u64, sha: &str| {
389 hf_file(
390 "altunenes/parakeet-rs",
391 PARAKEET_RS_REVISION,
392 &format!("{dir}/{name}"),
393 bytes,
394 sha,
395 )
396 };
397 CatalogModel {
398 id: "parakeet-eou-120m".into(),
399 display_name: "Parakeet EOU (120M, English)".into(),
400 kind: TaskKind::AudioStt,
401 vram_gb_estimate: 1.2,
402 description: Some("Light streaming speech-to-text, English".into()),
403 source: ModelSource {
404 engine: ModelEngine::Parakeet,
405 files: vec![
406 f(
407 "decoder_joint.onnx",
408 21_347_639,
409 "9d2553ac043c2fc5f69e970769b0fb8ab9103fbfdeb7d26a1ea9729d4bd2dddd",
410 ),
411 f(
412 "encoder.onnx",
413 459_341_289,
414 "d472887cc38a784a5bfc21c2dbe247639edc3b3f9992388d8ceceaec07256b5b",
415 ),
416 f(
417 "tokenizer.json",
418 20_053,
419 "f6b0ad8690559351fa478116fe0985a203b76f7c040f3a9381f485c99c0325f8",
420 ),
421 ],
422 cli_defaults: ModelCliDefaults::default(),
423 },
424 enabled: true,
425 origin: "local".into(),
426 exclusive_group: Some("stt".into()),
427 }
428}
429
430fn zimage_turbo() -> CatalogModel {
433 CatalogModel {
434 id: "z-image-turbo-q4_k_m.gguf".into(),
435 display_name: "Z-Image Turbo (Q4_K_M)".into(),
436 kind: TaskKind::Image,
437 vram_gb_estimate: 12.0,
438 description: Some(
439 "Distilled 8-step diffusion model packaged for sd.cpp. Diffusion (Q4_K), \
440 Qwen3-4B text encoder, Flux ae.safetensors VAE."
441 .into(),
442 ),
443 source: ModelSource {
444 engine: ModelEngine::SdCpp,
445 files: vec![
446 ModelFile {
451 role: ModelFileRole::DiffusionModel,
452 url: "https://huggingface.co/leejet/Z-Image-Turbo-GGUF/resolve/main/z_image_turbo-Q4_K.gguf".into(),
453 filename: "z_image_turbo-Q4_K.gguf".into(),
454 approx_bytes: Some(3_864_250_304),
455 sha256: Some(
456 "14b375ab4f226bc5378f68f37e899ef3c2242b8541e61e2bc1aff40976086fbd".into(),
457 ),
458 },
459 ModelFile {
460 role: ModelFileRole::TextEncoder,
461 url: "https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF/resolve/main/Qwen3-4B-Instruct-2507-Q4_K_M.gguf".into(),
462 filename: "Qwen3-4B-Instruct-2507-Q4_K_M.gguf".into(),
463 approx_bytes: Some(2_497_281_120),
464 sha256: Some(
465 "3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597".into(),
466 ),
467 },
468 ModelFile {
469 role: ModelFileRole::Vae,
470 url: "https://huggingface.co/Comfy-Org/Lumina_Image_2.0_Repackaged/resolve/main/split_files/vae/ae.safetensors".into(),
471 filename: "ae.safetensors".into(),
472 approx_bytes: Some(335_304_388),
473 sha256: Some(
474 "afc8e28272cd15db3919bacdb6918ce9c1ed22e96cb12c4d5ed0fba823529e38".into(),
475 ),
476 },
477 ],
478 cli_defaults: ModelCliDefaults {
479 cfg_scale: 1.0,
480 steps: 8,
481 width: 1024,
482 height: 1024,
483 sampling_method: Some("euler".into()),
484 flow_shift: None,
485 zero_cond_t: None,
486 offload_to_cpu: None,
487 context_size: None,
488 chat_template_kwargs: None,
489 mmap: None,
490 max_vram_gib: None,
491 },
492 },
493 enabled: true,
494 origin: "local".into(),
495 exclusive_group: None,
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 fn ids(c: &Catalog) -> Vec<&str> {
504 c.models.iter().map(|m| m.id.as_str()).collect()
505 }
506
507 #[test]
508 fn the_seed_carries_image_llm_and_both_streaming_speech_models() {
509 assert_eq!(
510 ids(&Catalog::seed()),
511 [
512 "z-image-turbo-q4_k_m.gguf",
513 "qwen3.5-0.8b",
514 "nemotron-3.5-stream",
515 "parakeet-eou-120m"
516 ]
517 );
518 }
519
520 #[test]
521 fn every_seed_file_is_pinned_and_checksummed() {
522 for model in Catalog::seed().models {
523 for file in &model.source.files {
524 assert!(
525 file.sha256.as_deref().is_some_and(|h| h.len() == 64),
526 "{} {}",
527 model.id,
528 file.filename
529 );
530 assert!(
531 file.approx_bytes.is_some_and(|b| b > 0),
532 "{} {}",
533 model.id,
534 file.filename
535 );
536 if model.id != "z-image-turbo-q4_k_m.gguf" {
537 assert!(
539 !file.url.contains("/resolve/main/"),
540 "{} pins a revision",
541 file.url
542 );
543 }
544 }
545 }
546 }
547
548 #[test]
549 fn the_streaming_seeds_swap_with_each_other() {
550 let seed = Catalog::seed();
551 for id in ["nemotron-3.5-stream", "parakeet-eou-120m"] {
552 let m = seed.get(id).unwrap();
553 assert_eq!(m.kind, TaskKind::AudioStt);
554 assert_eq!(m.source.engine, ModelEngine::Parakeet);
555 assert_eq!(m.exclusive_group.as_deref(), Some("stt"));
556 }
557 }
558
559 #[test]
560 fn the_llm_seed_answers_without_reasoning_in_a_long_context() {
561 let seed = Catalog::seed();
562 let m = seed.get("qwen3.5-0.8b").unwrap();
563 assert_eq!(m.kind, TaskKind::Llm);
564 assert_eq!(m.source.engine, ModelEngine::LlamaCpp);
565 assert_eq!(m.source.cli_defaults.context_size, Some(32768));
566 assert_eq!(
567 m.source.cli_defaults.chat_template_kwargs.as_ref().unwrap()["enable_thinking"],
568 false
569 );
570 }
571
572 #[test]
573 fn missing_seeds_are_added_to_an_existing_catalogue() {
574 let mut c = Catalog {
575 models: vec![zimage_turbo()],
576 ..Default::default()
577 };
578 assert!(c.ensure_seeds());
579 assert_eq!(ids(&c), ids(&Catalog::seed()));
580 assert!(!c.ensure_seeds(), "idempotent");
581 }
582
583 #[test]
584 fn seeding_never_overwrites_the_operators_entry() {
585 let mut mine = Catalog::seed().get("qwen3.5-0.8b").unwrap().clone();
586 mine.display_name = "my tuned qwen".into();
587 let mut c = Catalog {
588 models: vec![mine],
589 ..Default::default()
590 };
591 c.ensure_seeds();
592 assert_eq!(c.get("qwen3.5-0.8b").unwrap().display_name, "my tuned qwen");
593 }
594
595 #[test]
596 fn a_deleted_seed_stays_deleted() {
597 let mut c = Catalog::seed();
598 assert!(c.remove("parakeet-eou-120m"));
599 assert_eq!(c.dismissed_seeds, ["parakeet-eou-120m"]);
600 assert!(!c.ensure_seeds());
601 assert!(c.get("parakeet-eou-120m").is_none());
602 let reloaded = Catalog::from_json(&c.to_json().unwrap()).unwrap();
603 assert_eq!(reloaded.dismissed_seeds, ["parakeet-eou-120m"]);
604 }
605
606 #[test]
607 fn re_adding_a_dismissed_seed_forgets_the_dismissal() {
608 let mut c = Catalog::seed();
609 let eou = c.get("parakeet-eou-120m").unwrap().clone();
610 c.remove("parakeet-eou-120m");
611 c.upsert(eou);
612 assert!(c.dismissed_seeds.is_empty());
613 }
614
615 #[test]
616 fn removing_a_non_seed_records_nothing() {
617 let mut c = Catalog::seed();
618 c.upsert(studio_model("mine"));
619 c.remove("mine");
620 assert!(c.dismissed_seeds.is_empty());
621 }
622
623 #[test]
624 fn loading_an_older_catalogue_adds_the_new_seeds_and_saves() {
625 let dir = tempfile::tempdir().unwrap();
626 let path = dir.path().join("models.json");
627 let old = Catalog {
628 models: vec![zimage_turbo()],
629 ..Default::default()
630 };
631 std::fs::write(&path, old.to_json().unwrap()).unwrap();
632 let loaded = Catalog::load_or_seed(&path).unwrap();
633 assert_eq!(ids(&loaded), ids(&Catalog::seed()));
634 let on_disk = Catalog::from_json(&std::fs::read_to_string(&path).unwrap()).unwrap();
635 assert_eq!(ids(&on_disk), ids(&Catalog::seed()));
636 }
637
638 #[test]
639 fn seed_contains_zimage_with_three_files() {
640 let catalog = Catalog::seed();
641 let model = catalog
642 .get("z-image-turbo-q4_k_m.gguf")
643 .expect("z-image seeded");
644 assert_eq!(model.kind, TaskKind::Image);
645 assert_eq!(model.source.engine, ModelEngine::SdCpp);
646 assert_eq!(model.source.files.len(), 3);
647 assert_eq!(model.source.cli_defaults.steps, 8);
648 assert!(model.enabled);
649 }
650
651 #[test]
652 fn every_seeded_file_is_https_and_integrity_pinned() {
653 for model in Catalog::seed().list() {
657 for file in &model.source.files {
658 assert!(
659 file.url.starts_with("https://"),
660 "{} must be https",
661 file.url
662 );
663 let sha = file
664 .sha256
665 .as_deref()
666 .unwrap_or_else(|| panic!("{} has no sha256 pin", file.filename));
667 assert_eq!(sha.len(), 64, "{} pin must be 64 hex", file.filename);
668 assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
669 assert!(
670 file.approx_bytes.unwrap_or(0) > 0,
671 "{} needs a real approx_bytes for the disk preflight",
672 file.filename
673 );
674 }
675 }
676 }
677
678 #[test]
679 fn default_image_model_is_zimage() {
680 let catalog = Catalog::seed();
681 assert_eq!(
682 catalog.default_image_model().map(|m| m.id.as_str()),
683 Some("z-image-turbo-q4_k_m.gguf")
684 );
685 }
686
687 #[test]
688 fn json_round_trips() {
689 let catalog = Catalog::seed();
690 let json = catalog.to_json().unwrap();
691 assert!(json.contains("\"displayName\""));
693 assert!(json.contains("\"cliDefaults\""));
694 assert!(json.contains("\"diffusion-model\""));
695 let parsed = Catalog::from_json(&json).unwrap();
696 assert_eq!(parsed, catalog);
697 }
698
699 #[test]
700 fn upsert_adds_then_replaces() {
701 let mut catalog = Catalog::default();
702 let mut model = zimage_turbo();
703 catalog.upsert(model.clone());
704 assert_eq!(catalog.list().len(), 1);
705
706 model.display_name = "Renamed".into();
707 catalog.upsert(model);
708 assert_eq!(catalog.list().len(), 1);
709 assert_eq!(
710 catalog
711 .get("z-image-turbo-q4_k_m.gguf")
712 .unwrap()
713 .display_name,
714 "Renamed"
715 );
716 }
717
718 #[test]
719 fn remove_reports_presence() {
720 let mut catalog = Catalog::seed();
721 assert!(catalog.remove("z-image-turbo-q4_k_m.gguf"));
722 assert!(!catalog.remove("z-image-turbo-q4_k_m.gguf"));
723 assert!(catalog.get("z-image-turbo-q4_k_m.gguf").is_none());
724 }
725
726 #[test]
727 fn load_or_seed_writes_then_reads_back() {
728 let dir = std::env::temp_dir().join(format!("sw-catalog-{}", std::process::id()));
729 let _ = std::fs::remove_dir_all(&dir);
730 let path = dir.join("models.json");
731
732 let seeded = Catalog::load_or_seed(&path).unwrap();
734 assert!(path.exists());
735 assert!(seeded.get("z-image-turbo-q4_k_m.gguf").is_some());
736
737 let reloaded = Catalog::load_or_seed(&path).unwrap();
739 assert_eq!(reloaded, seeded);
740
741 let _ = std::fs::remove_dir_all(&dir);
742 }
743
744 #[test]
745 fn equality_derives_hold_for_catalog_model() {
746 assert_eq!(zimage_turbo(), zimage_turbo());
747 }
748
749 #[test]
754 fn save_atomically_replaces_without_temp_litter() {
755 let dir = tempfile::tempdir().unwrap();
759 let path = dir.path().join("models.json");
760 Catalog::seed().save(&path).unwrap();
761 let mut small = Catalog::default();
762 small.upsert(CatalogModel {
763 description: None,
764 ..zimage_turbo()
765 });
766 small.save(&path).unwrap();
767
768 let reloaded = Catalog::from_json(&std::fs::read_to_string(&path).unwrap()).unwrap();
770 assert_eq!(reloaded, small);
771
772 let names: Vec<String> = std::fs::read_dir(dir.path())
773 .unwrap()
774 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
775 .collect();
776 assert_eq!(
777 names,
778 vec!["models.json".to_string()],
779 "atomic save must leave only the target file, found: {names:?}"
780 );
781 }
782
783 #[test]
784 fn corrupt_catalog_is_quarantined_not_overwritten() {
785 let dir = tempfile::tempdir().unwrap();
790 let path = dir.path().join("models.json");
791 let operator_bytes = b"{ this is my hand-edited catalog, now corrupt";
792 std::fs::write(&path, operator_bytes).unwrap();
793
794 let logs = crate::test_support::capture({
795 let path = path.clone();
796 move || {
797 let recovered = Catalog::load_or_seed(&path).unwrap();
798 assert_eq!(recovered, Catalog::seed(), "reseeded in place");
799 }
800 });
801
802 let quarantined: Vec<_> = std::fs::read_dir(dir.path())
804 .unwrap()
805 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
806 .filter(|n| n.starts_with("models.json.corrupt-"))
807 .collect();
808 assert_eq!(quarantined.len(), 1, "exactly one quarantine file");
809 assert_eq!(
810 std::fs::read(dir.path().join(&quarantined[0])).unwrap(),
811 operator_bytes,
812 "the operator's bytes must survive verbatim"
813 );
814 assert_eq!(Catalog::load_or_seed(&path).unwrap(), Catalog::seed());
816 assert!(logs.contains("quarantined"), "got: {logs}");
818 assert!(logs.contains("models.json.corrupt-"), "got: {logs}");
819 }
820
821 #[test]
822 fn load_for_serving_keeps_the_path_after_quarantine_recovery() {
823 let dir = tempfile::tempdir().unwrap();
824 let path = dir.path().join("models.json");
825 std::fs::write(&path, b"not json").unwrap();
826 let (catalog, save_path) = Catalog::load_for_serving(Some(path.clone()));
827 assert_eq!(catalog, Catalog::seed());
828 assert_eq!(
829 save_path,
830 Some(path),
831 "a quarantined-and-reseeded file is healthy; persistence stays on"
832 );
833 }
834
835 #[test]
836 fn load_for_serving_disables_persistence_when_the_file_is_unreadable() {
837 let dir = tempfile::tempdir().unwrap();
841 let path = dir.path().join("models.json");
842 std::fs::create_dir(&path).unwrap();
843 let (catalog, save_path) = Catalog::load_for_serving(Some(path));
844 assert_eq!(catalog, Catalog::seed());
845 assert_eq!(
846 save_path, None,
847 "an unreadable catalog must not be writable"
848 );
849 }
850
851 #[test]
852 fn load_for_serving_without_a_path_serves_the_seed() {
853 let (catalog, save_path) = Catalog::load_for_serving(None);
854 assert_eq!(catalog, Catalog::seed());
855 assert_eq!(save_path, None);
856 }
857
858 fn with_id(mut m: CatalogModel, id: &str) -> CatalogModel {
865 m.id = id.to_string();
866 m
867 }
868
869 fn studio_model(id: &str) -> CatalogModel {
870 with_id(
871 CatalogModel {
872 origin: "studio".into(),
873 ..zimage_turbo()
874 },
875 id,
876 )
877 }
878
879 #[test]
880 fn sync_adds_a_new_studio_model_and_reports_change() {
881 let mut cat = Catalog::default();
882 assert!(cat.sync_studio_model(studio_model("m1")));
883 assert_eq!(cat.get("m1").unwrap().origin, "studio");
884 assert!(!cat.sync_studio_model(studio_model("m1")));
886 }
887
888 #[test]
889 fn sync_refreshes_a_changed_studio_model() {
890 let mut cat = Catalog::default();
891 cat.sync_studio_model(studio_model("m1"));
892 let mut updated = studio_model("m1");
893 updated.display_name = "Renamed by studio".into();
894 assert!(cat.sync_studio_model(updated));
895 assert_eq!(cat.get("m1").unwrap().display_name, "Renamed by studio");
896 }
897
898 #[test]
899 fn sync_never_clobbers_a_local_origin_entry() {
900 let mut cat = Catalog::default();
901 let mut local = with_id(zimage_turbo(), "m1");
902 local.display_name = "my hand-tuned model".into();
903 cat.upsert(local);
905 assert!(!cat.sync_studio_model(studio_model("m1")));
907 assert_eq!(cat.get("m1").unwrap().display_name, "my hand-tuned model");
908 assert_eq!(cat.get("m1").unwrap().origin, "local");
909 }
910}