1use anyhow::{Context, Result};
2use serde::Serialize;
3use std::collections::{HashMap, HashSet};
4use std::io::BufRead;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, Serialize)]
8pub struct SkillEntry {
9 pub name: String,
10 pub path: PathBuf,
11 pub has_skill_md: bool,
12 pub is_symlink: bool,
13 pub description: Option<String>,
14 pub issues: Vec<String>,
15 #[serde(skip_serializing_if = "Option::is_none")]
16 pub invocation_count: Option<u32>,
17}
18
19#[derive(Debug, Clone, Serialize)]
20pub struct SkillUsage {
21 pub skill: String,
22 pub count: u32,
23}
24
25#[derive(Debug, Clone, Serialize)]
26pub struct CleanupEntry {
27 pub skill: String,
28 pub reasons: Vec<String>,
29 pub token_estimate: usize,
30}
31
32#[derive(Debug, Clone, Serialize)]
33pub struct AuditResult {
34 pub skills_dir: PathBuf,
35 pub total: usize,
36 pub healthy: usize,
37 pub broken: usize,
38 pub skills: Vec<SkillEntry>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub manifest_diffs: Option<Vec<ManifestDiff>>,
41 #[serde(skip_serializing_if = "Vec::is_empty")]
42 pub similar_pairs: Vec<SimilarPair>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub usage: Option<Vec<SkillUsage>>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub cleanup: Option<Vec<CleanupEntry>>,
47}
48
49#[derive(Debug, Clone, Serialize)]
50pub struct SimilarPair {
51 pub skill_a: String,
52 pub skill_b: String,
53 pub score: f32,
55 pub desc_a: String,
56 pub desc_b: String,
57}
58
59#[derive(Debug, Clone, Serialize)]
60pub struct ManifestDiff {
61 pub name: String,
62 pub kind: DiffKind,
63}
64
65#[derive(Debug, Clone, Serialize)]
66#[serde(rename_all = "lowercase")]
67pub enum DiffKind {
68 Missing,
69 Orphan,
70}
71
72pub fn scan_skills(skills_dir: &Path) -> Result<AuditResult> {
73 if !skills_dir.exists() {
74 return Ok(AuditResult {
75 skills_dir: skills_dir.to_path_buf(),
76 total: 0,
77 healthy: 0,
78 broken: 0,
79 skills: Vec::new(),
80 manifest_diffs: None,
81 similar_pairs: Vec::new(),
82 usage: None,
83 cleanup: None,
84 });
85 }
86
87 let mut skills = Vec::new();
88
89 let entries = std::fs::read_dir(skills_dir)
90 .with_context(|| format!("reading skills directory: {}", skills_dir.display()))?;
91
92 for entry in entries {
93 let entry = entry?;
94 let path = entry.path();
95
96 let is_symlink = entry.file_type()?.is_symlink();
97 let resolved = if is_symlink {
98 std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone())
99 } else {
100 path.clone()
101 };
102
103 if !resolved.is_dir() {
104 continue;
105 }
106
107 let name = path
108 .file_name()
109 .and_then(|n| n.to_str())
110 .unwrap_or("unknown")
111 .to_string();
112
113 let skill_md = resolved.join("SKILL.md");
114 let has_skill_md = skill_md.exists();
115 let mut issues = Vec::new();
116 let mut description = None;
117
118 if is_symlink && !resolved.exists() {
119 issues.push("broken symlink".to_string());
120 } else if !has_skill_md {
121 issues.push("SKILL.md missing".to_string());
122 } else {
123 match std::fs::read_to_string(&skill_md) {
124 Ok(content) => {
125 if content.trim().is_empty() {
126 issues.push("SKILL.md is empty".to_string());
127 } else {
128 description = extract_frontmatter_field(&content, "description");
129 if description.is_none() {
130 issues.push("no description in SKILL.md frontmatter".to_string());
131 }
132 }
133 }
134 Err(e) => {
135 issues.push(format!("SKILL.md unreadable: {}", e));
136 }
137 }
138 }
139
140 skills.push(SkillEntry {
141 name,
142 path,
143 has_skill_md,
144 is_symlink,
145 description,
146 issues,
147 invocation_count: None,
148 });
149 }
150
151 skills.sort_by(|a, b| a.name.cmp(&b.name));
152
153 let total = skills.len();
154 let broken = skills.iter().filter(|s| !s.issues.is_empty()).count();
155
156 let similar_pairs = find_similar_pairs(&skills, 0.3);
157
158 Ok(AuditResult {
159 skills_dir: skills_dir.to_path_buf(),
160 total,
161 healthy: total - broken,
162 broken,
163 skills,
164 manifest_diffs: None,
165 similar_pairs,
166 usage: None,
167 cleanup: None,
168 })
169}
170
171pub fn find_similar_pairs(skills: &[SkillEntry], threshold: f32) -> Vec<SimilarPair> {
174 let mut pairs = Vec::new();
175 for i in 0..skills.len() {
176 let a = &skills[i];
177 let Some(desc_a) = &a.description else {
178 continue;
179 };
180 let tokens_a = description_tokens(desc_a);
181 if tokens_a.is_empty() {
182 continue;
183 }
184 for b in skills.iter().skip(i + 1) {
185 let Some(desc_b) = &b.description else {
186 continue;
187 };
188 let tokens_b = description_tokens(desc_b);
189 if tokens_b.is_empty() {
190 continue;
191 }
192 let intersection = tokens_a.intersection(&tokens_b).count();
193 let union = tokens_a.union(&tokens_b).count();
194 if union == 0 {
195 continue;
196 }
197 let score = intersection as f32 / union as f32;
198 if score >= threshold {
199 pairs.push(SimilarPair {
200 skill_a: a.name.clone(),
201 skill_b: b.name.clone(),
202 score,
203 desc_a: desc_a.clone(),
204 desc_b: desc_b.clone(),
205 });
206 }
207 }
208 }
209 pairs.sort_by(|x, y| {
210 y.score
211 .partial_cmp(&x.score)
212 .unwrap_or(std::cmp::Ordering::Equal)
213 });
214 pairs
215}
216
217static STOP_WORDS: &[&str] = &[
218 "a", "an", "and", "are", "as", "at", "be", "by", "do", "for", "from", "has", "have", "in",
219 "is", "it", "its", "not", "of", "on", "or", "the", "to", "use", "used", "via", "with",
220];
221
222fn description_tokens(desc: &str) -> HashSet<String> {
223 let stop: HashSet<&str> = STOP_WORDS.iter().copied().collect();
224 desc.split(|c: char| !c.is_alphanumeric())
225 .map(|w| w.to_lowercase())
226 .filter(|w| w.len() >= 2 && !stop.contains(w.as_str()))
227 .collect()
228}
229
230pub fn compare_manifest(audit: &mut AuditResult, manifest_path: &Path) -> Result<()> {
231 let content = std::fs::read_to_string(manifest_path)
232 .with_context(|| format!("reading manifest: {}", manifest_path.display()))?;
233
234 let manifest_names: Vec<String> = content
235 .lines()
236 .map(|l| l.trim())
237 .filter(|l| !l.is_empty() && !l.starts_with('#'))
238 .map(|l| l.to_string())
239 .collect();
240
241 let installed: HashSet<&str> = audit.skills.iter().map(|s| s.name.as_str()).collect();
242 let expected: HashSet<&str> = manifest_names.iter().map(|s| s.as_str()).collect();
243
244 let mut diffs = Vec::new();
245
246 for name in &manifest_names {
247 if !installed.contains(name.as_str()) {
248 diffs.push(ManifestDiff {
249 name: name.clone(),
250 kind: DiffKind::Missing,
251 });
252 }
253 }
254
255 for skill in &audit.skills {
256 if !expected.contains(skill.name.as_str()) {
257 diffs.push(ManifestDiff {
258 name: skill.name.clone(),
259 kind: DiffKind::Orphan,
260 });
261 }
262 }
263
264 audit.manifest_diffs = Some(diffs);
265 Ok(())
266}
267
268pub fn track_usage(audit: &mut AuditResult) -> Result<()> {
269 let home = std::env::var("HOME").context("HOME not set")?;
270 let projects_dir = PathBuf::from(&home).join(".claude/projects");
271 if !projects_dir.exists() {
272 audit.usage = Some(Vec::new());
273 return Ok(());
274 }
275
276 let mut counts: HashMap<String, u32> = HashMap::new();
277
278 for project_entry in std::fs::read_dir(&projects_dir)? {
279 let project_entry = project_entry?;
280 let project_path = project_entry.path();
281 if !project_path.is_dir() {
282 continue;
283 }
284 scan_session_dir(&project_path, &mut counts)?;
285 }
286
287 let installed: HashSet<String> = audit.skills.iter().map(|s| s.name.clone()).collect();
288
289 for skill in &mut audit.skills {
290 let count = counts.get(skill.name.as_str()).copied().unwrap_or(0);
291 skill.invocation_count = Some(count);
292 }
293
294 let mut usage_list: Vec<SkillUsage> = audit
295 .skills
296 .iter()
297 .map(|s| SkillUsage {
298 skill: s.name.clone(),
299 count: s.invocation_count.unwrap_or(0),
300 })
301 .collect();
302
303 for (name, count) in &counts {
304 if !installed.contains(name.as_str()) {
305 usage_list.push(SkillUsage {
306 skill: name.clone(),
307 count: *count,
308 });
309 }
310 }
311
312 usage_list.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.skill.cmp(&b.skill)));
313 audit.usage = Some(usage_list);
314 Ok(())
315}
316
317fn scan_session_dir(dir: &Path, counts: &mut HashMap<String, u32>) -> Result<()> {
318 for entry in std::fs::read_dir(dir)? {
319 let entry = entry?;
320 let path = entry.path();
321 if path.extension().is_some_and(|e| e == "jsonl") {
322 scan_jsonl(&path, counts).ok();
323 } else if path.is_dir() {
324 scan_session_dir(&path, counts).ok();
325 }
326 }
327 Ok(())
328}
329
330fn scan_jsonl(path: &Path, counts: &mut HashMap<String, u32>) -> Result<()> {
331 let file = std::fs::File::open(path)?;
332 let reader = std::io::BufReader::new(file);
333 for line in reader.lines() {
334 let line = line?;
335 if !line.contains("\"Skill\"") {
336 continue;
337 }
338 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&line) {
339 extract_skill_names(&val, counts);
340 }
341 }
342 Ok(())
343}
344
345fn extract_skill_names(val: &serde_json::Value, counts: &mut HashMap<String, u32>) {
346 if let Some(content) = val
347 .get("message")
348 .and_then(|m| m.get("content"))
349 .and_then(|c| c.as_array())
350 {
351 for block in content {
352 if block.get("type").and_then(|t| t.as_str()) == Some("tool_use")
353 && block.get("name").and_then(|n| n.as_str()) == Some("Skill")
354 && let Some(skill_name) = block
355 .get("input")
356 .and_then(|i| i.get("skill"))
357 .and_then(|s| s.as_str())
358 {
359 let base_name = skill_name.split(':').next().unwrap_or(skill_name);
360 *counts.entry(base_name.to_string()).or_insert(0) += 1;
361 }
362 }
363 }
364}
365
366pub fn generate_cleanup(audit: &mut AuditResult) {
367 let mut entries = Vec::new();
368
369 for skill in &audit.skills {
370 let mut reasons = Vec::new();
371
372 if !skill.issues.is_empty() {
373 reasons.push(format!("health: {}", skill.issues.join(", ")));
374 }
375
376 if skill.invocation_count == Some(0) {
377 reasons.push("never used in any session".to_string());
378 }
379
380 let is_duplicate = audit
381 .similar_pairs
382 .iter()
383 .any(|p| (p.skill_a == skill.name || p.skill_b == skill.name) && p.score >= 0.5);
384 if is_duplicate {
385 reasons.push("high similarity with another skill (≥50%)".to_string());
386 }
387
388 if !reasons.is_empty() {
389 let token_estimate = estimate_skill_tokens(&skill.path);
390 entries.push(CleanupEntry {
391 skill: skill.name.clone(),
392 reasons,
393 token_estimate,
394 });
395 }
396 }
397
398 entries.sort_by_key(|entry| std::cmp::Reverse(entry.token_estimate));
399 audit.cleanup = Some(entries);
400}
401
402fn estimate_skill_tokens(skill_dir: &Path) -> usize {
403 let mut total_bytes: u64 = 0;
404 if let Ok(entries) = walkdir(skill_dir) {
405 for path in entries {
406 if let Ok(meta) = std::fs::metadata(&path)
407 && meta.is_file()
408 {
409 total_bytes += meta.len();
410 }
411 }
412 }
413 (total_bytes as usize) / 4
414}
415
416fn walkdir(dir: &Path) -> Result<Vec<PathBuf>> {
417 let mut files = Vec::new();
418 if !dir.is_dir() {
419 return Ok(files);
420 }
421 for entry in std::fs::read_dir(dir)? {
422 let entry = entry?;
423 let path = entry.path();
424 if path.is_dir() {
425 files.extend(walkdir(&path)?);
426 } else {
427 files.push(path);
428 }
429 }
430 Ok(files)
431}
432
433pub fn write_report(audit: &AuditResult, path: &Path) -> Result<()> {
434 let mut report = String::new();
435 report.push_str("# Skill Audit Report\n\n");
436 report.push_str(&format!("**Generated:** {}\n\n", chrono_now()));
437 report.push_str(&format!(
438 "**Skills directory:** `{}`\n\n",
439 audit.skills_dir.display()
440 ));
441 report.push_str(&format!(
442 "| Metric | Count |\n|--------|-------|\n| Total | {} |\n| Healthy | {} |\n| Broken | {} |\n\n",
443 audit.total, audit.healthy, audit.broken
444 ));
445
446 report.push_str("## Skills\n\n");
447 report.push_str(
448 "| Status | Name | Description | Uses |\n|--------|------|-------------|------|\n",
449 );
450 for skill in &audit.skills {
451 let status = if skill.issues.is_empty() {
452 "ok"
453 } else {
454 "broken"
455 };
456 let desc = skill.description.as_deref().unwrap_or("-");
457 let uses = skill
458 .invocation_count
459 .map(|c| c.to_string())
460 .unwrap_or_else(|| "-".to_string());
461 report.push_str(&format!(
462 "| {} | {} | {} | {} |\n",
463 status, skill.name, desc, uses
464 ));
465 }
466
467 if !audit.similar_pairs.is_empty() {
468 report.push_str("\n## Possible Duplicates\n\n");
469 report.push_str("| Score | Skill A | Skill B |\n|-------|---------|--------|\n");
470 for pair in &audit.similar_pairs {
471 report.push_str(&format!(
472 "| {:.0}% | {} | {} |\n",
473 pair.score * 100.0,
474 pair.skill_a,
475 pair.skill_b
476 ));
477 }
478 }
479
480 if let Some(diffs) = &audit.manifest_diffs
481 && !diffs.is_empty()
482 {
483 report.push_str("\n## Manifest Diffs\n\n");
484 report.push_str("| Name | Status |\n|------|--------|\n");
485 for diff in diffs {
486 let label = match diff.kind {
487 DiffKind::Missing => "missing",
488 DiffKind::Orphan => "orphan",
489 };
490 report.push_str(&format!("| {} | {} |\n", diff.name, label));
491 }
492 }
493
494 if let Some(cleanup) = &audit.cleanup
495 && !cleanup.is_empty()
496 {
497 report.push_str("\n## Cleanup Recommendations\n\n");
498 report
499 .push_str("| Skill | Token Savings | Reasons |\n|-------|---------------|--------|\n");
500 for entry in cleanup {
501 report.push_str(&format!(
502 "| {} | ~{} | {} |\n",
503 entry.skill,
504 format_tokens(entry.token_estimate),
505 entry.reasons.join("; ")
506 ));
507 }
508 let total: usize = cleanup.iter().map(|e| e.token_estimate).sum();
509 report.push_str(&format!(
510 "\n**Total potential savings:** ~{}\n",
511 format_tokens(total)
512 ));
513 }
514
515 if let Some(parent) = path.parent() {
516 std::fs::create_dir_all(parent)?;
517 }
518 std::fs::write(path, &report)
519 .with_context(|| format!("writing report to {}", path.display()))?;
520 Ok(())
521}
522
523fn chrono_now() -> String {
524 let duration = std::time::SystemTime::now()
525 .duration_since(std::time::UNIX_EPOCH)
526 .unwrap_or_default();
527 let secs = duration.as_secs();
528 let days = secs / 86400;
529 let year = 1970 + (days * 400 / 146097);
530 format!("{}-xx-xx (epoch {})", year, secs)
531}
532
533fn format_tokens(tokens: usize) -> String {
534 if tokens >= 1_000_000 {
535 format!("{:.1}M tokens", tokens as f64 / 1_000_000.0)
536 } else if tokens >= 1_000 {
537 format!("{:.1}K tokens", tokens as f64 / 1_000.0)
538 } else {
539 format!("{} tokens", tokens)
540 }
541}
542
543fn extract_frontmatter_field(content: &str, field: &str) -> Option<String> {
544 let mut in_frontmatter = false;
545 let prefix = format!("{}:", field);
546
547 for line in content.lines() {
548 let trimmed = line.trim();
549 if trimmed == "---" {
550 if in_frontmatter {
551 return None;
552 }
553 in_frontmatter = true;
554 continue;
555 }
556 if !in_frontmatter {
557 continue;
558 }
559 if trimmed.starts_with(&prefix) {
560 let value = trimmed[prefix.len()..].trim();
561 let unquoted = value
562 .trim_start_matches('"')
563 .trim_end_matches('"')
564 .trim_start_matches('\'')
565 .trim_end_matches('\'');
566 if !unquoted.is_empty() {
567 return Some(unquoted.to_string());
568 }
569 }
570 }
571 None
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use std::fs;
578
579 #[test]
580 fn scan_empty_dir() {
581 let dir = tempfile::tempdir().unwrap();
582 let result = scan_skills(dir.path()).unwrap();
583 assert_eq!(result.total, 0);
584 assert_eq!(result.healthy, 0);
585 assert_eq!(result.broken, 0);
586 }
587
588 #[test]
589 fn scan_nonexistent_dir() {
590 let result = scan_skills(Path::new("/nonexistent/skills")).unwrap();
591 assert_eq!(result.total, 0);
592 }
593
594 #[test]
595 fn scan_healthy_skill() {
596 let dir = tempfile::tempdir().unwrap();
597 let skill = dir.path().join("my-skill");
598 fs::create_dir(&skill).unwrap();
599 fs::write(
600 skill.join("SKILL.md"),
601 "---\ndescription: A test skill\n---\n# my-skill\nDoes things.\n",
602 )
603 .unwrap();
604
605 let result = scan_skills(dir.path()).unwrap();
606 assert_eq!(result.total, 1);
607 assert_eq!(result.healthy, 1);
608 assert_eq!(result.broken, 0);
609 assert_eq!(result.skills[0].name, "my-skill");
610 assert_eq!(
611 result.skills[0].description.as_deref(),
612 Some("A test skill")
613 );
614 assert!(result.skills[0].issues.is_empty());
615 }
616
617 #[test]
618 fn scan_missing_skill_md() {
619 let dir = tempfile::tempdir().unwrap();
620 fs::create_dir(dir.path().join("broken")).unwrap();
621
622 let result = scan_skills(dir.path()).unwrap();
623 assert_eq!(result.total, 1);
624 assert_eq!(result.broken, 1);
625 assert!(
626 result.skills[0]
627 .issues
628 .contains(&"SKILL.md missing".to_string())
629 );
630 }
631
632 #[test]
633 fn scan_empty_skill_md() {
634 let dir = tempfile::tempdir().unwrap();
635 let skill = dir.path().join("empty");
636 fs::create_dir(&skill).unwrap();
637 fs::write(skill.join("SKILL.md"), "").unwrap();
638
639 let result = scan_skills(dir.path()).unwrap();
640 assert_eq!(result.broken, 1);
641 assert!(
642 result.skills[0]
643 .issues
644 .contains(&"SKILL.md is empty".to_string())
645 );
646 }
647
648 #[test]
649 fn scan_no_description_in_frontmatter() {
650 let dir = tempfile::tempdir().unwrap();
651 let skill = dir.path().join("no-desc");
652 fs::create_dir(&skill).unwrap();
653 fs::write(
654 skill.join("SKILL.md"),
655 "---\nname: no-desc\n---\n# no-desc\n",
656 )
657 .unwrap();
658
659 let result = scan_skills(dir.path()).unwrap();
660 assert_eq!(result.broken, 1);
661 assert!(
662 result.skills[0]
663 .issues
664 .iter()
665 .any(|i| i.contains("no description"))
666 );
667 }
668
669 #[test]
670 fn scan_multiple_skills_sorted() {
671 let dir = tempfile::tempdir().unwrap();
672 for name in &["zebra", "alpha", "mid"] {
673 let skill = dir.path().join(name);
674 fs::create_dir(&skill).unwrap();
675 fs::write(
676 skill.join("SKILL.md"),
677 format!("---\ndescription: {name} skill\n---\n"),
678 )
679 .unwrap();
680 }
681
682 let result = scan_skills(dir.path()).unwrap();
683 assert_eq!(result.total, 3);
684 assert_eq!(result.skills[0].name, "alpha");
685 assert_eq!(result.skills[1].name, "mid");
686 assert_eq!(result.skills[2].name, "zebra");
687 }
688
689 #[test]
690 fn manifest_missing_and_orphan() {
691 let dir = tempfile::tempdir().unwrap();
692 let skill = dir.path().join("installed");
693 fs::create_dir(&skill).unwrap();
694 fs::write(skill.join("SKILL.md"), "---\ndescription: installed\n---\n").unwrap();
695
696 let manifest = dir.path().join("manifest.txt");
697 fs::write(&manifest, "installed\nexpected-but-missing\n").unwrap();
698
699 let mut result = scan_skills(dir.path()).unwrap();
700 compare_manifest(&mut result, &manifest).unwrap();
701
702 let diffs = result.manifest_diffs.unwrap();
703 assert_eq!(diffs.len(), 1);
704 assert_eq!(diffs[0].name, "expected-but-missing");
705 assert!(matches!(diffs[0].kind, DiffKind::Missing));
706 }
707
708 #[test]
709 fn manifest_orphan_detected() {
710 let dir = tempfile::tempdir().unwrap();
711 let skill = dir.path().join("orphan");
712 fs::create_dir(&skill).unwrap();
713 fs::write(
714 skill.join("SKILL.md"),
715 "---\ndescription: orphan skill\n---\n",
716 )
717 .unwrap();
718
719 let manifest = dir.path().join("manifest.txt");
720 fs::write(&manifest, "# expected skills\nother-skill\n").unwrap();
721
722 let mut result = scan_skills(dir.path()).unwrap();
723 compare_manifest(&mut result, &manifest).unwrap();
724
725 let diffs = result.manifest_diffs.unwrap();
726 let orphans: Vec<_> = diffs
727 .iter()
728 .filter(|d| matches!(d.kind, DiffKind::Orphan))
729 .collect();
730 assert_eq!(orphans.len(), 1);
731 assert_eq!(orphans[0].name, "orphan");
732 }
733
734 #[test]
735 fn extract_quoted_description() {
736 let content = "---\ndescription: \"A quoted description\"\n---\n";
737 assert_eq!(
738 extract_frontmatter_field(content, "description"),
739 Some("A quoted description".to_string())
740 );
741 }
742
743 #[test]
744 fn extract_unquoted_description() {
745 let content = "---\ndescription: Simple description\n---\n";
746 assert_eq!(
747 extract_frontmatter_field(content, "description"),
748 Some("Simple description".to_string())
749 );
750 }
751
752 #[test]
753 fn extract_missing_field() {
754 let content = "---\nname: test\n---\n";
755 assert_eq!(extract_frontmatter_field(content, "description"), None);
756 }
757
758 #[test]
759 fn files_in_skills_dir_ignored() {
760 let dir = tempfile::tempdir().unwrap();
761 fs::write(dir.path().join("not-a-skill.txt"), "just a file").unwrap();
762
763 let result = scan_skills(dir.path()).unwrap();
764 assert_eq!(result.total, 0);
765 }
766
767 fn make_skill(name: &str, description: &str) -> SkillEntry {
770 SkillEntry {
771 name: name.to_string(),
772 path: PathBuf::from(name),
773 has_skill_md: true,
774 is_symlink: false,
775 description: Some(description.to_string()),
776 issues: Vec::new(),
777 invocation_count: None,
778 }
779 }
780
781 #[test]
782 fn similar_pairs_identical_descriptions() {
783 let skills = vec![
784 make_skill("skill-a", "search code and files"),
785 make_skill("skill-b", "search code and files"),
786 ];
787 let pairs = find_similar_pairs(&skills, 0.3);
788 assert_eq!(pairs.len(), 1);
789 assert!((pairs[0].score - 1.0).abs() < 0.01);
790 assert_eq!(pairs[0].skill_a, "skill-a");
791 assert_eq!(pairs[0].skill_b, "skill-b");
792 }
793
794 #[test]
795 fn similar_pairs_high_overlap() {
796 let skills = vec![
797 make_skill("search-tool", "fast semantic search over code symbols"),
798 make_skill("code-search", "semantic search over code and symbols"),
799 ];
800 let pairs = find_similar_pairs(&skills, 0.3);
801 assert_eq!(pairs.len(), 1);
802 assert!(pairs[0].score >= 0.3);
803 }
804
805 #[test]
806 fn similar_pairs_no_overlap() {
807 let skills = vec![
808 make_skill("graph-tool", "visualize dependency graph"),
809 make_skill("email-tool", "draft and send emails"),
810 ];
811 let pairs = find_similar_pairs(&skills, 0.3);
812 assert_eq!(pairs.len(), 0);
813 }
814
815 #[test]
816 fn similar_pairs_below_threshold() {
817 let skills = vec![
818 make_skill("skill-a", "search files"),
819 make_skill("skill-b", "analyze graph structure"),
820 ];
821 let pairs = find_similar_pairs(&skills, 0.3);
822 assert_eq!(pairs.len(), 0);
823 }
824
825 #[test]
826 fn similar_pairs_sorted_descending() {
827 let skills = vec![
828 make_skill("a", "search code symbols files index"),
829 make_skill("b", "search code symbols index"),
830 make_skill("c", "search code symbols files index queries"),
831 ];
832 let pairs = find_similar_pairs(&skills, 0.3);
833 for i in 1..pairs.len() {
835 assert!(pairs[i - 1].score >= pairs[i].score);
836 }
837 }
838
839 #[test]
840 fn similar_pairs_skips_no_description() {
841 let mut no_desc = make_skill("no-desc", "");
842 no_desc.description = None;
843 let skills = vec![
844 no_desc,
845 make_skill("skill-a", "search code files"),
846 make_skill("skill-b", "search code files"),
847 ];
848 let pairs = find_similar_pairs(&skills, 0.3);
849 assert_eq!(pairs.len(), 1);
851 }
852
853 #[test]
854 fn similar_pairs_stop_words_excluded() {
855 let skills = vec![
857 make_skill("skill-a", "the a and for to"),
858 make_skill("skill-b", "the a and for to in"),
859 ];
860 let pairs = find_similar_pairs(&skills, 0.3);
861 assert_eq!(pairs.len(), 0);
862 }
863
864 #[test]
865 fn scan_detects_similar_skills() {
866 let dir = tempfile::tempdir().unwrap();
867 for (name, desc) in &[
868 ("search-a", "search code symbols"),
869 ("search-b", "search code symbols"),
870 ("email", "draft and send emails"),
871 ] {
872 let skill = dir.path().join(name);
873 fs::create_dir(&skill).unwrap();
874 fs::write(
875 skill.join("SKILL.md"),
876 format!("---\ndescription: {desc}\n---\n"),
877 )
878 .unwrap();
879 }
880 let result = scan_skills(dir.path()).unwrap();
881 assert_eq!(result.similar_pairs.len(), 1);
882 assert_eq!(result.similar_pairs[0].skill_a, "search-a");
883 assert_eq!(result.similar_pairs[0].skill_b, "search-b");
884 }
885
886 #[test]
889 fn extract_skill_names_from_tool_use() {
890 let json_line = r#"{"message":{"content":[{"type":"tool_use","name":"Skill","input":{"skill":"agent-doc","args":"plan.md"}}]}}"#;
891 let val: serde_json::Value = serde_json::from_str(json_line).unwrap();
892 let mut counts = HashMap::new();
893 extract_skill_names(&val, &mut counts);
894 assert_eq!(counts.get("agent-doc"), Some(&1));
895 }
896
897 #[test]
898 fn extract_skill_names_strips_plugin_prefix() {
899 let json_line = r#"{"message":{"content":[{"type":"tool_use","name":"Skill","input":{"skill":"codex:rescue","args":""}}]}}"#;
900 let val: serde_json::Value = serde_json::from_str(json_line).unwrap();
901 let mut counts = HashMap::new();
902 extract_skill_names(&val, &mut counts);
903 assert_eq!(counts.get("codex"), Some(&1));
904 assert_eq!(counts.get("codex:rescue"), None);
905 }
906
907 #[test]
908 fn extract_skill_names_ignores_non_skill_tools() {
909 let json_line = r#"{"message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}}"#;
910 let val: serde_json::Value = serde_json::from_str(json_line).unwrap();
911 let mut counts = HashMap::new();
912 extract_skill_names(&val, &mut counts);
913 assert!(counts.is_empty());
914 }
915
916 #[test]
917 fn extract_skill_names_multiple_in_one_message() {
918 let json_line = r#"{"message":{"content":[
919 {"type":"tool_use","name":"Skill","input":{"skill":"agent-doc","args":"a.md"}},
920 {"type":"tool_use","name":"Skill","input":{"skill":"tsift","args":"search foo"}}
921 ]}}"#;
922 let val: serde_json::Value = serde_json::from_str(json_line).unwrap();
923 let mut counts = HashMap::new();
924 extract_skill_names(&val, &mut counts);
925 assert_eq!(counts.get("agent-doc"), Some(&1));
926 assert_eq!(counts.get("tsift"), Some(&1));
927 }
928
929 #[test]
930 fn scan_jsonl_counts_skills() {
931 let dir = tempfile::tempdir().unwrap();
932 let jsonl = dir.path().join("session.jsonl");
933 let content = concat!(
934 r#"{"message":{"content":[{"type":"tool_use","name":"Skill","input":{"skill":"agent-doc","args":"a.md"}}]}}"#,
935 "\n",
936 r#"{"message":{"content":[{"type":"tool_use","name":"Skill","input":{"skill":"agent-doc","args":"b.md"}}]}}"#,
937 "\n",
938 r#"{"message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}}"#,
939 "\n",
940 );
941 fs::write(&jsonl, content).unwrap();
942 let mut counts = HashMap::new();
943 scan_jsonl(&jsonl, &mut counts).unwrap();
944 assert_eq!(counts.get("agent-doc"), Some(&2));
945 assert_eq!(counts.len(), 1);
946 }
947
948 #[test]
951 fn generate_cleanup_flags_broken_skills() {
952 let dir = tempfile::tempdir().unwrap();
953 fs::create_dir(dir.path().join("broken")).unwrap();
954 let skill = dir.path().join("ok");
955 fs::create_dir(&skill).unwrap();
956 fs::write(skill.join("SKILL.md"), "---\ndescription: fine\n---\n").unwrap();
957
958 let mut result = scan_skills(dir.path()).unwrap();
959 generate_cleanup(&mut result);
960 let cleanup = result.cleanup.unwrap();
961 assert!(cleanup.iter().any(|e| e.skill == "broken"));
962 assert!(!cleanup.iter().any(|e| e.skill == "ok"));
963 }
964
965 #[test]
966 fn generate_cleanup_flags_never_used() {
967 let mut result = AuditResult {
968 skills_dir: PathBuf::from("/tmp"),
969 total: 2,
970 healthy: 2,
971 broken: 0,
972 skills: vec![
973 {
974 let mut s = make_skill("used", "does things");
975 s.invocation_count = Some(5);
976 s
977 },
978 {
979 let mut s = make_skill("unused", "does other things");
980 s.invocation_count = Some(0);
981 s
982 },
983 ],
984 manifest_diffs: None,
985 similar_pairs: Vec::new(),
986 usage: None,
987 cleanup: None,
988 };
989 generate_cleanup(&mut result);
990 let cleanup = result.cleanup.unwrap();
991 assert!(
992 cleanup
993 .iter()
994 .any(|e| e.skill == "unused" && e.reasons.iter().any(|r| r.contains("never used")))
995 );
996 assert!(!cleanup.iter().any(|e| e.skill == "used"));
997 }
998
999 #[test]
1000 fn generate_cleanup_flags_high_similarity_duplicates() {
1001 let mut result = AuditResult {
1002 skills_dir: PathBuf::from("/tmp"),
1003 total: 2,
1004 healthy: 2,
1005 broken: 0,
1006 skills: vec![
1007 make_skill("search-a", "search code"),
1008 make_skill("search-b", "search code"),
1009 ],
1010 manifest_diffs: None,
1011 similar_pairs: vec![SimilarPair {
1012 skill_a: "search-a".to_string(),
1013 skill_b: "search-b".to_string(),
1014 score: 0.8,
1015 desc_a: "search code".to_string(),
1016 desc_b: "search code".to_string(),
1017 }],
1018 usage: None,
1019 cleanup: None,
1020 };
1021 generate_cleanup(&mut result);
1022 let cleanup = result.cleanup.unwrap();
1023 assert_eq!(cleanup.len(), 2);
1024 assert!(
1025 cleanup
1026 .iter()
1027 .all(|e| e.reasons.iter().any(|r| r.contains("similarity")))
1028 );
1029 }
1030
1031 #[test]
1032 fn generate_cleanup_sorted_by_token_estimate() {
1033 let dir = tempfile::tempdir().unwrap();
1034 let small = dir.path().join("small");
1035 fs::create_dir(&small).unwrap();
1036 fs::write(small.join("SKILL.md"), "x").unwrap();
1037
1038 let big = dir.path().join("big");
1039 fs::create_dir(&big).unwrap();
1040 fs::write(big.join("SKILL.md"), "x".repeat(10000)).unwrap();
1041
1042 let mut result = AuditResult {
1043 skills_dir: dir.path().to_path_buf(),
1044 total: 2,
1045 healthy: 0,
1046 broken: 2,
1047 skills: vec![
1048 SkillEntry {
1049 name: "small".to_string(),
1050 path: small,
1051 has_skill_md: true,
1052 is_symlink: false,
1053 description: None,
1054 issues: vec!["broken".to_string()],
1055 invocation_count: None,
1056 },
1057 SkillEntry {
1058 name: "big".to_string(),
1059 path: big,
1060 has_skill_md: true,
1061 is_symlink: false,
1062 description: None,
1063 issues: vec!["broken".to_string()],
1064 invocation_count: None,
1065 },
1066 ],
1067 manifest_diffs: None,
1068 similar_pairs: Vec::new(),
1069 usage: None,
1070 cleanup: None,
1071 };
1072 generate_cleanup(&mut result);
1073 let cleanup = result.cleanup.unwrap();
1074 assert_eq!(cleanup[0].skill, "big");
1075 assert!(cleanup[0].token_estimate > cleanup[1].token_estimate);
1076 }
1077
1078 #[test]
1081 fn write_report_creates_file() {
1082 let dir = tempfile::tempdir().unwrap();
1083 let report_path = dir.path().join("reports/audit.md");
1084 let result = AuditResult {
1085 skills_dir: PathBuf::from("/test/skills"),
1086 total: 2,
1087 healthy: 1,
1088 broken: 1,
1089 skills: vec![
1090 {
1091 let mut s = make_skill("good", "a good skill");
1092 s.invocation_count = Some(10);
1093 s
1094 },
1095 {
1096 let mut s = make_skill("bad", "a bad skill");
1097 s.issues = vec!["SKILL.md missing".to_string()];
1098 s.invocation_count = Some(0);
1099 s
1100 },
1101 ],
1102 manifest_diffs: None,
1103 similar_pairs: Vec::new(),
1104 usage: None,
1105 cleanup: Some(vec![CleanupEntry {
1106 skill: "bad".to_string(),
1107 reasons: vec!["health: SKILL.md missing".to_string()],
1108 token_estimate: 500,
1109 }]),
1110 };
1111 write_report(&result, &report_path).unwrap();
1112 let content = fs::read_to_string(&report_path).unwrap();
1113 assert!(content.contains("# Skill Audit Report"));
1114 assert!(content.contains("good"));
1115 assert!(content.contains("bad"));
1116 assert!(content.contains("Cleanup Recommendations"));
1117 assert!(content.contains("500 tokens"));
1118 }
1119
1120 #[test]
1121 fn format_tokens_units() {
1122 assert_eq!(format_tokens(500), "500 tokens");
1123 assert_eq!(format_tokens(1500), "1.5K tokens");
1124 assert_eq!(format_tokens(1_500_000), "1.5M tokens");
1125 }
1126}