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 async_trait::async_trait;
7
8use super::RepositoryError;
9use crate::domain::{Model, NewModel};
10
11/// Repository for model persistence operations.
12///
13/// This trait defines CRUD operations for models. Implementations
14/// are responsible for all storage details (SQL, filesystem, etc.).
15///
16/// # Design Rules
17///
18/// - No `sqlx` types in signatures
19/// - CRUD-only: list, get, insert, update, delete
20/// - Tags and search logic belong in `ModelService`, not here
21/// - The one exception is [`ModelRepository::get_by_identifier`], a *provided*
22///   method: identifier resolution is a lookup-key policy, and it lives here
23///   precisely so that every facade over the repository shares one copy of it.
24#[async_trait]
25pub trait ModelRepository: Send + Sync {
26    /// List all models in the repository.
27    async fn list(&self) -> Result<Vec<Model>, RepositoryError>;
28
29    /// Get a model by its database ID.
30    ///
31    /// Returns `Err(RepositoryError::NotFound)` if the model doesn't exist.
32    async fn get_by_id(&self, id: i64) -> Result<Model, RepositoryError>;
33
34    /// Get a model by its name.
35    ///
36    /// Returns `Err(RepositoryError::NotFound)` if no model with that name exists.
37    async fn get_by_name(&self, name: &str) -> Result<Model, RepositoryError>;
38
39    /// Insert a new model into the repository.
40    ///
41    /// Returns the persisted model with its assigned ID.
42    /// Returns `Err(RepositoryError::AlreadyExists)` if a model with the same
43    /// file path already exists.
44    async fn insert(&self, model: &NewModel) -> Result<Model, RepositoryError>;
45
46    /// Update an existing model.
47    ///
48    /// Returns `Err(RepositoryError::NotFound)` if the model doesn't exist.
49    async fn update(&self, model: &Model) -> Result<(), RepositoryError>;
50
51    /// Delete a model by its database ID.
52    ///
53    /// Returns `Err(RepositoryError::NotFound)` if the model doesn't exist.
54    async fn delete(&self, id: i64) -> Result<(), RepositoryError>;
55
56    /// Resolve a model by user-facing identifier: numeric database id first,
57    /// then exact name.
58    ///
59    /// This is the **single lookup-key policy** for the workspace — every
60    /// facade over a repository (`ModelService`, the `ModelCatalogPort`
61    /// adapter) delegates here rather than choosing its own key. Before this
62    /// existed the two disagreed: the service resolved ids, the catalog port
63    /// did not, so the same string resolved differently depending on which
64    /// pipeline a request travelled down.
65    ///
66    /// Provided rather than required so implementors and test doubles inherit
67    /// it automatically.
68    ///
69    /// Returns `Ok(None)` when nothing matches. A storage failure on the id
70    /// lookup propagates rather than silently falling through to the name
71    /// lookup — only a genuine `NotFound` continues.
72    async fn get_by_identifier(&self, identifier: &str) -> Result<Option<Model>, RepositoryError> {
73        if let Ok(id) = identifier.parse::<i64>() {
74            match self.get_by_id(id).await {
75                Ok(model) => return Ok(Some(model)),
76                Err(RepositoryError::NotFound(_)) => {}
77                Err(e) => return Err(e),
78            }
79        }
80        match self.get_by_name(identifier).await {
81            Ok(model) => Ok(Some(model)),
82            Err(RepositoryError::NotFound(_)) => Ok(None),
83            Err(e) => Err(e),
84        }
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::domain::ModelCapabilities;
92    use chrono::Utc;
93    use std::collections::HashMap;
94    use std::path::PathBuf;
95
96    /// The single model these tests resolve: id 7, name "qwen3".
97    fn model() -> Model {
98        Model {
99            id: 7,
100            name: "qwen3".to_string(),
101            model_key: String::new(),
102            file_path: PathBuf::from("/models/qwen3.gguf"),
103            param_count_b: 7.0,
104            architecture: None,
105            quantization: None,
106            context_length: None,
107            expert_count: None,
108            expert_used_count: None,
109            expert_shared_count: None,
110            metadata: HashMap::new(),
111            added_at: Utc::now(),
112            hf_repo_id: None,
113            hf_commit_sha: None,
114            hf_filename: None,
115            download_date: None,
116            last_update_check: None,
117            tags: vec![],
118            capabilities: ModelCapabilities::default(),
119            inference_defaults: None,
120            defaults_origin: None,
121            server_defaults: None,
122            benchmark_summary: None,
123        }
124    }
125
126    /// Serves [`model`] and can be told to fail the id lookup with a storage
127    /// error instead of `NotFound`.
128    struct OneModelRepo {
129        storage_error_on_id: bool,
130    }
131
132    #[async_trait]
133    impl ModelRepository for OneModelRepo {
134        async fn list(&self) -> Result<Vec<Model>, RepositoryError> {
135            Ok(vec![model()])
136        }
137
138        async fn get_by_id(&self, id: i64) -> Result<Model, RepositoryError> {
139            if self.storage_error_on_id {
140                return Err(RepositoryError::Storage("disk on fire".into()));
141            }
142            if id == 7 {
143                Ok(model())
144            } else {
145                Err(RepositoryError::NotFound(format!("id={id}")))
146            }
147        }
148
149        async fn get_by_name(&self, name: &str) -> Result<Model, RepositoryError> {
150            if name == "qwen3" {
151                Ok(model())
152            } else {
153                Err(RepositoryError::NotFound(format!("name={name}")))
154            }
155        }
156
157        async fn insert(&self, _model: &NewModel) -> Result<Model, RepositoryError> {
158            unimplemented!("not exercised by these tests")
159        }
160
161        async fn update(&self, _model: &Model) -> Result<(), RepositoryError> {
162            unimplemented!("not exercised by these tests")
163        }
164
165        async fn delete(&self, _id: i64) -> Result<(), RepositoryError> {
166            unimplemented!("not exercised by these tests")
167        }
168    }
169
170    fn repo() -> OneModelRepo {
171        OneModelRepo {
172            storage_error_on_id: false,
173        }
174    }
175
176    #[tokio::test]
177    async fn resolves_a_numeric_identifier_by_id() {
178        let found = repo().get_by_identifier("7").await.unwrap();
179        assert_eq!(found.unwrap().name, "qwen3");
180    }
181
182    #[tokio::test]
183    async fn resolves_a_non_numeric_identifier_by_name() {
184        let found = repo().get_by_identifier("qwen3").await.unwrap();
185        assert_eq!(found.unwrap().id, 7);
186    }
187
188    /// A numeric string that is not a known id must still get its name lookup
189    /// — otherwise a model literally named "42" would be unreachable.
190    #[tokio::test]
191    async fn numeric_miss_falls_through_to_the_name_lookup() {
192        assert!(repo().get_by_identifier("42").await.unwrap().is_none());
193    }
194
195    #[tokio::test]
196    async fn unknown_identifier_is_none_not_an_error() {
197        assert!(repo().get_by_identifier("ghost").await.unwrap().is_none());
198    }
199
200    /// The fall-through is for `NotFound` only. A real storage failure must
201    /// surface rather than being masked by a name lookup that happens to miss.
202    #[tokio::test]
203    async fn storage_failure_on_the_id_lookup_propagates() {
204        let repo = OneModelRepo {
205            storage_error_on_id: true,
206        };
207        let err = repo.get_by_identifier("7").await.unwrap_err();
208        assert!(matches!(err, RepositoryError::Storage(_)));
209    }
210}