1use serde::{Deserialize, Serialize};
5#[allow(unused_imports)]
6use zeph_db::sql;
7
8use super::SqliteStore;
9use crate::error::MemoryError;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum SourceKind {
15 Local,
16 Hub,
17 File,
18}
19
20impl SourceKind {
21 fn as_str(&self) -> &'static str {
22 match self {
23 Self::Local => "local",
24 Self::Hub => "hub",
25 Self::File => "file",
26 }
27 }
28}
29
30impl std::fmt::Display for SourceKind {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 f.write_str(self.as_str())
33 }
34}
35
36impl std::str::FromStr for SourceKind {
37 type Err = String;
38
39 fn from_str(s: &str) -> Result<Self, Self::Err> {
40 match s {
41 "local" => Ok(Self::Local),
42 "hub" => Ok(Self::Hub),
43 "file" => Ok(Self::File),
44 other => Err(format!("unknown source_kind: {other}")),
45 }
46 }
47}
48
49#[derive(Debug, Clone)]
50pub struct SkillTrustRow {
51 pub skill_name: String,
52 pub trust_level: String,
53 pub source_kind: SourceKind,
54 pub source_url: Option<String>,
55 pub source_path: Option<String>,
56 pub blake3_hash: String,
57 pub updated_at: String,
58 pub git_hash: Option<String>,
60}
61
62type TrustTuple = (
63 String,
64 String,
65 String,
66 Option<String>,
67 Option<String>,
68 String,
69 String,
70 Option<String>,
71);
72
73fn row_from_tuple(t: TrustTuple) -> SkillTrustRow {
74 let source_kind = t.2.parse::<SourceKind>().unwrap_or(SourceKind::Local);
75 SkillTrustRow {
76 skill_name: t.0,
77 trust_level: t.1,
78 source_kind,
79 source_url: t.3,
80 source_path: t.4,
81 blake3_hash: t.5,
82 updated_at: t.6,
83 git_hash: t.7,
84 }
85}
86
87impl SqliteStore {
88 pub async fn upsert_skill_trust(
94 &self,
95 skill_name: &str,
96 trust_level: &str,
97 source_kind: SourceKind,
98 source_url: Option<&str>,
99 source_path: Option<&str>,
100 blake3_hash: &str,
101 ) -> Result<(), MemoryError> {
102 self.upsert_skill_trust_with_git_hash(
103 skill_name,
104 trust_level,
105 source_kind,
106 source_url,
107 source_path,
108 blake3_hash,
109 None,
110 )
111 .await
112 }
113
114 #[allow(clippy::too_many_arguments)]
124 pub async fn upsert_skill_trust_with_git_hash(
125 &self,
126 skill_name: &str,
127 trust_level: &str,
128 source_kind: SourceKind,
129 source_url: Option<&str>,
130 source_path: Option<&str>,
131 blake3_hash: &str,
132 git_hash: Option<&str>,
133 ) -> Result<(), MemoryError> {
134 zeph_db::query(
135 sql!("INSERT INTO skill_trust \
136 (skill_name, trust_level, source_kind, source_url, source_path, blake3_hash, git_hash, updated_at) \
137 VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) \
138 ON CONFLICT(skill_name) DO UPDATE SET \
139 trust_level = excluded.trust_level, \
140 source_kind = excluded.source_kind, \
141 source_url = excluded.source_url, \
142 source_path = excluded.source_path, \
143 blake3_hash = excluded.blake3_hash, \
144 git_hash = excluded.git_hash, \
145 updated_at = CURRENT_TIMESTAMP"),
146 )
147 .bind(skill_name)
148 .bind(trust_level)
149 .bind(source_kind.as_str())
150 .bind(source_url)
151 .bind(source_path)
152 .bind(blake3_hash)
153 .bind(git_hash)
154 .execute(&self.pool)
155 .await?;
156 Ok(())
157 }
158
159 pub async fn load_skill_trust(
165 &self,
166 skill_name: &str,
167 ) -> Result<Option<SkillTrustRow>, MemoryError> {
168 let row: Option<TrustTuple> = zeph_db::query_as(sql!(
169 "SELECT skill_name, trust_level, source_kind, source_url, source_path, \
170 blake3_hash, updated_at, git_hash \
171 FROM skill_trust WHERE skill_name = ?"
172 ))
173 .bind(skill_name)
174 .fetch_optional(&self.pool)
175 .await?;
176 Ok(row.map(row_from_tuple))
177 }
178
179 pub async fn load_all_skill_trust(&self) -> Result<Vec<SkillTrustRow>, MemoryError> {
185 let rows: Vec<TrustTuple> = zeph_db::query_as(sql!(
186 "SELECT skill_name, trust_level, source_kind, source_url, source_path, \
187 blake3_hash, updated_at, git_hash \
188 FROM skill_trust ORDER BY skill_name"
189 ))
190 .fetch_all(&self.pool)
191 .await?;
192 Ok(rows.into_iter().map(row_from_tuple).collect())
193 }
194
195 pub async fn set_skill_trust_level(
201 &self,
202 skill_name: &str,
203 trust_level: &str,
204 ) -> Result<bool, MemoryError> {
205 let result = zeph_db::query(
206 sql!("UPDATE skill_trust SET trust_level = ?, updated_at = CURRENT_TIMESTAMP WHERE skill_name = ?"),
207 )
208 .bind(trust_level)
209 .bind(skill_name)
210 .execute(&self.pool)
211 .await?;
212 Ok(result.rows_affected() > 0)
213 }
214
215 pub async fn delete_skill_trust(&self, skill_name: &str) -> Result<bool, MemoryError> {
221 let result = zeph_db::query(sql!("DELETE FROM skill_trust WHERE skill_name = ?"))
222 .bind(skill_name)
223 .execute(&self.pool)
224 .await?;
225 Ok(result.rows_affected() > 0)
226 }
227
228 pub async fn update_skill_hash(
234 &self,
235 skill_name: &str,
236 blake3_hash: &str,
237 ) -> Result<bool, MemoryError> {
238 let result = zeph_db::query(
239 sql!("UPDATE skill_trust SET blake3_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE skill_name = ?"),
240 )
241 .bind(blake3_hash)
242 .bind(skill_name)
243 .execute(&self.pool)
244 .await?;
245 Ok(result.rows_affected() > 0)
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 async fn test_store() -> SqliteStore {
254 SqliteStore::new(":memory:").await.unwrap()
255 }
256
257 #[tokio::test]
258 async fn upsert_and_load() {
259 let store = test_store().await;
260
261 store
262 .upsert_skill_trust("git", "trusted", SourceKind::Local, None, None, "abc123")
263 .await
264 .unwrap();
265
266 let row = store.load_skill_trust("git").await.unwrap().unwrap();
267 assert_eq!(row.skill_name, "git");
268 assert_eq!(row.trust_level, "trusted");
269 assert_eq!(row.source_kind, SourceKind::Local);
270 assert_eq!(row.blake3_hash, "abc123");
271 }
272
273 #[tokio::test]
274 async fn upsert_updates_existing() {
275 let store = test_store().await;
276
277 store
278 .upsert_skill_trust("git", "quarantined", SourceKind::Local, None, None, "hash1")
279 .await
280 .unwrap();
281 store
282 .upsert_skill_trust("git", "trusted", SourceKind::Local, None, None, "hash2")
283 .await
284 .unwrap();
285
286 let row = store.load_skill_trust("git").await.unwrap().unwrap();
287 assert_eq!(row.trust_level, "trusted");
288 assert_eq!(row.blake3_hash, "hash2");
289 }
290
291 #[tokio::test]
292 async fn load_nonexistent() {
293 let store = test_store().await;
294 let row = store.load_skill_trust("nope").await.unwrap();
295 assert!(row.is_none());
296 }
297
298 #[tokio::test]
299 async fn load_all() {
300 let store = test_store().await;
301
302 store
303 .upsert_skill_trust("alpha", "trusted", SourceKind::Local, None, None, "h1")
304 .await
305 .unwrap();
306 store
307 .upsert_skill_trust(
308 "beta",
309 "quarantined",
310 SourceKind::Hub,
311 Some("https://hub.example.com"),
312 None,
313 "h2",
314 )
315 .await
316 .unwrap();
317
318 let rows = store.load_all_skill_trust().await.unwrap();
319 assert_eq!(rows.len(), 2);
320 assert_eq!(rows[0].skill_name, "alpha");
321 assert_eq!(rows[1].skill_name, "beta");
322 }
323
324 #[tokio::test]
325 async fn set_trust_level() {
326 let store = test_store().await;
327
328 store
329 .upsert_skill_trust("git", "quarantined", SourceKind::Local, None, None, "h1")
330 .await
331 .unwrap();
332
333 let updated = store.set_skill_trust_level("git", "blocked").await.unwrap();
334 assert!(updated);
335
336 let row = store.load_skill_trust("git").await.unwrap().unwrap();
337 assert_eq!(row.trust_level, "blocked");
338 }
339
340 #[tokio::test]
341 async fn set_trust_level_nonexistent() {
342 let store = test_store().await;
343 let updated = store
344 .set_skill_trust_level("nope", "blocked")
345 .await
346 .unwrap();
347 assert!(!updated);
348 }
349
350 #[tokio::test]
351 async fn delete_trust() {
352 let store = test_store().await;
353
354 store
355 .upsert_skill_trust("git", "trusted", SourceKind::Local, None, None, "h1")
356 .await
357 .unwrap();
358
359 let deleted = store.delete_skill_trust("git").await.unwrap();
360 assert!(deleted);
361
362 let row = store.load_skill_trust("git").await.unwrap();
363 assert!(row.is_none());
364 }
365
366 #[tokio::test]
367 async fn delete_nonexistent() {
368 let store = test_store().await;
369 let deleted = store.delete_skill_trust("nope").await.unwrap();
370 assert!(!deleted);
371 }
372
373 #[tokio::test]
374 async fn update_hash() {
375 let store = test_store().await;
376
377 store
378 .upsert_skill_trust("git", "verified", SourceKind::Local, None, None, "old_hash")
379 .await
380 .unwrap();
381
382 let updated = store.update_skill_hash("git", "new_hash").await.unwrap();
383 assert!(updated);
384
385 let row = store.load_skill_trust("git").await.unwrap().unwrap();
386 assert_eq!(row.blake3_hash, "new_hash");
387 }
388
389 #[tokio::test]
390 async fn source_with_url() {
391 let store = test_store().await;
392
393 store
394 .upsert_skill_trust(
395 "remote-skill",
396 "quarantined",
397 SourceKind::Hub,
398 Some("https://hub.example.com/skill"),
399 None,
400 "h1",
401 )
402 .await
403 .unwrap();
404
405 let row = store
406 .load_skill_trust("remote-skill")
407 .await
408 .unwrap()
409 .unwrap();
410 assert_eq!(row.source_kind, SourceKind::Hub);
411 assert_eq!(
412 row.source_url.as_deref(),
413 Some("https://hub.example.com/skill")
414 );
415 }
416
417 #[tokio::test]
418 async fn source_with_path() {
419 let store = test_store().await;
420
421 store
422 .upsert_skill_trust(
423 "file-skill",
424 "quarantined",
425 SourceKind::File,
426 None,
427 Some("/tmp/skill.tar.gz"),
428 "h1",
429 )
430 .await
431 .unwrap();
432
433 let row = store.load_skill_trust("file-skill").await.unwrap().unwrap();
434 assert_eq!(row.source_kind, SourceKind::File);
435 assert_eq!(row.source_path.as_deref(), Some("/tmp/skill.tar.gz"));
436 }
437
438 #[test]
439 fn source_kind_display_local() {
440 assert_eq!(SourceKind::Local.to_string(), "local");
441 }
442
443 #[test]
444 fn source_kind_display_hub() {
445 assert_eq!(SourceKind::Hub.to_string(), "hub");
446 }
447
448 #[test]
449 fn source_kind_display_file() {
450 assert_eq!(SourceKind::File.to_string(), "file");
451 }
452
453 #[test]
454 fn source_kind_from_str_local() {
455 let kind: SourceKind = "local".parse().unwrap();
456 assert_eq!(kind, SourceKind::Local);
457 }
458
459 #[test]
460 fn source_kind_from_str_hub() {
461 let kind: SourceKind = "hub".parse().unwrap();
462 assert_eq!(kind, SourceKind::Hub);
463 }
464
465 #[test]
466 fn source_kind_from_str_file() {
467 let kind: SourceKind = "file".parse().unwrap();
468 assert_eq!(kind, SourceKind::File);
469 }
470
471 #[test]
472 fn source_kind_from_str_unknown_returns_error() {
473 let result: Result<SourceKind, _> = "s3".parse();
474 assert!(result.is_err());
475 assert!(result.unwrap_err().contains("unknown source_kind"));
476 }
477
478 #[test]
479 fn source_kind_serde_json_roundtrip_local() {
480 let original = SourceKind::Local;
481 let json = serde_json::to_string(&original).unwrap();
482 assert_eq!(json, r#""local""#);
483 let back: SourceKind = serde_json::from_str(&json).unwrap();
484 assert_eq!(back, original);
485 }
486
487 #[test]
488 fn source_kind_serde_json_roundtrip_hub() {
489 let original = SourceKind::Hub;
490 let json = serde_json::to_string(&original).unwrap();
491 assert_eq!(json, r#""hub""#);
492 let back: SourceKind = serde_json::from_str(&json).unwrap();
493 assert_eq!(back, original);
494 }
495
496 #[test]
497 fn source_kind_serde_json_roundtrip_file() {
498 let original = SourceKind::File;
499 let json = serde_json::to_string(&original).unwrap();
500 assert_eq!(json, r#""file""#);
501 let back: SourceKind = serde_json::from_str(&json).unwrap();
502 assert_eq!(back, original);
503 }
504
505 #[test]
506 fn source_kind_serde_json_invalid_value_errors() {
507 let result: Result<SourceKind, _> = serde_json::from_str(r#""unknown""#);
508 assert!(result.is_err());
509 }
510
511 #[tokio::test]
512 async fn trust_row_includes_git_hash() {
513 let store = test_store().await;
514
515 store
516 .upsert_skill_trust_with_git_hash(
517 "versioned-skill",
518 "trusted",
519 SourceKind::Hub,
520 Some("https://hub.example.com/skill"),
521 None,
522 "blake3abc",
523 Some("deadbeef1234"),
524 )
525 .await
526 .unwrap();
527
528 let row = store
529 .load_skill_trust("versioned-skill")
530 .await
531 .unwrap()
532 .unwrap();
533 assert_eq!(row.git_hash.as_deref(), Some("deadbeef1234"));
534 assert_eq!(row.blake3_hash, "blake3abc");
535 }
536
537 #[tokio::test]
538 async fn upsert_without_git_hash_leaves_it_null() {
539 let store = test_store().await;
540
541 store
542 .upsert_skill_trust("git", "trusted", SourceKind::Local, None, None, "hash1")
543 .await
544 .unwrap();
545
546 let row = store.load_skill_trust("git").await.unwrap().unwrap();
547 assert!(row.git_hash.is_none());
548 }
549
550 #[tokio::test]
551 async fn upsert_each_source_kind_roundtrip() {
552 let store = test_store().await;
553 let variants = [
554 ("skill-local", SourceKind::Local),
555 ("skill-hub", SourceKind::Hub),
556 ("skill-file", SourceKind::File),
557 ];
558 for (name, kind) in &variants {
559 store
560 .upsert_skill_trust(name, "trusted", kind.clone(), None, None, "hash")
561 .await
562 .unwrap();
563 let row = store.load_skill_trust(name).await.unwrap().unwrap();
564 assert_eq!(&row.source_kind, kind);
565 }
566 }
567}