Skip to main content

zeph_memory/sqlite/
trust.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use super::SqliteStore;
5use crate::error::MemoryError;
6
7#[derive(Debug, Clone)]
8pub struct SkillTrustRow {
9    pub skill_name: String,
10    pub trust_level: String,
11    pub source_kind: String,
12    pub source_url: Option<String>,
13    pub source_path: Option<String>,
14    pub blake3_hash: String,
15    pub updated_at: String,
16}
17
18type TrustTuple = (
19    String,
20    String,
21    String,
22    Option<String>,
23    Option<String>,
24    String,
25    String,
26);
27
28fn row_from_tuple(t: TrustTuple) -> SkillTrustRow {
29    SkillTrustRow {
30        skill_name: t.0,
31        trust_level: t.1,
32        source_kind: t.2,
33        source_url: t.3,
34        source_path: t.4,
35        blake3_hash: t.5,
36        updated_at: t.6,
37    }
38}
39
40impl SqliteStore {
41    /// Upsert trust metadata for a skill.
42    ///
43    /// # Errors
44    ///
45    /// Returns an error if the database operation fails.
46    pub async fn upsert_skill_trust(
47        &self,
48        skill_name: &str,
49        trust_level: &str,
50        source_kind: &str,
51        source_url: Option<&str>,
52        source_path: Option<&str>,
53        blake3_hash: &str,
54    ) -> Result<(), MemoryError> {
55        sqlx::query(
56            "INSERT INTO skill_trust (skill_name, trust_level, source_kind, source_url, source_path, blake3_hash, updated_at) \
57             VALUES (?, ?, ?, ?, ?, ?, datetime('now')) \
58             ON CONFLICT(skill_name) DO UPDATE SET \
59             trust_level = excluded.trust_level, \
60             source_kind = excluded.source_kind, \
61             source_url = excluded.source_url, \
62             source_path = excluded.source_path, \
63             blake3_hash = excluded.blake3_hash, \
64             updated_at = datetime('now')",
65        )
66        .bind(skill_name)
67        .bind(trust_level)
68        .bind(source_kind)
69        .bind(source_url)
70        .bind(source_path)
71        .bind(blake3_hash)
72        .execute(&self.pool)
73        .await?;
74        Ok(())
75    }
76
77    /// Load trust metadata for a single skill.
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if the query fails.
82    pub async fn load_skill_trust(
83        &self,
84        skill_name: &str,
85    ) -> Result<Option<SkillTrustRow>, MemoryError> {
86        let row: Option<TrustTuple> = sqlx::query_as(
87            "SELECT skill_name, trust_level, source_kind, source_url, source_path, blake3_hash, updated_at \
88             FROM skill_trust WHERE skill_name = ?",
89        )
90        .bind(skill_name)
91        .fetch_optional(&self.pool)
92        .await?;
93        Ok(row.map(row_from_tuple))
94    }
95
96    /// Load all skill trust entries.
97    ///
98    /// # Errors
99    ///
100    /// Returns an error if the query fails.
101    pub async fn load_all_skill_trust(&self) -> Result<Vec<SkillTrustRow>, MemoryError> {
102        let rows: Vec<TrustTuple> = sqlx::query_as(
103            "SELECT skill_name, trust_level, source_kind, source_url, source_path, blake3_hash, updated_at \
104             FROM skill_trust ORDER BY skill_name",
105        )
106        .fetch_all(&self.pool)
107        .await?;
108        Ok(rows.into_iter().map(row_from_tuple).collect())
109    }
110
111    /// Update only the trust level for a skill.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the skill does not exist or the update fails.
116    pub async fn set_skill_trust_level(
117        &self,
118        skill_name: &str,
119        trust_level: &str,
120    ) -> Result<bool, MemoryError> {
121        let result = sqlx::query(
122            "UPDATE skill_trust SET trust_level = ?, updated_at = datetime('now') WHERE skill_name = ?",
123        )
124        .bind(trust_level)
125        .bind(skill_name)
126        .execute(&self.pool)
127        .await?;
128        Ok(result.rows_affected() > 0)
129    }
130
131    /// Delete trust entry for a skill.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if the delete fails.
136    pub async fn delete_skill_trust(&self, skill_name: &str) -> Result<bool, MemoryError> {
137        let result = sqlx::query("DELETE FROM skill_trust WHERE skill_name = ?")
138            .bind(skill_name)
139            .execute(&self.pool)
140            .await?;
141        Ok(result.rows_affected() > 0)
142    }
143
144    /// Update the blake3 hash for a skill.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if the update fails.
149    pub async fn update_skill_hash(
150        &self,
151        skill_name: &str,
152        blake3_hash: &str,
153    ) -> Result<bool, MemoryError> {
154        let result = sqlx::query(
155            "UPDATE skill_trust SET blake3_hash = ?, updated_at = datetime('now') WHERE skill_name = ?",
156        )
157        .bind(blake3_hash)
158        .bind(skill_name)
159        .execute(&self.pool)
160        .await?;
161        Ok(result.rows_affected() > 0)
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    async fn test_store() -> SqliteStore {
170        SqliteStore::new(":memory:").await.unwrap()
171    }
172
173    #[tokio::test]
174    async fn upsert_and_load() {
175        let store = test_store().await;
176
177        store
178            .upsert_skill_trust("git", "trusted", "local", None, None, "abc123")
179            .await
180            .unwrap();
181
182        let row = store.load_skill_trust("git").await.unwrap().unwrap();
183        assert_eq!(row.skill_name, "git");
184        assert_eq!(row.trust_level, "trusted");
185        assert_eq!(row.source_kind, "local");
186        assert_eq!(row.blake3_hash, "abc123");
187    }
188
189    #[tokio::test]
190    async fn upsert_updates_existing() {
191        let store = test_store().await;
192
193        store
194            .upsert_skill_trust("git", "quarantined", "local", None, None, "hash1")
195            .await
196            .unwrap();
197        store
198            .upsert_skill_trust("git", "trusted", "local", None, None, "hash2")
199            .await
200            .unwrap();
201
202        let row = store.load_skill_trust("git").await.unwrap().unwrap();
203        assert_eq!(row.trust_level, "trusted");
204        assert_eq!(row.blake3_hash, "hash2");
205    }
206
207    #[tokio::test]
208    async fn load_nonexistent() {
209        let store = test_store().await;
210        let row = store.load_skill_trust("nope").await.unwrap();
211        assert!(row.is_none());
212    }
213
214    #[tokio::test]
215    async fn load_all() {
216        let store = test_store().await;
217
218        store
219            .upsert_skill_trust("alpha", "trusted", "local", None, None, "h1")
220            .await
221            .unwrap();
222        store
223            .upsert_skill_trust(
224                "beta",
225                "quarantined",
226                "hub",
227                Some("https://hub.example.com"),
228                None,
229                "h2",
230            )
231            .await
232            .unwrap();
233
234        let rows = store.load_all_skill_trust().await.unwrap();
235        assert_eq!(rows.len(), 2);
236        assert_eq!(rows[0].skill_name, "alpha");
237        assert_eq!(rows[1].skill_name, "beta");
238    }
239
240    #[tokio::test]
241    async fn set_trust_level() {
242        let store = test_store().await;
243
244        store
245            .upsert_skill_trust("git", "quarantined", "local", None, None, "h1")
246            .await
247            .unwrap();
248
249        let updated = store.set_skill_trust_level("git", "blocked").await.unwrap();
250        assert!(updated);
251
252        let row = store.load_skill_trust("git").await.unwrap().unwrap();
253        assert_eq!(row.trust_level, "blocked");
254    }
255
256    #[tokio::test]
257    async fn set_trust_level_nonexistent() {
258        let store = test_store().await;
259        let updated = store
260            .set_skill_trust_level("nope", "blocked")
261            .await
262            .unwrap();
263        assert!(!updated);
264    }
265
266    #[tokio::test]
267    async fn delete_trust() {
268        let store = test_store().await;
269
270        store
271            .upsert_skill_trust("git", "trusted", "local", None, None, "h1")
272            .await
273            .unwrap();
274
275        let deleted = store.delete_skill_trust("git").await.unwrap();
276        assert!(deleted);
277
278        let row = store.load_skill_trust("git").await.unwrap();
279        assert!(row.is_none());
280    }
281
282    #[tokio::test]
283    async fn delete_nonexistent() {
284        let store = test_store().await;
285        let deleted = store.delete_skill_trust("nope").await.unwrap();
286        assert!(!deleted);
287    }
288
289    #[tokio::test]
290    async fn update_hash() {
291        let store = test_store().await;
292
293        store
294            .upsert_skill_trust("git", "verified", "local", None, None, "old_hash")
295            .await
296            .unwrap();
297
298        let updated = store.update_skill_hash("git", "new_hash").await.unwrap();
299        assert!(updated);
300
301        let row = store.load_skill_trust("git").await.unwrap().unwrap();
302        assert_eq!(row.blake3_hash, "new_hash");
303    }
304
305    #[tokio::test]
306    async fn source_with_url() {
307        let store = test_store().await;
308
309        store
310            .upsert_skill_trust(
311                "remote-skill",
312                "quarantined",
313                "hub",
314                Some("https://hub.example.com/skill"),
315                None,
316                "h1",
317            )
318            .await
319            .unwrap();
320
321        let row = store
322            .load_skill_trust("remote-skill")
323            .await
324            .unwrap()
325            .unwrap();
326        assert_eq!(row.source_kind, "hub");
327        assert_eq!(
328            row.source_url.as_deref(),
329            Some("https://hub.example.com/skill")
330        );
331    }
332
333    #[tokio::test]
334    async fn source_with_path() {
335        let store = test_store().await;
336
337        store
338            .upsert_skill_trust(
339                "file-skill",
340                "quarantined",
341                "file",
342                None,
343                Some("/tmp/skill.tar.gz"),
344                "h1",
345            )
346            .await
347            .unwrap();
348
349        let row = store.load_skill_trust("file-skill").await.unwrap().unwrap();
350        assert_eq!(row.source_kind, "file");
351        assert_eq!(row.source_path.as_deref(), Some("/tmp/skill.tar.gz"));
352    }
353}