Skip to main content

gglib_core/services/
model_service.rs

1//! Model service - orchestrates model CRUD operations.
2
3use 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/// Whether an explicit import may overwrite a model already in the library.
10///
11/// Named rather than a bare `bool` because the call sites read very
12/// differently — `ImportMode::Refresh` says what it does, `true` does not,
13/// and the wrong value here silently rewrites a row's tags and capabilities.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum ImportMode {
16    /// Refuse when the file is already registered, reporting
17    /// [`RepositoryError::AlreadyExists`].
18    ///
19    /// The default, and what an "add this file to my library" means: a
20    /// duplicate is a conflict the caller should hear about, not an
21    /// overwrite performed on their behalf.
22    #[default]
23    Fresh,
24    /// Re-derive the model's *detected* metadata from the file, updating the
25    /// stored row in place and keeping its database id.
26    ///
27    /// This is what `gglib model add --reimport` asks for, and what re-importing
28    /// did unconditionally before [`ModelService::import_from_file`] began
29    /// guarding it. It refreshes tags, capabilities, quantization, context
30    /// length, the expert counts and the dialect spec — wider than `model
31    /// retag`, which rebuilds only tags and the dialect spec.
32    ///
33    /// It is narrower than "overwrite the row", and the six columns do not all
34    /// behave alike:
35    ///
36    /// - `tags`, `capabilities` and `dialect_spec` are **assigned**. A refresh
37    ///   replaces them with whatever was just derived, including with nothing
38    ///   — these can be cleared.
39    /// - `quantization`, `context_length` and the expert counts are
40    ///   **coalesced**. A detector that now reads no value leaves the stored
41    ///   one standing rather than emptying it.
42    /// - `name`, `param_count_b` and `architecture` are **absent** from the
43    ///   upsert entirely, so a name the user chose survives untouched.
44    Refresh,
45}
46
47/// The diff produced by [`ModelService::retag_model`] when at least one tag
48/// or the dialect spec changed.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct RetagDiff {
51    /// Tags that were newly added.
52    pub added: Vec<String>,
53    /// Tags that were removed (only non-empty on a `full = true` rebuild).
54    pub removed: Vec<String>,
55    /// Whether the persisted dialect spec was rewritten.
56    pub spec_changed: bool,
57}
58
59impl RetagDiff {
60    /// Returns `true` if any tag was added or removed, or the spec changed.
61    pub const fn is_changed(&self) -> bool {
62        !self.added.is_empty() || !self.removed.is_empty() || self.spec_changed
63    }
64}
65
66/// Service for model operations.
67///
68/// This service provides high-level model management by delegating
69/// to the injected `ModelRepository`. It adds no business logic
70/// beyond what the repository provides - it's a thin facade.
71pub struct ModelService {
72    repo: Arc<dyn ModelRepository>,
73}
74
75impl ModelService {
76    /// Create a new model service with the given repository.
77    pub fn new(repo: Arc<dyn ModelRepository>) -> Self {
78        Self { repo }
79    }
80
81    /// List all models.
82    pub async fn list(&self) -> Result<Vec<Model>, CoreError> {
83        self.repo.list().await.map_err(CoreError::from)
84    }
85
86    /// Get a model by its identifier (numeric database id, then exact name).
87    ///
88    /// Thin wrapper over [`ModelRepository::get_by_identifier`], which owns the
89    /// lookup-key policy so this service and the `ModelCatalogPort` adapter
90    /// cannot disagree about what a given string resolves to.
91    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    /// Get a model by its database ID.
99    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    /// Find a model by identifier — a numeric id, then an exact name (not an
108    /// HF id, despite what this said for a long time). Errors if not found.
109    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    /// Add a new model from an already-built row.
116    ///
117    /// This is the raw registration door: it inherits
118    /// [`ModelRepository::insert`]'s upsert, so a row whose key already exists
119    /// is overwritten rather than reported. That is right for
120    /// registration-after-download, which has to be safe to retry, and wrong
121    /// for "add this file to my library" — which is
122    /// [`Self::import_from_file`], the door that checks first.
123    pub async fn add(&self, model: NewModel) -> Result<Model, CoreError> {
124        self.repo.insert(&model).await.map_err(CoreError::from)
125    }
126
127    /// Import a model from a local GGUF file with full metadata extraction.
128    ///
129    /// Validates file, parses GGUF metadata, detects capabilities, and registers
130    /// with rich metadata. This is the canonical way to add local models.
131    ///
132    /// # Arguments
133    ///
134    /// * `file_path` - Absolute path to the GGUF file
135    /// * `gguf_parser` - Parser implementation for metadata extraction
136    /// * `param_count_override` - Optional user override for parameter count
137    /// * `mode` - Whether a file already in the library is a conflict
138    ///   ([`ImportMode::Fresh`]) or should be re-derived in place
139    ///   ([`ImportMode::Refresh`])
140    ///
141    /// # Returns
142    ///
143    /// Returns the registered `Model` with full metadata, or validation error.
144    ///
145    /// # Errors
146    ///
147    /// [`CoreError::Validation`] if the file is missing, is not a readable
148    /// GGUF, or cannot be resolved to a canonical path;
149    /// [`RepositoryError::AlreadyExists`] under [`ImportMode::Fresh`] when the
150    /// file is already registered.
151    ///
152    /// # Design
153    ///
154    /// This method validates and parses the GGUF file, then delegates
155    /// naming, capability detection, and tag generation to
156    /// [`build_new_model`] — the construction path shared with the
157    /// `HuggingFace` download path — before persisting the result.
158    ///
159    /// The path is resolved once here, immediately after validation has
160    /// established that the file exists, and the resolved form is what every
161    /// later step sees: the duplicate lookup, the row that gets built, and
162    /// the `model_key` derived from it. Normalising at the entry point rather
163    /// than at each consumer is what keeps those three from disagreeing about
164    /// which file is which.
165    ///
166    /// # Concurrency
167    ///
168    /// The duplicate check and the insert are separate statements with no
169    /// transaction around them, and `file_path` carries no unique index — only
170    /// `model_key` does. Two simultaneous adds of one file can therefore both
171    /// pass the check; the `ON CONFLICT(model_key)` clause still collapses
172    /// them onto a single row, so the library stays correct, but the loser is
173    /// told it added a model that in fact already existed rather than
174    /// receiving a conflict. Closing that would take a unique index on the
175    /// path or a transaction spanning both statements, and is not something
176    /// this guard attempts.
177    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        // 1. Validate and parse GGUF file
185        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        // 2. Resolve the path exactly once, now that validation has
194        //    established the file is there. Everything downstream uses the
195        //    resolved form, so no later step has to re-resolve — and none can
196        //    quietly disagree about what "the same file" means.
197        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        // 3. A file already in the library is a conflict, not a silent
205        //    overwrite. `insert` upserts so that registration after a download
206        //    can be retried; an explicit "add this file" is the opposite
207        //    intent, and without this check the caller gets a success response
208        //    for a model it did not add, with the existing row's tags and
209        //    capabilities rewritten underneath it.
210        //
211        //    `ImportMode::Refresh` is the caller stating that the overwrite is
212        //    what they came for — re-deriving a model's spec from the file
213        //    with newer detection logic. It is opt-in because it is
214        //    destructive, and unreachable by accident.
215        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        // A refresh has to be asked for by the model's *own* primary file.
234        //
235        // `find_by_path` also matches a sharded model through its sibling
236        // paths, and the row it hands back is the one keyed to shard 1. Left
237        // unchecked, refreshing from shard 2 would land on that row and
238        // `file_path = excluded.file_path` would repoint it at the shard-2
239        // file — which llama.cpp cannot open a split GGUF from, so the model
240        // would stop launching. Appending a stray row (the old behaviour) was
241        // survivable; destroying the good row is not.
242        //
243        // Both sides are resolved before comparing. Comparing the stored
244        // column directly would make this guard assume the column is already
245        // canonical — the very assumption that produced the bug this change
246        // exists to fix. A row still holding an unresolved path would then
247        // refuse a refresh of its *own* first shard, and say so by printing
248        // the same path twice.
249        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        // 4. Build the model row via the naming/capability/tag policy shared
265        //    with the HuggingFace download path.
266        //
267        //    Built from the path the caller gave, not the resolved one. The
268        //    derived name falls back to the file stem, so building from the
269        //    resolved path would silently rename a symlinked model to its
270        //    target's stem — `current.gguf -> Qwen3-8B.gguf` would stop being
271        //    "current" — and would disagree with the preview the CLI printed
272        //    from the path the user typed. Only the *stored* path is
273        //    canonical; the *derived* name still comes from what was asked
274        //    for.
275        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        // 5. Store and key the row by the resolved path, whatever spelling
287        //    was used to reach it.
288        new_model.file_path = resolved;
289
290        // 6. A refresh has to land on the row it is refreshing.
291        //
292        //    `build_new_model` was handed `ModelOrigin::LocalFile`, so it
293        //    carries no HuggingFace metadata and the row would be keyed
294        //    `local:<hash>`. A downloaded model is keyed `hf:<repo>@<sha>#<file>`.
295        //    Nothing would conflict, `file_path` carries no unique index, and
296        //    `--reimport` on a downloaded model would append a *second* row for
297        //    one file — the precise outcome this whole change exists to
298        //    prevent, reintroduced by the flag added to serve it.
299        //
300        //    Carrying the stored provenance forward keeps the computed key
301        //    equal to the existing row's, so the upsert updates it.
302        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        // 7. Persist to repository
309        self.repo.insert(&new_model).await.map_err(CoreError::from)
310    }
311
312    /// Look up the model registered under `file_path`, if any.
313    ///
314    /// Resolves the path the same way [`Self::import_from_file`] does, so a
315    /// caller can ask "is this already here?" before doing expensive or
316    /// interactive work and get the same answer the import would.
317    ///
318    /// # Errors
319    ///
320    /// [`CoreError::Validation`] if the path cannot be resolved, and
321    /// [`CoreError::Repository`] if the lookup itself fails. Neither is
322    /// reported as "no duplicate".
323    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    /// Update a model.
337    pub async fn update(&self, model: &Model) -> Result<(), CoreError> {
338        self.repo.update(model).await.map_err(CoreError::from)
339    }
340
341    /// Delete a model by ID.
342    pub async fn delete(&self, id: i64) -> Result<(), CoreError> {
343        self.repo.delete(id).await.map_err(CoreError::from)
344    }
345
346    // ─────────────────────────────────────────────────────────────────────────
347    // Tag Operations
348    // ─────────────────────────────────────────────────────────────────────────
349
350    /// List all unique tags used across all models.
351    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    /// Add a tag to a model.
365    ///
366    /// If the tag already exists on the model, this is a no-op.
367    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    /// Remove a tag from a model.
382    ///
383    /// If the tag doesn't exist on the model, this is a no-op. System tags
384    /// (see [`crate::domain::is_system_tag`]) are protected and cannot be
385    /// removed through this API — use [`Self::remove_tag_force`] for
386    /// admin/debug paths that intentionally need to drop them.
387    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    /// Force-remove a tag from a model, including system tags.
397    ///
398    /// Bypasses the system-tag protection enforced by [`Self::remove_tag`].
399    /// Intended for admin/debug paths (e.g. the `gglib model retag --full`
400    /// rebuild) where the caller intentionally needs to drop a `format:*`
401    /// tag before re-detecting capabilities.
402    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    /// Get all tags for a specific model.
414    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    // ─────────────────────────────────────────────────────────────────────────
424    // Filter/Aggregate Operations
425    // ─────────────────────────────────────────────────────────────────────────
426
427    /// Get filter options aggregated from all models.
428    ///
429    /// Returns distinct quantizations, parameter count range, and context length range
430    /// for use in the GUI filter popover.
431    ///
432    /// Note: Uses in-memory aggregation for simplicity. This is acceptable for typical
433    /// model libraries (<100 models). Revisit if libraries grow large.
434    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        // Collect distinct quantizations
441        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        // Compute param_count_b range
451        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        // Compute context_length range (only models with context_length set)
470        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        // Compute latest_tg_tps range across benchmarked models
481        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    // ─────────────────────────────────────────────────────────────────────────
506    // Capability Bootstrap
507    // ─────────────────────────────────────────────────────────────────────────
508
509    /// Backfill capabilities for models that don't have them set.
510    ///
511    /// This runs on startup to handle models with unknown capabilities.
512    /// Only infers if capabilities are empty (0/unknown).
513    ///
514    /// # INVARIANT
515    ///
516    /// Never overwrite explicitly-set capabilities. Only infer when unknown.
517    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            // Only infer if capabilities are unknown (empty)
524            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    // ─────────────────────────────────────────────────────────────────────────
542    // Retag
543    // ─────────────────────────────────────────────────────────────────────────
544
545    /// Re-derive auto-tags for a single model from its persisted GGUF metadata.
546    ///
547    /// `full = false` (default) is **additive**: any newly-detected tag that
548    /// isn't already present is appended; nothing is ever removed. This is
549    /// the safe path for backfilling `format:*` tags on models imported
550    /// before format-tag detection landed.
551    ///
552    /// `full = true` performs a full rebuild: every previously auto-generated
553    /// tag (the predefined capability tag namespace plus every existing
554    /// `format:*` tag) is dropped and the freshly-detected set is added in
555    /// its place. User-curated tags outside that namespace are preserved.
556    ///
557    /// Returns `None` when the tag set is unchanged (no write occurred) and
558    /// `Some(diff)` when the model was updated, carrying the full added/removed
559    /// delta.
560    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        // Re-derive capabilities from the persisted metadata blob; the file
573        // doesn't have to exist on disk.
574        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        // Spec semantics mirror the tag semantics: additive mode only fills
582        // a missing spec, `--full` re-derives unconditionally — including
583        // clearing a spec that is no longer derivable.
584        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            // Drop every tag in the auto-generated namespace, then re-add.
600            // The list lives with the constants that produce it: a tag missing
601            // from it survives a refresh forever, silently keeping a
602            // capability the model no longer has.
603            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)] // exact literal round-trip through param_count_b, no lossy conversion
632mod 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            // NOTE: this double keys on `file_path`; `SqliteModelRepository`
693            // keys on `model_key`. They agree for a plain local add and
694            // diverge whenever the key is derived from something else — a
695            // downloaded model keyed `hf:<repo>@<sha>#<file>`, most of all. A
696            // test written here will therefore report "one row, id kept" for
697            // input the real repository would have duplicated, so anything
698            // turning on key identity belongs in a test against the real
699            // repository (see `gglib-app-services`'s
700            // `forcing_a_downloaded_model_refreshes_it_rather_than_duplicating_it`).
701            //
702            // Mirror the `SQLite` repository: registering the same file twice
703            // updates that row and keeps its id rather than appending a second
704            // one. A double that appends contradicts the trait doc and makes
705            // the silent-overwrite bug this guard exists for invisible to
706            // every test written against it.
707            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    /// **The 409 this was written for.** Adding a file already in the library
792    /// used to succeed: `insert` upserts on the model key, so the second add
793    /// overwrote the first row and returned it, and the caller was told the
794    /// model had been added. `AlreadyExists` was never constructed anywhere in
795    /// the workspace, so `models.rs`'s Conflict arm and the
796    /// `AlreadyExists -> HttpError::Conflict` mapping both sat unreachable.
797    #[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    /// `--reimport` is the documented way to refresh a model's derived columns
836    /// from the file. The guard above blocks the workflow `docs/tags.md`
837    /// describes, so `Refresh` has to restore it: same file, same row, no
838    /// conflict.
839    #[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    /// The lookup a caller can run before doing expensive or interactive work
876    /// has to agree with the guard inside the import, or the CLI would refuse
877    /// files the import would accept and vice versa.
878    #[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    /// An unresolvable path is an error, never `Ok(None)`. `Ok(None)` reads
909    /// as "no duplicate" and silently reinstates the overwrite the guard
910    /// exists to prevent.
911    #[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    /// A repository that matches any path, standing in for the sibling-shard
926    /// arm of the real `find_by_path` — the one case where the row handed back
927    /// is *not* the row the queried path is the primary file of.
928    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    /// The refusal resolves both sides, so a row whose stored path is spelled
956    /// differently from the caller's — while naming the same file — is still
957    /// recognised as that caller's own model and refreshed.
958    ///
959    /// That state is reachable: `canonical_model_path_string` falls back to
960    /// the literal path when the file cannot be resolved, so a row registered
961    /// while its file was missing keeps whatever spelling it was given.
962    /// Comparing the stored column directly would refuse a refresh of the
963    /// model's own file and print the same path on both sides of the error.
964    #[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        // Seed the row under the indirect spelling, as a registration made
973        // while the file was unresolvable would have.
974        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        // Only that the guard *lets it through* is asserted here. Which row it
996        // then lands on turns on key identity, and `MockRepo` upserts on
997        // `file_path` while the real repository upserts on `model_key` — the
998        // divergence noted above. Under this double the two spellings look
999        // like two rows; under the real repository both resolve to one key.
1000        // The landing is covered against the real repository by
1001        // gglib-app-services' `forcing_from_a_sibling_shard_refuses_rather_than_repointing`.
1002        assert!(
1003            outcome.is_ok(),
1004            "the row names this very file, however it was spelled: {:?}",
1005            outcome.err()
1006        );
1007    }
1008
1009    /// The other side of the same guard: when the located row names a
1010    /// genuinely different file — the sharded case, where the sibling arm
1011    /// returns the shard-1 row — the refresh is refused rather than
1012    /// repointing it.
1013    #[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    /// A second *different* file must still go in — the check keys on the
1047    /// path, not on "the library is non-empty".
1048    #[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        // Add models with different characteristics
1183        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()); // Duplicate quant
1208        // No context_length set
1209
1210        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        // Should have 2 distinct quantizations, sorted
1217        assert_eq!(options.quantizations, vec!["Q4_K_M", "Q8_0"]);
1218
1219        // Param range: 7.0 to 70.0
1220        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        // Context range: 4096 to 8192 (model3 has no context)
1225        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        // Standard removal rejected.
1245        let err = service
1246            .remove_tag(created.id, "format:qwen-xml")
1247            .await
1248            .unwrap_err();
1249        assert!(matches!(err, CoreError::Validation(_)));
1250
1251        // Tag still present.
1252        let tags = service.get_tags(created.id).await.unwrap();
1253        assert!(tags.contains(&"format:qwen-xml".to_string()));
1254
1255        // Force variant succeeds.
1256        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    /// Stub parser that emits a fixed capability set for retag tests.
1280    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(),      // user
1364            "format:hermes".to_string(), // stale auto
1365            "reasoning".to_string(),     // stale auto capability
1366        ];
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()]; // stale auto capability
1393        let created = service.add(new_model).await.unwrap();
1394
1395        // Detection no longer reports MTP support.
1396        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        // Detection no longer derives a spec — full mode must clear it.
1478        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}