1use anyhow::{Result, anyhow, bail};
2use std::collections::HashMap;
3use std::path::Path;
4use tracing::warn;
5use walkdir::WalkDir;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum SelectionMode {
9 All,
10 Select(Vec<String>),
11 Interactive,
12}
13
14pub fn parse_frontmatter(content: &str) -> Option<(Option<String>, Option<String>)> {
15 let mut lines = content.lines();
16 let first = lines.next()?.trim();
17 if first != "---" {
18 return None;
19 }
20
21 let mut name = None;
22 let mut description = None;
23 let mut found_end = false;
24
25 for line in lines {
26 let line = line.trim();
27 if line == "---" {
28 found_end = true;
29 break;
30 }
31 if let Some((k, v)) = line.split_once(':') {
32 let key = k.trim();
33 let val = v.trim().trim_matches('"').trim_matches('\'').to_string();
34 if key == "name" {
35 name = Some(val);
36 } else if key == "description" {
37 description = Some(val);
38 }
39 }
40 }
41
42 if found_end {
43 return Some((name, description));
44 }
45 None
46}
47
48pub fn derive_source_slug(url: &str) -> String {
49 let mut trimmed = url.trim();
50 if trimmed.ends_with(".git") {
51 trimmed = &trimmed[..trimmed.len() - 4];
52 }
53
54 trimmed = trimmed.trim_end_matches('/');
56
57 if let Some(pos) = trimmed.find("://") {
59 let path = &trimmed[pos + 3..];
60 if let Some(slash_pos) = path.find('/') {
61 let parts: Vec<&str> = path[slash_pos + 1..].split('/').collect();
62 if parts.len() >= 2 {
63 return format!("{}-{}", parts[0], parts[1]);
64 }
65 }
66 }
67
68 if let Some(colon_pos) = trimmed.find(':') {
70 let path = &trimmed[colon_pos + 1..];
71 let parts: Vec<&str> = path.split('/').collect();
72 if parts.len() >= 2 {
73 return format!("{}-{}", parts[0], parts[1]);
74 }
75 }
76
77 let parts: Vec<&str> = trimmed.split('/').collect();
79 if parts.len() >= 2 {
80 let len = parts.len();
81 return format!("{}-{}", parts[len - 2], parts[len - 1]);
82 }
83
84 crate::normalize::normalize_key(url)
85}
86
87pub fn resolve_selection(
88 skills: &[(String, String)],
89 mode: SelectionMode,
90 is_tty: bool,
91) -> Result<Vec<String>> {
92 match mode {
93 SelectionMode::All => Ok(skills.iter().map(|(n, _)| n.clone()).collect()),
94 SelectionMode::Select(names) => {
95 let mut selected = Vec::new();
96 for name in names {
97 if skills.iter().any(|(n, _)| n == &name) {
98 selected.push(name);
99 } else {
100 bail!("Skill '{}' not found in source", name);
101 }
102 }
103 Ok(selected)
104 }
105 SelectionMode::Interactive => {
106 if !is_tty {
107 bail!("Cannot resolve selection interactively: not a TTY. Use --all or --select");
108 }
109 use std::io::{self, Write};
110 println!("Available skills:");
111 for (i, (name, desc)) in skills.iter().enumerate() {
112 println!(" [{}] {} - {}", i + 1, name, desc);
113 }
114 print!("Enter the numbers of the skills to install (comma-separated, e.g. 1, 3): ");
115 io::stdout().flush()?;
116 let mut input = String::new();
117 io::stdin().read_line(&mut input)?;
118 let mut selected = Vec::new();
119 for part in input.split(|c: char| c == ',' || c.is_whitespace()) {
120 let part = part.trim();
121 if part.is_empty() {
122 continue;
123 }
124 if let Ok(idx) = part.parse::<usize>() {
125 if idx > 0 && idx <= skills.len() {
126 selected.push(skills[idx - 1].0.clone());
127 } else {
128 bail!("Invalid skill index: {}", idx);
129 }
130 } else {
131 bail!("Invalid input: {}", part);
132 }
133 }
134 if selected.is_empty() {
135 bail!("No skills selected");
136 }
137 Ok(selected)
138 }
139 }
140}
141
142pub async fn install_skills_from_dir<
143 #[cfg(feature = "documents")] E: crate::embed::EmbeddingProvider,
144>(
145 conn: &libsql::Connection,
146 dir_path: &Path,
147 source: &str,
148 version: &str,
149 mode: SelectionMode,
150 is_tty: bool,
151 prune: bool,
152 #[cfg(feature = "documents")] vector_ctx: Option<(&crate::vector::VectorStore, &E)>,
153) -> Result<()> {
154 let mut parsed_skills = Vec::new();
155 let mut skill_contents = HashMap::new();
156
157 for entry in WalkDir::new(dir_path)
158 .into_iter()
159 .filter_map(|e| e.ok())
160 .filter(|e| e.path().is_file() && e.path().extension().is_some_and(|ext| ext == "md"))
161 {
162 let mut content = std::fs::read_to_string(entry.path())?;
163 content = content.replace("\r\n", "\n");
164 if let Some((parsed_name, parsed_desc)) = parse_frontmatter(&content) {
165 let name = match parsed_name {
166 Some(n) => n,
167 None => {
168 let file_stem = entry
169 .path()
170 .file_stem()
171 .and_then(|s| s.to_str())
172 .unwrap_or("");
173 if file_stem.eq_ignore_ascii_case("SKILL")
174 || file_stem.eq_ignore_ascii_case("index")
175 {
176 entry
177 .path()
178 .parent()
179 .and_then(|p| p.file_name())
180 .and_then(|n| n.to_str())
181 .unwrap_or(file_stem)
182 .to_string()
183 } else {
184 file_stem.to_string()
185 }
186 }
187 };
188 let desc = parsed_desc.unwrap_or_default();
189 parsed_skills.push((name.clone(), desc));
190 skill_contents.insert(name, content);
191 } else {
192 if content.starts_with("---\n") {
194 warn!("Malformed frontmatter in skill file {:?}", entry.path());
195 }
196 }
197 }
198
199 if parsed_skills.is_empty() {
200 bail!("No valid skills found in {}", source);
201 }
202
203 let selected_names = resolve_selection(&parsed_skills, mode, is_tty)?;
204 let slug = derive_source_slug(source);
205
206 if prune {
210 let fresh: std::collections::HashSet<String> = selected_names
211 .iter()
212 .map(|n| crate::normalize::normalize_key(&format!("skill:{}:{}", slug, n)))
213 .collect();
214 let orphans: Vec<String> = crate::db::list_skills(conn)
215 .await?
216 .into_iter()
217 .filter(|s| derive_source_slug(&s.source) == slug && !fresh.contains(&s.entity_name))
218 .map(|s| s.entity_name)
219 .collect();
220 if !orphans.is_empty() {
221 warn!(
222 "Pruning {} orphaned skill(s) from {}",
223 orphans.len(),
224 source
225 );
226 crate::db::mcp_delete_entities(conn, orphans).await?;
227 }
228 }
229
230 for name in selected_names {
231 let body = skill_contents
232 .remove(&name)
233 .ok_or_else(|| anyhow!("Content missing for skill {}", name))?;
234 let description = parsed_skills
235 .iter()
236 .find(|(n, _)| n == &name)
237 .map(|(_, d)| d.as_str())
238 .unwrap_or("");
239
240 let entity_name = crate::normalize::normalize_key(&format!("skill:{}:{}", slug, name));
241
242 let tx = conn.transaction().await?;
243
244 tx.execute(
246 crate::constant::SQL_INSERT_ENTITY,
247 libsql::params![entity_name.clone(), "skill".to_string()],
248 )
249 .await?;
250
251 tx.execute(
253 crate::constant::SQL_UPSERT_TRUTH,
254 libsql::params![entity_name.clone(), "description".to_string(), description],
255 )
256 .await?;
257 tx.execute(
258 crate::constant::SQL_UPSERT_TRUTH,
259 libsql::params![entity_name.clone(), "source".to_string(), source],
260 )
261 .await?;
262 tx.execute(
263 crate::constant::SQL_UPSERT_TRUTH,
264 libsql::params![entity_name.clone(), "version".to_string(), version],
265 )
266 .await?;
267 tx.execute(
268 crate::constant::SQL_UPSERT_TRUTH,
269 libsql::params![
270 entity_name.clone(),
271 "installed".to_string(),
272 chrono::Utc::now().format("%Y-%m-%d").to_string()
273 ],
274 )
275 .await?;
276
277 tx.execute(
279 crate::constant::SQL_UPSERT_SKILL,
280 libsql::params![entity_name.clone(), body.clone(), source, version],
281 )
282 .await?;
283
284 tx.commit().await?;
285
286 #[cfg(feature = "documents")]
288 if let Some((store, embedder)) = vector_ctx {
289 store.delete_by_topic(&entity_name).await?;
291 crate::db::delete_topic(conn, &entity_name).await?;
292
293 let texts = crate::chunk::chunk_text(&body, 512, 64);
294 if !texts.is_empty() {
295 let vectors = embedder.embed(&texts).await?;
296 let chunks: Vec<crate::vector::Chunk> = texts
297 .into_iter()
298 .zip(vectors)
299 .enumerate()
300 .map(|(i, (text, vector))| crate::vector::Chunk {
301 id: uuid::Uuid::new_v4().to_string(),
302 topic_id: entity_name.clone(),
303 chunk_idx: i as u32,
304 text,
305 source: source.to_string(),
306 vector,
307 })
308 .collect();
309 store.insert_chunks(chunks).await?;
310 }
311 crate::db::upsert_topic(conn, &entity_name, &name, source, &body).await?;
312 }
313 }
314
315 Ok(())
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn test_parse_frontmatter_valid() {
324 let content = "---\nname: my-skill\ndescription: \"does something\"\n---\nbody content";
325 let parsed = parse_frontmatter(content);
326 assert_eq!(
327 parsed,
328 Some((
329 Some("my-skill".to_string()),
330 Some("does something".to_string())
331 ))
332 );
333 }
334
335 #[test]
336 fn test_parse_frontmatter_missing() {
337 let content = "no frontmatter here";
338 let parsed = parse_frontmatter(content);
339 assert_eq!(parsed, None);
340 }
341
342 #[test]
343 fn test_parse_frontmatter_malformed() {
344 let content = "---\nname: partial-skill\n---\nbody content";
345 let parsed = parse_frontmatter(content);
346 assert_eq!(parsed, Some((Some("partial-skill".to_string()), None)));
347 }
348
349 #[test]
350 fn test_derive_source_slug() {
351 assert_eq!(
352 derive_source_slug("https://github.com/jasonswett/llm-skills.git"),
353 "jasonswett-llm-skills"
354 );
355 assert_eq!(
356 derive_source_slug("git@github.com:jasonswett/llm-skills.git"),
357 "jasonswett-llm-skills"
358 );
359 assert_eq!(
360 derive_source_slug("/path/to/local-skills"),
361 "to-local-skills"
362 );
363 }
364
365 #[test]
366 fn test_resolve_selection_all() {
367 let skills = vec![
368 ("skill-a".to_string(), "desc-a".to_string()),
369 ("skill-b".to_string(), "desc-b".to_string()),
370 ];
371 let selected = resolve_selection(&skills, SelectionMode::All, false).unwrap();
372 assert_eq!(selected, vec!["skill-a", "skill-b"]);
373 }
374
375 #[test]
376 fn test_resolve_selection_select() {
377 let skills = vec![
378 ("skill-a".to_string(), "desc-a".to_string()),
379 ("skill-b".to_string(), "desc-b".to_string()),
380 ];
381 let selected = resolve_selection(
382 &skills,
383 SelectionMode::Select(vec!["skill-b".to_string()]),
384 false,
385 )
386 .unwrap();
387 assert_eq!(selected, vec!["skill-b"]);
388 }
389
390 #[tokio::test]
391 async fn test_install_from_local_git_repo() {
392 use tempfile::tempdir;
393 let git_dir = tempdir().unwrap();
394 let repo_path = git_dir.path();
395
396 std::process::Command::new("git")
398 .arg("init")
399 .current_dir(repo_path)
400 .status()
401 .unwrap();
402
403 std::process::Command::new("git")
405 .arg("config")
406 .arg("user.name")
407 .arg("Test User")
408 .current_dir(repo_path)
409 .status()
410 .unwrap();
411 std::process::Command::new("git")
412 .arg("config")
413 .arg("user.email")
414 .arg("test@example.com")
415 .current_dir(repo_path)
416 .status()
417 .unwrap();
418
419 let skill_file = repo_path.join("test-skill.md");
421 std::fs::write(
422 &skill_file,
423 "---\nname: repo-skill\ndescription: cloned skill\n---\nbody text\n",
424 )
425 .unwrap();
426
427 std::process::Command::new("git")
429 .arg("add")
430 .arg("test-skill.md")
431 .current_dir(repo_path)
432 .status()
433 .unwrap();
434 std::process::Command::new("git")
435 .arg("commit")
436 .arg("-m")
437 .arg("initial commit")
438 .current_dir(repo_path)
439 .status()
440 .unwrap();
441
442 let output = std::process::Command::new("git")
444 .arg("rev-parse")
445 .arg("HEAD")
446 .current_dir(repo_path)
447 .output()
448 .unwrap();
449 let head_commit = String::from_utf8(output.stdout).unwrap().trim().to_string();
450
451 let db_dir = tempdir().unwrap();
453 unsafe {
454 std::env::set_var(
455 crate::db::ENV_DATABASE_URL,
456 db_dir.path().join("test.db").to_str().unwrap(),
457 );
458 }
459 let (_db, conn) = crate::db::init_db().await.unwrap();
460
461 let clone_temp_dir = tempdir().unwrap();
463 let clone_path = clone_temp_dir.path();
464 std::process::Command::new("git")
465 .arg("clone")
466 .arg(repo_path.to_str().unwrap())
467 .arg(clone_path.to_str().unwrap())
468 .status()
469 .unwrap();
470
471 install_skills_from_dir(
472 &conn,
473 clone_path,
474 repo_path.to_str().unwrap(),
475 &head_commit,
476 SelectionMode::All,
477 false,
478 true,
479 #[cfg(feature = "documents")]
480 None::<(
481 &crate::vector::VectorStore,
482 &crate::embed::fastembed_provider::FastEmbedProvider,
483 )>,
484 )
485 .await
486 .unwrap();
487
488 let skills = crate::db::list_skills(&conn).await.unwrap();
490 assert_eq!(skills.len(), 1);
491 assert_eq!(
492 skills[0].entity_name,
493 crate::normalize::normalize_key(&format!(
494 "skill:{}:repo-skill",
495 derive_source_slug(repo_path.to_str().unwrap())
496 ))
497 );
498 assert_eq!(skills[0].version, head_commit);
499 }
500
501 #[cfg(feature = "documents")]
502 #[tokio::test]
503 async fn test_install_skills_document_embedding() {
504 use tempfile::tempdir;
505 let git_dir = tempdir().unwrap();
506 let repo_path = git_dir.path();
507
508 std::process::Command::new("git")
510 .arg("init")
511 .current_dir(repo_path)
512 .status()
513 .unwrap();
514
515 std::process::Command::new("git")
517 .arg("config")
518 .arg("user.name")
519 .arg("Test User")
520 .current_dir(repo_path)
521 .status()
522 .unwrap();
523 std::process::Command::new("git")
524 .arg("config")
525 .arg("user.email")
526 .arg("test@example.com")
527 .current_dir(repo_path)
528 .status()
529 .unwrap();
530
531 let skill_file = repo_path.join("test-skill.md");
533 std::fs::write(
534 &skill_file,
535 "---\nname: doc-skill\ndescription: searchable skill\n---\nHere is some unique knowledge about quantum cryptography.\n",
536 )
537 .unwrap();
538
539 std::process::Command::new("git")
541 .arg("add")
542 .arg("test-skill.md")
543 .current_dir(repo_path)
544 .status()
545 .unwrap();
546 std::process::Command::new("git")
547 .arg("commit")
548 .arg("-m")
549 .arg("initial commit")
550 .current_dir(repo_path)
551 .status()
552 .unwrap();
553
554 let output = std::process::Command::new("git")
556 .arg("rev-parse")
557 .arg("HEAD")
558 .current_dir(repo_path)
559 .output()
560 .unwrap();
561 let head_commit = String::from_utf8(output.stdout).unwrap().trim().to_string();
562
563 let db_dir = tempdir().unwrap();
565 unsafe {
566 std::env::set_var(
567 crate::db::ENV_DATABASE_URL,
568 db_dir.path().join("test.db").to_str().unwrap(),
569 );
570 }
571 let (_db, conn) = crate::db::init_db().await.unwrap();
572
573 let store = crate::vector::VectorStore::new_with_dim(conn.clone(), 384);
575
576 struct FakeEmbedder(usize);
577 impl crate::embed::EmbeddingProvider for FakeEmbedder {
578 async fn embed(&self, texts: &[String]) -> anyhow::Result<Vec<Vec<f32>>> {
579 Ok(texts.iter().map(|_| vec![0.1f32; self.0]).collect())
580 }
581 fn dim(&self) -> usize {
582 self.0
583 }
584 }
585 let embedder = FakeEmbedder(384);
586
587 let clone_temp_dir = tempdir().unwrap();
589 let clone_path = clone_temp_dir.path();
590 std::process::Command::new("git")
591 .arg("clone")
592 .arg(repo_path.to_str().unwrap())
593 .arg(clone_path.to_str().unwrap())
594 .status()
595 .unwrap();
596
597 install_skills_from_dir(
598 &conn,
599 clone_path,
600 repo_path.to_str().unwrap(),
601 &head_commit,
602 SelectionMode::All,
603 false,
604 true,
605 Some((&store, &embedder)),
606 )
607 .await
608 .unwrap();
609
610 let results = crate::recall::recall("cryptography", &conn, &store, &embedder, 5)
612 .await
613 .unwrap();
614 assert!(!results.is_empty(), "expected skill to be queryable");
615
616 let slug = derive_source_slug(repo_path.to_str().unwrap());
618 let expected_topic_id =
619 crate::normalize::normalize_key(&format!("skill:{}:doc-skill", slug));
620 assert_eq!(results[0].topic_id, expected_topic_id);
621 assert_eq!(results[0].title, "doc-skill");
622 assert!(results[0].snippet.contains("quantum cryptography"));
623 }
624
625 #[tokio::test]
626 async fn test_sync_prunes_orphaned_skills() {
627 use tempfile::tempdir;
628 let src_dir = tempdir().unwrap();
629 let src = src_dir.path();
630
631 std::fs::write(
633 src.join("alpha.md"),
634 "---\nname: alpha\ndescription: a\n---\nalpha body\n",
635 )
636 .unwrap();
637 std::fs::write(
638 src.join("beta.md"),
639 "---\nname: beta\ndescription: b\n---\nbeta body\n",
640 )
641 .unwrap();
642
643 let db_dir = tempdir().unwrap();
644 unsafe {
645 std::env::set_var(
646 crate::db::ENV_DATABASE_URL,
647 db_dir.path().join("test.db").to_str().unwrap(),
648 );
649 }
650 let (_db, conn) = crate::db::init_db().await.unwrap();
651
652 let source = src.to_str().unwrap();
653 let slug = derive_source_slug(source);
654
655 install_skills_from_dir(
656 &conn,
657 src,
658 source,
659 "v1",
660 SelectionMode::All,
661 false,
662 true,
663 #[cfg(feature = "documents")]
664 None::<(
665 &crate::vector::VectorStore,
666 &crate::embed::fastembed_provider::FastEmbedProvider,
667 )>,
668 )
669 .await
670 .unwrap();
671 assert_eq!(crate::db::list_skills(&conn).await.unwrap().len(), 2);
672
673 std::fs::remove_file(src.join("beta.md")).unwrap();
675
676 install_skills_from_dir(
677 &conn,
678 src,
679 source,
680 "v2",
681 SelectionMode::All,
682 false,
683 true,
684 #[cfg(feature = "documents")]
685 None::<(
686 &crate::vector::VectorStore,
687 &crate::embed::fastembed_provider::FastEmbedProvider,
688 )>,
689 )
690 .await
691 .unwrap();
692
693 let skills = crate::db::list_skills(&conn).await.unwrap();
694 assert_eq!(skills.len(), 1);
695 let alpha = crate::normalize::normalize_key(&format!("skill:{}:alpha", slug));
696 assert_eq!(skills[0].entity_name, alpha);
697 assert_eq!(skills[0].version, "v2");
698 }
699
700 #[tokio::test]
701 async fn test_select_does_not_prune() {
702 use tempfile::tempdir;
703 let src_dir = tempdir().unwrap();
704 let src = src_dir.path();
705
706 std::fs::write(
707 src.join("alpha.md"),
708 "---\nname: alpha\ndescription: a\n---\nalpha body\n",
709 )
710 .unwrap();
711 std::fs::write(
712 src.join("beta.md"),
713 "---\nname: beta\ndescription: b\n---\nbeta body\n",
714 )
715 .unwrap();
716
717 let db_dir = tempdir().unwrap();
718 unsafe {
719 std::env::set_var(
720 crate::db::ENV_DATABASE_URL,
721 db_dir.path().join("test.db").to_str().unwrap(),
722 );
723 }
724 let (_db, conn) = crate::db::init_db().await.unwrap();
725 let source = src.to_str().unwrap();
726
727 for name in ["alpha", "beta"] {
729 install_skills_from_dir(
730 &conn,
731 src,
732 source,
733 "v1",
734 SelectionMode::Select(vec![name.to_string()]),
735 false,
736 false,
737 #[cfg(feature = "documents")]
738 None::<(
739 &crate::vector::VectorStore,
740 &crate::embed::fastembed_provider::FastEmbedProvider,
741 )>,
742 )
743 .await
744 .unwrap();
745 }
746
747 assert_eq!(crate::db::list_skills(&conn).await.unwrap().len(), 2);
748 }
749
750 #[tokio::test]
751 async fn test_install_skills_with_fallbacks() {
752 use tempfile::tempdir;
753 let git_dir = tempdir().unwrap();
754 let repo_path = git_dir.path();
755
756 std::process::Command::new("git")
758 .arg("init")
759 .current_dir(repo_path)
760 .status()
761 .unwrap();
762
763 std::process::Command::new("git")
765 .arg("config")
766 .arg("user.name")
767 .arg("Test User")
768 .current_dir(repo_path)
769 .status()
770 .unwrap();
771 std::process::Command::new("git")
772 .arg("config")
773 .arg("user.email")
774 .arg("test@example.com")
775 .current_dir(repo_path)
776 .status()
777 .unwrap();
778
779 let refactor_file = repo_path.join("refactor.md");
781 std::fs::write(
782 &refactor_file,
783 "---\ndescription: Iterative refactoring loop\n---\nrefactor body\n",
784 )
785 .unwrap();
786
787 let sdr_dir = repo_path.join("software-design-review");
788 std::fs::create_dir(&sdr_dir).unwrap();
789 let sdr_file = sdr_dir.join("SKILL.md");
790 std::fs::write(
791 &sdr_file,
792 "---\nname: software-design-review\n---\nsdr body\n",
793 )
794 .unwrap();
795
796 std::process::Command::new("git")
798 .arg("add")
799 .arg("refactor.md")
800 .arg("software-design-review/SKILL.md")
801 .current_dir(repo_path)
802 .status()
803 .unwrap();
804 std::process::Command::new("git")
805 .arg("commit")
806 .arg("-m")
807 .arg("add skills")
808 .current_dir(repo_path)
809 .status()
810 .unwrap();
811
812 let output = std::process::Command::new("git")
814 .arg("rev-parse")
815 .arg("HEAD")
816 .current_dir(repo_path)
817 .output()
818 .unwrap();
819 let head_commit = String::from_utf8(output.stdout).unwrap().trim().to_string();
820
821 let db_dir = tempdir().unwrap();
823 unsafe {
824 std::env::set_var(
825 crate::db::ENV_DATABASE_URL,
826 db_dir.path().join("test.db").to_str().unwrap(),
827 );
828 }
829 let (_db, conn) = crate::db::init_db().await.unwrap();
830
831 let clone_temp_dir = tempdir().unwrap();
833 let clone_path = clone_temp_dir.path();
834 std::process::Command::new("git")
835 .arg("clone")
836 .arg(repo_path.to_str().unwrap())
837 .arg(clone_path.to_str().unwrap())
838 .status()
839 .unwrap();
840
841 install_skills_from_dir(
842 &conn,
843 clone_path,
844 repo_path.to_str().unwrap(),
845 &head_commit,
846 SelectionMode::All,
847 false,
848 true,
849 #[cfg(feature = "documents")]
850 None::<(
851 &crate::vector::VectorStore,
852 &crate::embed::fastembed_provider::FastEmbedProvider,
853 )>,
854 )
855 .await
856 .unwrap();
857
858 let skills = crate::db::list_skills(&conn).await.unwrap();
860 assert_eq!(skills.len(), 2);
861
862 let slug = derive_source_slug(repo_path.to_str().unwrap());
863
864 let refactor_entity = crate::normalize::normalize_key(&format!("skill:{}:refactor", slug));
865 let sdr_entity =
866 crate::normalize::normalize_key(&format!("skill:{}:software-design-review", slug));
867
868 let refactor_row = skills
869 .iter()
870 .find(|s| s.entity_name == refactor_entity)
871 .unwrap();
872 assert_eq!(refactor_row.description, "Iterative refactoring loop");
873
874 let sdr_row = skills.iter().find(|s| s.entity_name == sdr_entity).unwrap();
875 assert_eq!(sdr_row.description, "");
876 }
877}