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}