1use serde::{Deserialize, Serialize};
5
6use super::SqliteStore;
7use crate::error::MemoryError;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum SourceKind {
13 Local,
14 Hub,
15 File,
16}
17
18impl SourceKind {
19 fn as_str(&self) -> &'static str {
20 match self {
21 Self::Local => "local",
22 Self::Hub => "hub",
23 Self::File => "file",
24 }
25 }
26}
27
28impl std::fmt::Display for SourceKind {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 f.write_str(self.as_str())
31 }
32}
33
34impl std::str::FromStr for SourceKind {
35 type Err = String;
36
37 fn from_str(s: &str) -> Result<Self, Self::Err> {
38 match s {
39 "local" => Ok(Self::Local),
40 "hub" => Ok(Self::Hub),
41 "file" => Ok(Self::File),
42 other => Err(format!("unknown source_kind: {other}")),
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
48pub struct SkillTrustRow {
49 pub skill_name: String,
50 pub trust_level: String,
51 pub source_kind: SourceKind,
52 pub source_url: Option<String>,
53 pub source_path: Option<String>,
54 pub blake3_hash: String,
55 pub updated_at: String,
56}
57
58type TrustTuple = (
59 String,
60 String,
61 String,
62 Option<String>,
63 Option<String>,
64 String,
65 String,
66);
67
68fn row_from_tuple(t: TrustTuple) -> SkillTrustRow {
69 let source_kind = t.2.parse::<SourceKind>().unwrap_or(SourceKind::Local);
70 SkillTrustRow {
71 skill_name: t.0,
72 trust_level: t.1,
73 source_kind,
74 source_url: t.3,
75 source_path: t.4,
76 blake3_hash: t.5,
77 updated_at: t.6,
78 }
79}
80
81impl SqliteStore {
82 pub async fn upsert_skill_trust(
88 &self,
89 skill_name: &str,
90 trust_level: &str,
91 source_kind: SourceKind,
92 source_url: Option<&str>,
93 source_path: Option<&str>,
94 blake3_hash: &str,
95 ) -> Result<(), MemoryError> {
96 sqlx::query(
97 "INSERT INTO skill_trust (skill_name, trust_level, source_kind, source_url, source_path, blake3_hash, updated_at) \
98 VALUES (?, ?, ?, ?, ?, ?, datetime('now')) \
99 ON CONFLICT(skill_name) DO UPDATE SET \
100 trust_level = excluded.trust_level, \
101 source_kind = excluded.source_kind, \
102 source_url = excluded.source_url, \
103 source_path = excluded.source_path, \
104 blake3_hash = excluded.blake3_hash, \
105 updated_at = datetime('now')",
106 )
107 .bind(skill_name)
108 .bind(trust_level)
109 .bind(source_kind.as_str())
110 .bind(source_url)
111 .bind(source_path)
112 .bind(blake3_hash)
113 .execute(&self.pool)
114 .await?;
115 Ok(())
116 }
117
118 pub async fn load_skill_trust(
124 &self,
125 skill_name: &str,
126 ) -> Result<Option<SkillTrustRow>, MemoryError> {
127 let row: Option<TrustTuple> = sqlx::query_as(
128 "SELECT skill_name, trust_level, source_kind, source_url, source_path, blake3_hash, updated_at \
129 FROM skill_trust WHERE skill_name = ?",
130 )
131 .bind(skill_name)
132 .fetch_optional(&self.pool)
133 .await?;
134 Ok(row.map(row_from_tuple))
135 }
136
137 pub async fn load_all_skill_trust(&self) -> Result<Vec<SkillTrustRow>, MemoryError> {
143 let rows: Vec<TrustTuple> = sqlx::query_as(
144 "SELECT skill_name, trust_level, source_kind, source_url, source_path, blake3_hash, updated_at \
145 FROM skill_trust ORDER BY skill_name",
146 )
147 .fetch_all(&self.pool)
148 .await?;
149 Ok(rows.into_iter().map(row_from_tuple).collect())
150 }
151
152 pub async fn set_skill_trust_level(
158 &self,
159 skill_name: &str,
160 trust_level: &str,
161 ) -> Result<bool, MemoryError> {
162 let result = sqlx::query(
163 "UPDATE skill_trust SET trust_level = ?, updated_at = datetime('now') WHERE skill_name = ?",
164 )
165 .bind(trust_level)
166 .bind(skill_name)
167 .execute(&self.pool)
168 .await?;
169 Ok(result.rows_affected() > 0)
170 }
171
172 pub async fn delete_skill_trust(&self, skill_name: &str) -> Result<bool, MemoryError> {
178 let result = sqlx::query("DELETE FROM skill_trust WHERE skill_name = ?")
179 .bind(skill_name)
180 .execute(&self.pool)
181 .await?;
182 Ok(result.rows_affected() > 0)
183 }
184
185 pub async fn update_skill_hash(
191 &self,
192 skill_name: &str,
193 blake3_hash: &str,
194 ) -> Result<bool, MemoryError> {
195 let result = sqlx::query(
196 "UPDATE skill_trust SET blake3_hash = ?, updated_at = datetime('now') WHERE skill_name = ?",
197 )
198 .bind(blake3_hash)
199 .bind(skill_name)
200 .execute(&self.pool)
201 .await?;
202 Ok(result.rows_affected() > 0)
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 async fn test_store() -> SqliteStore {
211 SqliteStore::new(":memory:").await.unwrap()
212 }
213
214 #[tokio::test]
215 async fn upsert_and_load() {
216 let store = test_store().await;
217
218 store
219 .upsert_skill_trust("git", "trusted", SourceKind::Local, None, None, "abc123")
220 .await
221 .unwrap();
222
223 let row = store.load_skill_trust("git").await.unwrap().unwrap();
224 assert_eq!(row.skill_name, "git");
225 assert_eq!(row.trust_level, "trusted");
226 assert_eq!(row.source_kind, SourceKind::Local);
227 assert_eq!(row.blake3_hash, "abc123");
228 }
229
230 #[tokio::test]
231 async fn upsert_updates_existing() {
232 let store = test_store().await;
233
234 store
235 .upsert_skill_trust("git", "quarantined", SourceKind::Local, None, None, "hash1")
236 .await
237 .unwrap();
238 store
239 .upsert_skill_trust("git", "trusted", SourceKind::Local, None, None, "hash2")
240 .await
241 .unwrap();
242
243 let row = store.load_skill_trust("git").await.unwrap().unwrap();
244 assert_eq!(row.trust_level, "trusted");
245 assert_eq!(row.blake3_hash, "hash2");
246 }
247
248 #[tokio::test]
249 async fn load_nonexistent() {
250 let store = test_store().await;
251 let row = store.load_skill_trust("nope").await.unwrap();
252 assert!(row.is_none());
253 }
254
255 #[tokio::test]
256 async fn load_all() {
257 let store = test_store().await;
258
259 store
260 .upsert_skill_trust("alpha", "trusted", SourceKind::Local, None, None, "h1")
261 .await
262 .unwrap();
263 store
264 .upsert_skill_trust(
265 "beta",
266 "quarantined",
267 SourceKind::Hub,
268 Some("https://hub.example.com"),
269 None,
270 "h2",
271 )
272 .await
273 .unwrap();
274
275 let rows = store.load_all_skill_trust().await.unwrap();
276 assert_eq!(rows.len(), 2);
277 assert_eq!(rows[0].skill_name, "alpha");
278 assert_eq!(rows[1].skill_name, "beta");
279 }
280
281 #[tokio::test]
282 async fn set_trust_level() {
283 let store = test_store().await;
284
285 store
286 .upsert_skill_trust("git", "quarantined", SourceKind::Local, None, None, "h1")
287 .await
288 .unwrap();
289
290 let updated = store.set_skill_trust_level("git", "blocked").await.unwrap();
291 assert!(updated);
292
293 let row = store.load_skill_trust("git").await.unwrap().unwrap();
294 assert_eq!(row.trust_level, "blocked");
295 }
296
297 #[tokio::test]
298 async fn set_trust_level_nonexistent() {
299 let store = test_store().await;
300 let updated = store
301 .set_skill_trust_level("nope", "blocked")
302 .await
303 .unwrap();
304 assert!(!updated);
305 }
306
307 #[tokio::test]
308 async fn delete_trust() {
309 let store = test_store().await;
310
311 store
312 .upsert_skill_trust("git", "trusted", SourceKind::Local, None, None, "h1")
313 .await
314 .unwrap();
315
316 let deleted = store.delete_skill_trust("git").await.unwrap();
317 assert!(deleted);
318
319 let row = store.load_skill_trust("git").await.unwrap();
320 assert!(row.is_none());
321 }
322
323 #[tokio::test]
324 async fn delete_nonexistent() {
325 let store = test_store().await;
326 let deleted = store.delete_skill_trust("nope").await.unwrap();
327 assert!(!deleted);
328 }
329
330 #[tokio::test]
331 async fn update_hash() {
332 let store = test_store().await;
333
334 store
335 .upsert_skill_trust("git", "verified", SourceKind::Local, None, None, "old_hash")
336 .await
337 .unwrap();
338
339 let updated = store.update_skill_hash("git", "new_hash").await.unwrap();
340 assert!(updated);
341
342 let row = store.load_skill_trust("git").await.unwrap().unwrap();
343 assert_eq!(row.blake3_hash, "new_hash");
344 }
345
346 #[tokio::test]
347 async fn source_with_url() {
348 let store = test_store().await;
349
350 store
351 .upsert_skill_trust(
352 "remote-skill",
353 "quarantined",
354 SourceKind::Hub,
355 Some("https://hub.example.com/skill"),
356 None,
357 "h1",
358 )
359 .await
360 .unwrap();
361
362 let row = store
363 .load_skill_trust("remote-skill")
364 .await
365 .unwrap()
366 .unwrap();
367 assert_eq!(row.source_kind, SourceKind::Hub);
368 assert_eq!(
369 row.source_url.as_deref(),
370 Some("https://hub.example.com/skill")
371 );
372 }
373
374 #[tokio::test]
375 async fn source_with_path() {
376 let store = test_store().await;
377
378 store
379 .upsert_skill_trust(
380 "file-skill",
381 "quarantined",
382 SourceKind::File,
383 None,
384 Some("/tmp/skill.tar.gz"),
385 "h1",
386 )
387 .await
388 .unwrap();
389
390 let row = store.load_skill_trust("file-skill").await.unwrap().unwrap();
391 assert_eq!(row.source_kind, SourceKind::File);
392 assert_eq!(row.source_path.as_deref(), Some("/tmp/skill.tar.gz"));
393 }
394
395 #[test]
396 fn source_kind_display_local() {
397 assert_eq!(SourceKind::Local.to_string(), "local");
398 }
399
400 #[test]
401 fn source_kind_display_hub() {
402 assert_eq!(SourceKind::Hub.to_string(), "hub");
403 }
404
405 #[test]
406 fn source_kind_display_file() {
407 assert_eq!(SourceKind::File.to_string(), "file");
408 }
409
410 #[test]
411 fn source_kind_from_str_local() {
412 let kind: SourceKind = "local".parse().unwrap();
413 assert_eq!(kind, SourceKind::Local);
414 }
415
416 #[test]
417 fn source_kind_from_str_hub() {
418 let kind: SourceKind = "hub".parse().unwrap();
419 assert_eq!(kind, SourceKind::Hub);
420 }
421
422 #[test]
423 fn source_kind_from_str_file() {
424 let kind: SourceKind = "file".parse().unwrap();
425 assert_eq!(kind, SourceKind::File);
426 }
427
428 #[test]
429 fn source_kind_from_str_unknown_returns_error() {
430 let result: Result<SourceKind, _> = "s3".parse();
431 assert!(result.is_err());
432 assert!(result.unwrap_err().contains("unknown source_kind"));
433 }
434
435 #[test]
436 fn source_kind_serde_json_roundtrip_local() {
437 let original = SourceKind::Local;
438 let json = serde_json::to_string(&original).unwrap();
439 assert_eq!(json, r#""local""#);
440 let back: SourceKind = serde_json::from_str(&json).unwrap();
441 assert_eq!(back, original);
442 }
443
444 #[test]
445 fn source_kind_serde_json_roundtrip_hub() {
446 let original = SourceKind::Hub;
447 let json = serde_json::to_string(&original).unwrap();
448 assert_eq!(json, r#""hub""#);
449 let back: SourceKind = serde_json::from_str(&json).unwrap();
450 assert_eq!(back, original);
451 }
452
453 #[test]
454 fn source_kind_serde_json_roundtrip_file() {
455 let original = SourceKind::File;
456 let json = serde_json::to_string(&original).unwrap();
457 assert_eq!(json, r#""file""#);
458 let back: SourceKind = serde_json::from_str(&json).unwrap();
459 assert_eq!(back, original);
460 }
461
462 #[test]
463 fn source_kind_serde_json_invalid_value_errors() {
464 let result: Result<SourceKind, _> = serde_json::from_str(r#""unknown""#);
465 assert!(result.is_err());
466 }
467
468 #[tokio::test]
469 async fn upsert_each_source_kind_roundtrip() {
470 let store = test_store().await;
471 let variants = [
472 ("skill-local", SourceKind::Local),
473 ("skill-hub", SourceKind::Hub),
474 ("skill-file", SourceKind::File),
475 ];
476 for (name, kind) in &variants {
477 store
478 .upsert_skill_trust(name, "trusted", kind.clone(), None, None, "hash")
479 .await
480 .unwrap();
481 let row = store.load_skill_trust(name).await.unwrap().unwrap();
482 assert_eq!(&row.source_kind, kind);
483 }
484 }
485}