1use super::{ModelOrigin, build_new_model};
4use crate::domain::{Model, NewModel};
5use crate::ports::{CoreError, GgufParserPort, ModelRepository, RepositoryError};
6use std::path::Path;
7use std::sync::Arc;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum ImportMode {
16 #[default]
23 Fresh,
24 Refresh,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct RetagDiff {
51 pub added: Vec<String>,
53 pub removed: Vec<String>,
55 pub spec_changed: bool,
57}
58
59impl RetagDiff {
60 pub const fn is_changed(&self) -> bool {
62 !self.added.is_empty() || !self.removed.is_empty() || self.spec_changed
63 }
64}
65
66pub struct ModelService {
72 repo: Arc<dyn ModelRepository>,
73}
74
75impl ModelService {
76 pub fn new(repo: Arc<dyn ModelRepository>) -> Self {
78 Self { repo }
79 }
80
81 pub async fn list(&self) -> Result<Vec<Model>, CoreError> {
83 self.repo.list().await.map_err(CoreError::from)
84 }
85
86 pub async fn get(&self, identifier: &str) -> Result<Option<Model>, CoreError> {
92 self.repo
93 .get_by_identifier(identifier)
94 .await
95 .map_err(CoreError::from)
96 }
97
98 pub async fn get_by_id(&self, id: i64) -> Result<Option<Model>, CoreError> {
100 match self.repo.get_by_id(id).await {
101 Ok(model) => Ok(Some(model)),
102 Err(RepositoryError::NotFound(_)) => Ok(None),
103 Err(e) => Err(CoreError::from(e)),
104 }
105 }
106
107 pub async fn find_by_identifier(&self, identifier: &str) -> Result<Model, CoreError> {
110 self.get(identifier)
111 .await?
112 .ok_or_else(|| CoreError::Validation(format!("Model not found: {identifier}")))
113 }
114
115 pub async fn add(&self, model: NewModel) -> Result<Model, CoreError> {
124 self.repo.insert(&model).await.map_err(CoreError::from)
125 }
126
127 pub async fn import_from_file(
178 &self,
179 file_path: &Path,
180 gguf_parser: &dyn GgufParserPort,
181 param_count_override: Option<f64>,
182 mode: ImportMode,
183 ) -> Result<Model, CoreError> {
184 let gguf_metadata = crate::utils::validation::validate_and_parse_gguf(
186 gguf_parser,
187 file_path
188 .to_str()
189 .ok_or_else(|| CoreError::Validation("Invalid file path encoding".to_string()))?,
190 )
191 .map_err(|e| CoreError::Validation(format!("GGUF validation failed: {e}")))?;
192
193 let resolved = crate::paths::canonical_model_path(file_path).map_err(|e| {
198 CoreError::Validation(format!(
199 "Cannot resolve '{}' to a canonical path: {e}",
200 file_path.display()
201 ))
202 })?;
203
204 let existing = self
216 .repo
217 .find_by_path(&resolved)
218 .await
219 .map_err(CoreError::from)?;
220
221 if mode == ImportMode::Fresh
222 && let Some(existing) = &existing
223 {
224 return Err(CoreError::Repository(RepositoryError::AlreadyExists(
225 format!(
226 "'{}' is already in the library as \"{}\"",
227 file_path.display(),
228 existing.name
229 ),
230 )));
231 }
232
233 if let Some(existing) = &existing {
250 let existing_primary = crate::paths::canonical_model_path_string(&existing.file_path);
251 if existing_primary != resolved.to_string_lossy() {
252 return Err(CoreError::Validation(format!(
253 "'{}' belongs to \"{}\", which is registered under '{}'. \
254 Re-import that path instead — refreshing a sharded model from \
255 anything but its first shard would repoint it at a file it \
256 cannot be loaded from.",
257 file_path.display(),
258 existing.name,
259 existing_primary
260 )));
261 }
262 }
263
264 let origin = ModelOrigin::LocalFile {
276 param_count_override,
277 };
278 let mut new_model = build_new_model(
279 file_path,
280 Some(&gguf_metadata),
281 gguf_parser,
282 &origin,
283 chrono::Utc::now(),
284 );
285
286 new_model.file_path = resolved;
289
290 if let Some(existing) = &existing {
303 new_model.hf_repo_id.clone_from(&existing.hf_repo_id);
304 new_model.hf_commit_sha.clone_from(&existing.hf_commit_sha);
305 new_model.hf_filename.clone_from(&existing.hf_filename);
306 }
307
308 self.repo.insert(&new_model).await.map_err(CoreError::from)
310 }
311
312 pub async fn find_by_path(&self, file_path: &Path) -> Result<Option<Model>, CoreError> {
324 let resolved = crate::paths::canonical_model_path(file_path).map_err(|e| {
325 CoreError::Validation(format!(
326 "Cannot resolve '{}' to a canonical path: {e}",
327 file_path.display()
328 ))
329 })?;
330 self.repo
331 .find_by_path(&resolved)
332 .await
333 .map_err(CoreError::from)
334 }
335
336 pub async fn update(&self, model: &Model) -> Result<(), CoreError> {
338 self.repo.update(model).await.map_err(CoreError::from)
339 }
340
341 pub async fn delete(&self, id: i64) -> Result<(), CoreError> {
343 self.repo.delete(id).await.map_err(CoreError::from)
344 }
345
346 pub async fn list_tags(&self) -> Result<Vec<String>, CoreError> {
352 let models = self.repo.list().await.map_err(CoreError::from)?;
353 let mut all_tags = std::collections::HashSet::new();
354 for model in models {
355 for tag in model.tags {
356 all_tags.insert(tag);
357 }
358 }
359 let mut tags: Vec<String> = all_tags.into_iter().collect();
360 tags.sort();
361 Ok(tags)
362 }
363
364 pub async fn add_tag(&self, model_id: i64, tag: String) -> Result<(), CoreError> {
368 let mut model = self
369 .repo
370 .get_by_id(model_id)
371 .await
372 .map_err(CoreError::from)?;
373 if !model.tags.contains(&tag) {
374 model.tags.push(tag);
375 model.tags.sort();
376 self.repo.update(&model).await.map_err(CoreError::from)?;
377 }
378 Ok(())
379 }
380
381 pub async fn remove_tag(&self, model_id: i64, tag: &str) -> Result<(), CoreError> {
388 if crate::domain::is_system_tag(tag) {
389 return Err(CoreError::Validation(format!(
390 "tag '{tag}' is a system tag and cannot be removed via the standard API",
391 )));
392 }
393 self.remove_tag_force(model_id, tag).await
394 }
395
396 pub async fn remove_tag_force(&self, model_id: i64, tag: &str) -> Result<(), CoreError> {
403 let mut model = self
404 .repo
405 .get_by_id(model_id)
406 .await
407 .map_err(CoreError::from)?;
408 model.tags.retain(|t| t != tag);
409 self.repo.update(&model).await.map_err(CoreError::from)?;
410 Ok(())
411 }
412
413 pub async fn get_tags(&self, model_id: i64) -> Result<Vec<String>, CoreError> {
415 let model = self
416 .repo
417 .get_by_id(model_id)
418 .await
419 .map_err(CoreError::from)?;
420 Ok(model.tags)
421 }
422
423 pub async fn get_filter_options(&self) -> Result<crate::domain::ModelFilterOptions, CoreError> {
435 use crate::domain::{ModelFilterOptions, RangeValues};
436 use std::collections::HashSet;
437
438 let models = self.repo.list().await.map_err(CoreError::from)?;
439
440 let mut quantizations: Vec<String> = models
442 .iter()
443 .filter_map(|m| m.quantization.clone())
444 .filter(|q| !q.is_empty())
445 .collect::<HashSet<_>>()
446 .into_iter()
447 .collect();
448 quantizations.sort();
449
450 let param_range = if models.is_empty() {
452 None
453 } else {
454 let min = models
455 .iter()
456 .map(|m| m.param_count_b)
457 .fold(f64::INFINITY, f64::min);
458 let max = models
459 .iter()
460 .map(|m| m.param_count_b)
461 .fold(f64::NEG_INFINITY, f64::max);
462 if min.is_finite() && max.is_finite() {
463 Some(RangeValues { min, max })
464 } else {
465 None
466 }
467 };
468
469 let context_lengths: Vec<u64> = models.iter().filter_map(|m| m.context_length).collect();
471 #[allow(clippy::cast_precision_loss)]
472 let context_range = if context_lengths.is_empty() {
473 None
474 } else {
475 let min = *context_lengths.iter().min().unwrap() as f64;
476 let max = *context_lengths.iter().max().unwrap() as f64;
477 Some(RangeValues { min, max })
478 };
479
480 let tps_values: Vec<f64> = models
482 .iter()
483 .filter_map(|m| m.benchmark_summary.as_ref()?.latest_tg_tps)
484 .collect();
485 let speed_range = if tps_values.is_empty() {
486 None
487 } else {
488 let min = tps_values.iter().copied().fold(f64::INFINITY, f64::min);
489 let max = tps_values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
490 if min.is_finite() && max.is_finite() {
491 Some(RangeValues { min, max })
492 } else {
493 None
494 }
495 };
496
497 Ok(ModelFilterOptions {
498 quantizations,
499 param_range,
500 context_range,
501 speed_range,
502 })
503 }
504
505 pub async fn bootstrap_capabilities(&self) -> Result<(), CoreError> {
518 use crate::domain::{capabilities_from_architecture, infer_from_chat_template};
519
520 let models = self.repo.list().await.map_err(CoreError::from)?;
521
522 for mut model in models {
523 if model.capabilities.is_empty() {
525 let template = model.metadata.get("tokenizer.chat_template");
526 let name = model.metadata.get("general.name");
527 let arch = model.metadata.get("general.architecture");
528 let from_template = infer_from_chat_template(
529 template.map(String::as_str),
530 name.map(String::as_str),
531 );
532 let from_arch = capabilities_from_architecture(arch.map(String::as_str));
533 model.capabilities = from_template | from_arch;
534 self.repo.update(&model).await.map_err(CoreError::from)?;
535 }
536 }
537
538 Ok(())
539 }
540
541 pub async fn retag_model(
561 &self,
562 model_id: i64,
563 gguf_parser: &dyn GgufParserPort,
564 full: bool,
565 ) -> Result<Option<RetagDiff>, CoreError> {
566 let mut model = self
567 .repo
568 .get_by_id(model_id)
569 .await
570 .map_err(CoreError::from)?;
571
572 let gguf_metadata = crate::domain::gguf::GgufMetadata {
575 metadata: model.metadata.clone(),
576 ..Default::default()
577 };
578 let caps = gguf_parser.detect_capabilities(&gguf_metadata);
579 let new_tags = caps.to_tags();
580
581 let new_spec = caps.dialect;
585 let spec_changed = if full {
586 let changed = model.dialect_spec != new_spec;
587 model.dialect_spec = new_spec;
588 changed
589 } else if model.dialect_spec.is_none() && new_spec.is_some() {
590 model.dialect_spec = new_spec;
591 true
592 } else {
593 false
594 };
595
596 let before: std::collections::BTreeSet<String> = model.tags.iter().cloned().collect();
597
598 if full {
599 model.tags.retain(|t| {
604 !crate::domain::capability_tags::ALL.contains(&t.as_str())
605 && !crate::domain::is_system_tag(t)
606 });
607 }
608
609 for t in &new_tags {
610 if !model.tags.contains(t) {
611 model.tags.push(t.clone());
612 }
613 }
614 model.tags.sort();
615
616 let after: std::collections::BTreeSet<String> = model.tags.iter().cloned().collect();
617 if after == before && !spec_changed {
618 return Ok(None);
619 }
620
621 self.repo.update(&model).await.map_err(CoreError::from)?;
622 Ok(Some(RetagDiff {
623 added: after.difference(&before).cloned().collect(),
624 removed: before.difference(&after).cloned().collect(),
625 spec_changed,
626 }))
627 }
628}
629
630#[cfg(test)]
631#[allow(clippy::float_cmp)] mod tests {
633 use super::*;
634 use crate::ports::{ModelRepository, RepositoryError};
635 use async_trait::async_trait;
636 use chrono::Utc;
637
638 use std::path::PathBuf;
639 use std::sync::Mutex;
640
641 struct MockRepo {
642 models: Mutex<Vec<Model>>,
643 }
644
645 impl MockRepo {
646 fn new() -> Self {
647 Self {
648 models: Mutex::new(vec![]),
649 }
650 }
651 }
652
653 #[async_trait]
654 impl ModelRepository for MockRepo {
655 async fn list(&self) -> Result<Vec<Model>, RepositoryError> {
656 Ok(self.models.lock().unwrap().clone())
657 }
658
659 async fn get_by_id(&self, id: i64) -> Result<Model, RepositoryError> {
660 self.models
661 .lock()
662 .unwrap()
663 .iter()
664 .find(|m| m.id == id)
665 .cloned()
666 .ok_or_else(|| RepositoryError::NotFound(format!("id={id}")))
667 }
668
669 async fn get_by_name(&self, name: &str) -> Result<Model, RepositoryError> {
670 self.models
671 .lock()
672 .unwrap()
673 .iter()
674 .find(|m| m.name == name)
675 .cloned()
676 .ok_or_else(|| RepositoryError::NotFound(format!("name={name}")))
677 }
678
679 async fn find_by_path(&self, path: &Path) -> Result<Option<Model>, RepositoryError> {
680 Ok(self
681 .models
682 .lock()
683 .unwrap()
684 .iter()
685 .find(|m| m.file_path.as_path() == path)
686 .cloned())
687 }
688
689 #[allow(clippy::cast_possible_wrap, clippy::significant_drop_tightening)]
690 async fn insert(&self, model: &NewModel) -> Result<Model, RepositoryError> {
691 let mut models = self.models.lock().unwrap();
692 let existing = models.iter().position(|m| m.file_path == model.file_path);
708 let id = existing.map_or(models.len() as i64 + 1, |i| models[i].id);
709 let created = Model {
710 dialect_spec: model.dialect_spec.clone(),
711 id,
712 name: model.name.clone(),
713 model_key: String::new(),
714 file_path: model.file_path.clone(),
715 param_count_b: model.param_count_b,
716 architecture: model.architecture.clone(),
717 quantization: model.quantization.clone(),
718 context_length: model.context_length,
719 expert_count: model.expert_count,
720 expert_used_count: model.expert_used_count,
721 expert_shared_count: model.expert_shared_count,
722 metadata: model.metadata.clone(),
723 added_at: model.added_at,
724 hf_repo_id: model.hf_repo_id.clone(),
725 hf_commit_sha: model.hf_commit_sha.clone(),
726 hf_filename: model.hf_filename.clone(),
727 download_date: model.download_date,
728 last_update_check: model.last_update_check,
729 tags: model.tags.clone(),
730 capabilities: model.capabilities,
731 inference_defaults: model.inference_defaults.clone(),
732 defaults_origin: model.defaults_origin,
733 server_defaults: model.server_defaults.clone(),
734 template_caps: None,
735 benchmark_summary: None,
736 };
737 if let Some(index) = existing {
738 models[index] = created.clone();
739 } else {
740 models.push(created.clone());
741 }
742 Ok(created)
743 }
744
745 async fn update(&self, model: &Model) -> Result<(), RepositoryError> {
746 let mut models = self.models.lock().unwrap();
747 models.iter_mut().find(|m| m.id == model.id).map_or_else(
748 || Err(RepositoryError::NotFound(format!("id={}", model.id))),
749 |m| {
750 m.clone_from(model);
751 Ok(())
752 },
753 )
754 }
755
756 async fn delete(&self, id: i64) -> Result<(), RepositoryError> {
757 let mut models = self.models.lock().unwrap();
758 let len_before = models.len();
759 models.retain(|m| m.id != id);
760 if models.len() == len_before {
761 Err(RepositoryError::NotFound(format!("id={id}")))
762 } else {
763 Ok(())
764 }
765 }
766 }
767
768 #[tokio::test]
769 async fn test_import_from_file_names_from_stem() {
770 let repo = Arc::new(MockRepo::new());
771 let service = ModelService::new(repo);
772
773 let dir = tempfile::tempdir().unwrap();
774 let path = dir.path().join("Qwen3-8B-Q4_K_M.gguf");
775 std::fs::File::create(&path).unwrap();
776
777 let model = service
778 .import_from_file(
779 &path,
780 &crate::ports::NoopGgufParser,
781 None,
782 ImportMode::Fresh,
783 )
784 .await
785 .unwrap();
786
787 assert_eq!(model.name, "Qwen3-8B-Q4_K_M");
788 assert_eq!(model.hf_repo_id, None);
789 }
790
791 #[tokio::test]
798 async fn importing_the_same_file_twice_is_a_conflict() {
799 let repo = Arc::new(MockRepo::new());
800 let service = ModelService::new(repo);
801
802 let dir = tempfile::tempdir().unwrap();
803 let path = dir.path().join("Qwen3-8B-Q4_K_M.gguf");
804 std::fs::File::create(&path).unwrap();
805
806 service
807 .import_from_file(
808 &path,
809 &crate::ports::NoopGgufParser,
810 None,
811 ImportMode::Fresh,
812 )
813 .await
814 .expect("first add succeeds");
815
816 let err = service
817 .import_from_file(
818 &path,
819 &crate::ports::NoopGgufParser,
820 None,
821 ImportMode::Fresh,
822 )
823 .await
824 .expect_err("second add is a conflict");
825
826 assert!(
827 matches!(
828 err,
829 CoreError::Repository(RepositoryError::AlreadyExists(_))
830 ),
831 "expected AlreadyExists, got {err:?}"
832 );
833 }
834
835 #[tokio::test]
840 async fn refresh_re_imports_a_file_already_in_the_library() {
841 let repo = Arc::new(MockRepo::new());
842 let service = ModelService::new(repo);
843
844 let dir = tempfile::tempdir().unwrap();
845 let path = dir.path().join("Qwen3-8B-Q4_K_M.gguf");
846 std::fs::File::create(&path).unwrap();
847
848 let first = service
849 .import_from_file(
850 &path,
851 &crate::ports::NoopGgufParser,
852 None,
853 ImportMode::Fresh,
854 )
855 .await
856 .expect("first add succeeds");
857
858 let refreshed = service
859 .import_from_file(
860 &path,
861 &crate::ports::NoopGgufParser,
862 None,
863 ImportMode::Refresh,
864 )
865 .await
866 .expect("--reimport re-imports rather than refusing");
867
868 assert_eq!(
869 refreshed.id, first.id,
870 "a refresh updates the row in place; it does not create a second"
871 );
872 assert_eq!(service.list().await.unwrap().len(), 1);
873 }
874
875 #[tokio::test]
879 async fn find_by_path_agrees_with_the_import_guard() {
880 let repo = Arc::new(MockRepo::new());
881 let service = ModelService::new(repo);
882
883 let dir = tempfile::tempdir().unwrap();
884 let path = dir.path().join("Qwen3-8B-Q4_K_M.gguf");
885 std::fs::File::create(&path).unwrap();
886
887 assert!(
888 service.find_by_path(&path).await.unwrap().is_none(),
889 "nothing is registered yet"
890 );
891
892 service
893 .import_from_file(
894 &path,
895 &crate::ports::NoopGgufParser,
896 None,
897 ImportMode::Fresh,
898 )
899 .await
900 .unwrap();
901
902 assert!(
903 service.find_by_path(&path).await.unwrap().is_some(),
904 "the file the import just refused to duplicate must be findable"
905 );
906 }
907
908 #[tokio::test]
912 async fn find_by_path_reports_an_unresolvable_path_instead_of_no_duplicate() {
913 let repo = Arc::new(MockRepo::new());
914 let service = ModelService::new(repo);
915
916 let dir = tempfile::tempdir().unwrap();
917 let err = service
918 .find_by_path(&dir.path().join("Absent.gguf"))
919 .await
920 .expect_err("a path that does not resolve is not 'no duplicate'");
921
922 assert!(matches!(err, CoreError::Validation(_)), "got {err:?}");
923 }
924
925 struct SiblingMatchRepo(MockRepo);
929
930 #[async_trait]
931 impl ModelRepository for SiblingMatchRepo {
932 async fn list(&self) -> Result<Vec<Model>, RepositoryError> {
933 self.0.list().await
934 }
935 async fn get_by_id(&self, id: i64) -> Result<Model, RepositoryError> {
936 self.0.get_by_id(id).await
937 }
938 async fn get_by_name(&self, name: &str) -> Result<Model, RepositoryError> {
939 self.0.get_by_name(name).await
940 }
941 async fn find_by_path(&self, _path: &Path) -> Result<Option<Model>, RepositoryError> {
942 Ok(self.0.list().await?.into_iter().next())
943 }
944 async fn insert(&self, model: &NewModel) -> Result<Model, RepositoryError> {
945 self.0.insert(model).await
946 }
947 async fn update(&self, model: &Model) -> Result<(), RepositoryError> {
948 self.0.update(model).await
949 }
950 async fn delete(&self, id: i64) -> Result<(), RepositoryError> {
951 self.0.delete(id).await
952 }
953 }
954
955 #[tokio::test]
965 async fn a_refresh_is_accepted_when_the_stored_path_is_only_spelled_differently() {
966 let dir = tempfile::tempdir().unwrap();
967 let file = dir.path().join("Solo.gguf");
968 std::fs::File::create(&file).unwrap();
969 std::fs::create_dir(dir.path().join("sub")).unwrap();
970 let respelled = dir.path().join("sub").join("..").join("Solo.gguf");
971
972 let inner = MockRepo::new();
975 inner
976 .insert(&NewModel::new(
977 "Solo".to_string(),
978 respelled,
979 7.0,
980 Utc::now(),
981 ))
982 .await
983 .unwrap();
984
985 let service = ModelService::new(Arc::new(SiblingMatchRepo(inner)));
986 let outcome = service
987 .import_from_file(
988 &file,
989 &crate::ports::NoopGgufParser,
990 None,
991 ImportMode::Refresh,
992 )
993 .await;
994
995 assert!(
1003 outcome.is_ok(),
1004 "the row names this very file, however it was spelled: {:?}",
1005 outcome.err()
1006 );
1007 }
1008
1009 #[tokio::test]
1014 async fn a_refresh_is_refused_when_the_located_row_names_another_file() {
1015 let dir = tempfile::tempdir().unwrap();
1016 let first = dir.path().join("m-00001-of-00002.gguf");
1017 let second = dir.path().join("m-00002-of-00002.gguf");
1018 std::fs::File::create(&first).unwrap();
1019 std::fs::File::create(&second).unwrap();
1020
1021 let inner = MockRepo::new();
1022 inner
1023 .insert(&NewModel::new(
1024 "Sharded".to_string(),
1025 first,
1026 7.0,
1027 Utc::now(),
1028 ))
1029 .await
1030 .unwrap();
1031
1032 let service = ModelService::new(Arc::new(SiblingMatchRepo(inner)));
1033 let err = service
1034 .import_from_file(
1035 &second,
1036 &crate::ports::NoopGgufParser,
1037 None,
1038 ImportMode::Refresh,
1039 )
1040 .await
1041 .expect_err("shard 2 must not repoint the shard-1 row");
1042
1043 assert!(matches!(err, CoreError::Validation(_)), "got {err:?}");
1044 }
1045
1046 #[tokio::test]
1049 async fn a_different_file_is_not_a_conflict() {
1050 let repo = Arc::new(MockRepo::new());
1051 let service = ModelService::new(repo);
1052
1053 let dir = tempfile::tempdir().unwrap();
1054 for name in ["a.gguf", "b.gguf"] {
1055 let path = dir.path().join(name);
1056 std::fs::File::create(&path).unwrap();
1057 service
1058 .import_from_file(
1059 &path,
1060 &crate::ports::NoopGgufParser,
1061 None,
1062 ImportMode::Fresh,
1063 )
1064 .await
1065 .unwrap_or_else(|e| panic!("{name} should import: {e:?}"));
1066 }
1067 }
1068
1069 #[tokio::test]
1070 async fn test_import_from_file_missing_path_is_validation_error() {
1071 let repo = Arc::new(MockRepo::new());
1072 let service = ModelService::new(repo);
1073
1074 let err = service
1075 .import_from_file(
1076 Path::new("/nonexistent/model.gguf"),
1077 &crate::ports::NoopGgufParser,
1078 None,
1079 ImportMode::Fresh,
1080 )
1081 .await
1082 .unwrap_err();
1083 assert!(matches!(err, CoreError::Validation(_)));
1084 }
1085
1086 #[tokio::test]
1087 async fn test_import_from_file_wrong_extension_is_validation_error() {
1088 let repo = Arc::new(MockRepo::new());
1089 let service = ModelService::new(repo);
1090
1091 let dir = tempfile::tempdir().unwrap();
1092 let path = dir.path().join("model.bin");
1093 std::fs::File::create(&path).unwrap();
1094
1095 let err = service
1096 .import_from_file(
1097 &path,
1098 &crate::ports::NoopGgufParser,
1099 None,
1100 ImportMode::Fresh,
1101 )
1102 .await
1103 .unwrap_err();
1104 assert!(matches!(err, CoreError::Validation(_)));
1105 }
1106
1107 #[tokio::test]
1108 async fn test_import_from_file_param_override_reaches_new_model() {
1109 let repo = Arc::new(MockRepo::new());
1110 let service = ModelService::new(repo);
1111
1112 let dir = tempfile::tempdir().unwrap();
1113 let path = dir.path().join("model.gguf");
1114 std::fs::File::create(&path).unwrap();
1115
1116 let model = service
1117 .import_from_file(
1118 &path,
1119 &crate::ports::NoopGgufParser,
1120 Some(13.0),
1121 ImportMode::Fresh,
1122 )
1123 .await
1124 .unwrap();
1125
1126 assert_eq!(model.param_count_b, 13.0);
1127 }
1128
1129 #[tokio::test]
1130 async fn test_list_empty() {
1131 let repo = Arc::new(MockRepo::new());
1132 let service = ModelService::new(repo);
1133 let models = service.list().await.unwrap();
1134 assert!(models.is_empty());
1135 }
1136
1137 #[tokio::test]
1138 async fn test_add_and_get() {
1139 let repo = Arc::new(MockRepo::new());
1140 let service = ModelService::new(repo);
1141
1142 let new_model = NewModel::new(
1143 "test-model".to_string(),
1144 PathBuf::from("/path/to/model.gguf"),
1145 7.0,
1146 Utc::now(),
1147 );
1148
1149 let created = service.add(new_model).await.unwrap();
1150 assert_eq!(created.name, "test-model");
1151
1152 let found = service.get("test-model").await.unwrap();
1153 assert!(found.is_some());
1154 assert_eq!(found.unwrap().id, created.id);
1155 }
1156
1157 #[tokio::test]
1158 async fn test_find_by_identifier_not_found() {
1159 let repo = Arc::new(MockRepo::new());
1160 let service = ModelService::new(repo);
1161
1162 let result = service.find_by_identifier("nonexistent").await;
1163 assert!(result.is_err());
1164 }
1165
1166 #[tokio::test]
1167 async fn test_get_filter_options_empty() {
1168 let repo = Arc::new(MockRepo::new());
1169 let service = ModelService::new(repo);
1170
1171 let options = service.get_filter_options().await.unwrap();
1172 assert!(options.quantizations.is_empty());
1173 assert!(options.param_range.is_none());
1174 assert!(options.context_range.is_none());
1175 }
1176
1177 #[tokio::test]
1178 async fn test_get_filter_options_with_models() {
1179 let repo = Arc::new(MockRepo::new());
1180 let service = ModelService::new(repo);
1181
1182 let mut model1 = NewModel::new(
1184 "model-1".to_string(),
1185 PathBuf::from("/path/to/model1.gguf"),
1186 7.0,
1187 Utc::now(),
1188 );
1189 model1.quantization = Some("Q4_K_M".to_string());
1190 model1.context_length = Some(4096);
1191
1192 let mut model2 = NewModel::new(
1193 "model-2".to_string(),
1194 PathBuf::from("/path/to/model2.gguf"),
1195 13.0,
1196 Utc::now(),
1197 );
1198 model2.quantization = Some("Q8_0".to_string());
1199 model2.context_length = Some(8192);
1200
1201 let mut model3 = NewModel::new(
1202 "model-3".to_string(),
1203 PathBuf::from("/path/to/model3.gguf"),
1204 70.0,
1205 Utc::now(),
1206 );
1207 model3.quantization = Some("Q4_K_M".to_string()); service.add(model1).await.unwrap();
1211 service.add(model2).await.unwrap();
1212 service.add(model3).await.unwrap();
1213
1214 let options = service.get_filter_options().await.unwrap();
1215
1216 assert_eq!(options.quantizations, vec!["Q4_K_M", "Q8_0"]);
1218
1219 let param_range = options.param_range.unwrap();
1221 assert!((param_range.min - 7.0).abs() < 0.001);
1222 assert!((param_range.max - 70.0).abs() < 0.001);
1223
1224 let context_range = options.context_range.unwrap();
1226 assert!((context_range.min - 4096.0).abs() < 0.001);
1227 assert!((context_range.max - 8192.0).abs() < 0.001);
1228 }
1229
1230 #[tokio::test]
1231 async fn test_remove_tag_rejects_system_tag() {
1232 let repo = Arc::new(MockRepo::new());
1233 let service = ModelService::new(repo);
1234
1235 let mut new_model = NewModel::new(
1236 "qwen-test".to_string(),
1237 PathBuf::from("/path/to/m.gguf"),
1238 7.0,
1239 Utc::now(),
1240 );
1241 new_model.tags = vec!["chat".to_string(), "format:qwen-xml".to_string()];
1242 let created = service.add(new_model).await.unwrap();
1243
1244 let err = service
1246 .remove_tag(created.id, "format:qwen-xml")
1247 .await
1248 .unwrap_err();
1249 assert!(matches!(err, CoreError::Validation(_)));
1250
1251 let tags = service.get_tags(created.id).await.unwrap();
1253 assert!(tags.contains(&"format:qwen-xml".to_string()));
1254
1255 service
1257 .remove_tag_force(created.id, "format:qwen-xml")
1258 .await
1259 .unwrap();
1260 let tags = service.get_tags(created.id).await.unwrap();
1261 assert!(!tags.contains(&"format:qwen-xml".to_string()));
1262 }
1263
1264 #[tokio::test]
1265 async fn test_remove_tag_allows_user_tag() {
1266 let repo = Arc::new(MockRepo::new());
1267 let service = ModelService::new(repo);
1268
1269 let mut new_model =
1270 NewModel::new("u".to_string(), PathBuf::from("/p.gguf"), 7.0, Utc::now());
1271 new_model.tags = vec!["chat".to_string(), "format:hermes".to_string()];
1272 let created = service.add(new_model).await.unwrap();
1273
1274 service.remove_tag(created.id, "chat").await.unwrap();
1275 let tags = service.get_tags(created.id).await.unwrap();
1276 assert_eq!(tags, vec!["format:hermes".to_string()]);
1277 }
1278
1279 struct StubCapsParser {
1281 tags: Vec<String>,
1282 spec: Option<crate::domain::DialectSpec>,
1283 }
1284
1285 impl crate::ports::GgufParserPort for StubCapsParser {
1286 fn parse(
1287 &self,
1288 _file_path: &std::path::Path,
1289 ) -> std::result::Result<crate::ports::GgufMetadata, crate::ports::GgufParseError> {
1290 Ok(crate::ports::GgufMetadata::default())
1291 }
1292
1293 fn detect_capabilities(
1294 &self,
1295 _metadata: &crate::ports::GgufMetadata,
1296 ) -> crate::ports::GgufCapabilities {
1297 let mut extensions = std::collections::BTreeSet::new();
1298 for t in &self.tags {
1299 extensions.insert(t.clone());
1300 }
1301 crate::ports::GgufCapabilities {
1302 flags: crate::domain::gguf::CapabilityFlags::empty(),
1303 extensions,
1304 dialect: self.spec.clone(),
1305 }
1306 }
1307 }
1308
1309 #[tokio::test]
1310 async fn test_retag_additive_appends_missing_tags() {
1311 let repo = Arc::new(MockRepo::new());
1312 let service = ModelService::new(repo);
1313
1314 let mut new_model =
1315 NewModel::new("m".to_string(), PathBuf::from("/p.gguf"), 7.0, Utc::now());
1316 new_model.tags = vec!["chat".to_string()];
1317 let created = service.add(new_model).await.unwrap();
1318
1319 let parser = StubCapsParser {
1320 tags: vec!["format:qwen-xml".to_string()],
1321 spec: None,
1322 };
1323 let diff = service
1324 .retag_model(created.id, &parser, false)
1325 .await
1326 .unwrap();
1327 assert_eq!(diff.unwrap().added, vec!["format:qwen-xml".to_string()]);
1328
1329 let tags = service.get_tags(created.id).await.unwrap();
1330 assert!(tags.contains(&"chat".to_string()));
1331 assert!(tags.contains(&"format:qwen-xml".to_string()));
1332 }
1333
1334 #[tokio::test]
1335 async fn test_retag_additive_noop_when_already_present() {
1336 let repo = Arc::new(MockRepo::new());
1337 let service = ModelService::new(repo);
1338
1339 let mut new_model =
1340 NewModel::new("m".to_string(), PathBuf::from("/p.gguf"), 7.0, Utc::now());
1341 new_model.tags = vec!["format:qwen-xml".to_string()];
1342 let created = service.add(new_model).await.unwrap();
1343
1344 let parser = StubCapsParser {
1345 tags: vec!["format:qwen-xml".to_string()],
1346 spec: None,
1347 };
1348 let diff = service
1349 .retag_model(created.id, &parser, false)
1350 .await
1351 .unwrap();
1352 assert!(diff.is_none());
1353 }
1354
1355 #[tokio::test]
1356 async fn test_retag_full_replaces_auto_tags_preserves_user() {
1357 let repo = Arc::new(MockRepo::new());
1358 let service = ModelService::new(repo);
1359
1360 let mut new_model =
1361 NewModel::new("m".to_string(), PathBuf::from("/p.gguf"), 7.0, Utc::now());
1362 new_model.tags = vec![
1363 "favorite".to_string(), "format:hermes".to_string(), "reasoning".to_string(), ];
1367 let created = service.add(new_model).await.unwrap();
1368
1369 let parser = StubCapsParser {
1370 tags: vec!["format:qwen-xml".to_string()],
1371 spec: None,
1372 };
1373 service
1374 .retag_model(created.id, &parser, true)
1375 .await
1376 .unwrap();
1377
1378 let tags = service.get_tags(created.id).await.unwrap();
1379 assert!(tags.contains(&"favorite".to_string()));
1380 assert!(tags.contains(&"format:qwen-xml".to_string()));
1381 assert!(!tags.contains(&"format:hermes".to_string()));
1382 assert!(!tags.contains(&"reasoning".to_string()));
1383 }
1384
1385 #[tokio::test]
1386 async fn test_retag_full_drops_stale_mtp_tag() {
1387 let repo = Arc::new(MockRepo::new());
1388 let service = ModelService::new(repo);
1389
1390 let mut new_model =
1391 NewModel::new("m".to_string(), PathBuf::from("/p.gguf"), 7.0, Utc::now());
1392 new_model.tags = vec!["mtp".to_string()]; let created = service.add(new_model).await.unwrap();
1394
1395 let parser = StubCapsParser {
1397 tags: Vec::new(),
1398 spec: None,
1399 };
1400 service
1401 .retag_model(created.id, &parser, true)
1402 .await
1403 .unwrap();
1404
1405 let tags = service.get_tags(created.id).await.unwrap();
1406 assert!(!tags.contains(&"mtp".to_string()));
1407 }
1408
1409 #[tokio::test]
1410 async fn test_retag_additive_fills_a_missing_spec() {
1411 let repo = Arc::new(MockRepo::new());
1412 let service = ModelService::new(repo.clone());
1413
1414 let mut new_model =
1415 NewModel::new("m".to_string(), PathBuf::from("/p.gguf"), 7.0, Utc::now());
1416 new_model.tags = vec!["format:qwen-xml".to_string()];
1417 let created = service.add(new_model).await.unwrap();
1418
1419 let parser = StubCapsParser {
1420 tags: vec!["format:qwen-xml".to_string()],
1421 spec: Some(crate::domain::DialectSpec::qwen_xml()),
1422 };
1423 let diff = service
1424 .retag_model(created.id, &parser, false)
1425 .await
1426 .unwrap()
1427 .expect("spec fill must count as a change");
1428 assert!(diff.spec_changed);
1429 assert!(diff.added.is_empty() && diff.removed.is_empty());
1430
1431 let model = service.get_by_id(created.id).await.unwrap().unwrap();
1432 assert_eq!(
1433 model.dialect_spec,
1434 Some(crate::domain::DialectSpec::qwen_xml())
1435 );
1436 }
1437
1438 #[tokio::test]
1439 async fn test_retag_additive_never_overwrites_an_existing_spec() {
1440 let repo = Arc::new(MockRepo::new());
1441 let service = ModelService::new(repo.clone());
1442
1443 let derived = crate::domain::DialectSpec {
1444 tool_open: "«TC»".to_string(),
1445 tool_close: "«/TC»".to_string(),
1446 ..crate::domain::DialectSpec::qwen_xml()
1447 };
1448 let mut new_model =
1449 NewModel::new("m".to_string(), PathBuf::from("/p.gguf"), 7.0, Utc::now());
1450 new_model.dialect_spec = Some(derived.clone());
1451 let created = service.add(new_model).await.unwrap();
1452
1453 let parser = StubCapsParser {
1454 tags: Vec::new(),
1455 spec: Some(crate::domain::DialectSpec::qwen_xml()),
1456 };
1457 let diff = service
1458 .retag_model(created.id, &parser, false)
1459 .await
1460 .unwrap();
1461 assert!(diff.is_none(), "additive retag must not rewrite a spec");
1462
1463 let model = service.get_by_id(created.id).await.unwrap().unwrap();
1464 assert_eq!(model.dialect_spec, Some(derived));
1465 }
1466
1467 #[tokio::test]
1468 async fn test_retag_full_rederives_and_can_clear_the_spec() {
1469 let repo = Arc::new(MockRepo::new());
1470 let service = ModelService::new(repo.clone());
1471
1472 let mut new_model =
1473 NewModel::new("m".to_string(), PathBuf::from("/p.gguf"), 7.0, Utc::now());
1474 new_model.dialect_spec = Some(crate::domain::DialectSpec::qwen_xml());
1475 let created = service.add(new_model).await.unwrap();
1476
1477 let parser = StubCapsParser {
1479 tags: Vec::new(),
1480 spec: None,
1481 };
1482 let diff = service
1483 .retag_model(created.id, &parser, true)
1484 .await
1485 .unwrap()
1486 .expect("clearing the spec is a change");
1487 assert!(diff.spec_changed);
1488
1489 let model = service.get_by_id(created.id).await.unwrap().unwrap();
1490 assert_eq!(model.dialect_spec, None);
1491 }
1492}