Skip to main content

gglib_core/ports/
model_repository.rs

1//! Model repository trait definition.
2//!
3//! This port defines the interface for model persistence operations.
4//! Implementations must handle all storage details internally.
5
6use std::path::Path;
7
8use async_trait::async_trait;
9
10use super::RepositoryError;
11use crate::domain::{Model, NewModel};
12
13/// Repository for model persistence operations.
14///
15/// This trait defines CRUD operations for models. Implementations
16/// are responsible for all storage details (SQL, filesystem, etc.).
17///
18/// # Design Rules
19///
20/// - No `sqlx` types in signatures
21/// - CRUD-only: list, get, insert, update, delete
22/// - Tags and search logic belong in `ModelService`, not here
23/// - The one exception is [`ModelRepository::get_by_identifier`], a *provided*
24///   method: identifier resolution is a lookup-key policy, and it lives here
25///   precisely so that every facade over the repository shares one copy of it.
26#[async_trait]
27pub trait ModelRepository: Send + Sync {
28    /// List all models in the repository.
29    async fn list(&self) -> Result<Vec<Model>, RepositoryError>;
30
31    /// Get a model by its database ID.
32    ///
33    /// Returns `Err(RepositoryError::NotFound)` if the model doesn't exist.
34    async fn get_by_id(&self, id: i64) -> Result<Model, RepositoryError>;
35
36    /// Get a model by its name.
37    ///
38    /// Returns `Err(RepositoryError::NotFound)` if no model with that name exists.
39    async fn get_by_name(&self, name: &str) -> Result<Model, RepositoryError>;
40
41    /// Insert a new model into the repository, or update the existing row for
42    /// the same model.
43    ///
44    /// **This upserts.** Registering the same model twice is not an error: it
45    /// overwrites the mutable columns and returns the existing row, id
46    /// included. That is deliberate — post-download registration has to be
47    /// safe to retry, and a re-scan must not fail on what it already knows.
48    ///
49    /// It also means this method never reports a duplicate. A caller for whom
50    /// "already there" *is* an error — an explicit "add this file to my
51    /// library" rather than a registration — must ask [`Self::find_by_path`]
52    /// first. `ModelService::import_from_file` does.
53    ///
54    /// This doc used to promise `Err(RepositoryError::AlreadyExists)` on a
55    /// duplicate file path. No implementation ever did that, and the promise
56    /// is what made the silent overwrite hard to see.
57    async fn insert(&self, model: &NewModel) -> Result<Model, RepositoryError>;
58
59    /// Find the model registered under `path`, if there is one.
60    ///
61    /// `path` is matched against the form the implementation stores.
62    ///
63    /// An implementation that also stores a model's sibling shard paths must
64    /// match those too: adding shard 2 of a group already in the library is
65    /// the same duplicate as adding shard 1, and only shard 1's path lives in
66    /// the primary path field. `SqliteModelRepository` in `gglib-db` does
67    /// this; the test doubles in this workspace store no siblings and so match
68    /// on the single path alone, which means a sharded duplicate is only
69    /// observable in tests that use the real repository.
70    ///
71    /// Callers pass a path already resolved by
72    /// [`canonical_model_path`](crate::paths::canonical_model_path); this
73    /// method does not resolve it for them. That is the whole point of the
74    /// split: resolution can fail, and a failure has to reach the caller as
75    /// an error rather than decay into `Ok(None)`, which reads as "no
76    /// duplicate" and silently reinstates the overwrite this lookup exists to
77    /// prevent.
78    ///
79    /// Required rather than provided. A default body here would have to touch
80    /// the filesystem, which `ports/` does not do (see the design rules in
81    /// this module's README), and would scan the entire table per call in a
82    /// trait that every test double inherits.
83    async fn find_by_path(&self, path: &Path) -> Result<Option<Model>, RepositoryError>;
84
85    /// Update an existing model.
86    ///
87    /// Returns `Err(RepositoryError::NotFound)` if the model doesn't exist.
88    async fn update(&self, model: &Model) -> Result<(), RepositoryError>;
89
90    /// Delete a model by its database ID.
91    ///
92    /// Returns `Err(RepositoryError::NotFound)` if the model doesn't exist.
93    async fn delete(&self, id: i64) -> Result<(), RepositoryError>;
94
95    /// Resolve a model by user-facing identifier: numeric database id first,
96    /// then exact name.
97    ///
98    /// This is the **single lookup-key policy** for the workspace — every
99    /// facade over a repository (`ModelService`, the `ModelCatalogPort`
100    /// adapter) delegates here rather than choosing its own key. Before this
101    /// existed the two disagreed: the service resolved ids, the catalog port
102    /// did not, so the same string resolved differently depending on which
103    /// pipeline a request travelled down.
104    ///
105    /// Provided rather than required so implementors and test doubles inherit
106    /// it automatically.
107    ///
108    /// Returns `Ok(None)` when nothing matches. A storage failure on the id
109    /// lookup propagates rather than silently falling through to the name
110    /// lookup — only a genuine `NotFound` continues.
111    async fn get_by_identifier(&self, identifier: &str) -> Result<Option<Model>, RepositoryError> {
112        if let Ok(id) = identifier.parse::<i64>() {
113            match self.get_by_id(id).await {
114                Ok(model) => return Ok(Some(model)),
115                Err(RepositoryError::NotFound(_)) => {}
116                Err(e) => return Err(e),
117            }
118        }
119        match self.get_by_name(identifier).await {
120            Ok(model) => Ok(Some(model)),
121            Err(RepositoryError::NotFound(_)) => Ok(None),
122            Err(e) => Err(e),
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::domain::ModelCapabilities;
131    use chrono::Utc;
132    use std::collections::HashMap;
133    use std::path::PathBuf;
134
135    /// The single model these tests resolve: id 7, name "qwen3".
136    fn model() -> Model {
137        Model {
138            dialect_spec: None,
139            id: 7,
140            name: "qwen3".to_string(),
141            model_key: String::new(),
142            file_path: PathBuf::from("/models/qwen3.gguf"),
143            param_count_b: 7.0,
144            architecture: None,
145            quantization: None,
146            context_length: None,
147            expert_count: None,
148            expert_used_count: None,
149            expert_shared_count: None,
150            metadata: HashMap::new(),
151            added_at: Utc::now(),
152            hf_repo_id: None,
153            hf_commit_sha: None,
154            hf_filename: None,
155            download_date: None,
156            last_update_check: None,
157            tags: vec![],
158            capabilities: ModelCapabilities::default(),
159            inference_defaults: None,
160            defaults_origin: None,
161            server_defaults: None,
162            template_caps: None,
163            benchmark_summary: None,
164        }
165    }
166
167    /// Serves [`model`] and can be told to fail the id lookup with a storage
168    /// error instead of `NotFound`.
169    struct OneModelRepo {
170        storage_error_on_id: bool,
171    }
172
173    #[async_trait]
174    impl ModelRepository for OneModelRepo {
175        async fn list(&self) -> Result<Vec<Model>, RepositoryError> {
176            Ok(vec![model()])
177        }
178
179        async fn get_by_id(&self, id: i64) -> Result<Model, RepositoryError> {
180            if self.storage_error_on_id {
181                return Err(RepositoryError::Storage("disk on fire".into()));
182            }
183            if id == 7 {
184                Ok(model())
185            } else {
186                Err(RepositoryError::NotFound(format!("id={id}")))
187            }
188        }
189
190        async fn get_by_name(&self, name: &str) -> Result<Model, RepositoryError> {
191            if name == "qwen3" {
192                Ok(model())
193            } else {
194                Err(RepositoryError::NotFound(format!("name={name}")))
195            }
196        }
197
198        async fn find_by_path(&self, path: &Path) -> Result<Option<Model>, RepositoryError> {
199            Ok(self
200                .list()
201                .await?
202                .into_iter()
203                .find(|m| m.file_path.as_path() == path))
204        }
205
206        async fn insert(&self, _model: &NewModel) -> Result<Model, RepositoryError> {
207            unimplemented!("not exercised by these tests")
208        }
209
210        async fn update(&self, _model: &Model) -> Result<(), RepositoryError> {
211            unimplemented!("not exercised by these tests")
212        }
213
214        async fn delete(&self, _id: i64) -> Result<(), RepositoryError> {
215            unimplemented!("not exercised by these tests")
216        }
217    }
218
219    fn repo() -> OneModelRepo {
220        OneModelRepo {
221            storage_error_on_id: false,
222        }
223    }
224
225    #[tokio::test]
226    async fn resolves_a_numeric_identifier_by_id() {
227        let found = repo().get_by_identifier("7").await.unwrap();
228        assert_eq!(found.unwrap().name, "qwen3");
229    }
230
231    #[tokio::test]
232    async fn resolves_a_non_numeric_identifier_by_name() {
233        let found = repo().get_by_identifier("qwen3").await.unwrap();
234        assert_eq!(found.unwrap().id, 7);
235    }
236
237    /// A numeric string that is not a known id must still get its name lookup
238    /// — otherwise a model literally named "42" would be unreachable.
239    #[tokio::test]
240    async fn numeric_miss_falls_through_to_the_name_lookup() {
241        assert!(repo().get_by_identifier("42").await.unwrap().is_none());
242    }
243
244    #[tokio::test]
245    async fn unknown_identifier_is_none_not_an_error() {
246        assert!(repo().get_by_identifier("ghost").await.unwrap().is_none());
247    }
248
249    /// The fall-through is for `NotFound` only. A real storage failure must
250    /// surface rather than being masked by a name lookup that happens to miss.
251    #[tokio::test]
252    async fn storage_failure_on_the_id_lookup_propagates() {
253        let repo = OneModelRepo {
254            storage_error_on_id: true,
255        };
256        let err = repo.get_by_identifier("7").await.unwrap_err();
257        assert!(matches!(err, RepositoryError::Storage(_)));
258    }
259}