Skip to main content

tsift_quality/
audit.rs

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    /// Jaccard similarity of description word sets (0.0–1.0)
54    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
171/// Compute Jaccard similarity between two description word sets.
172/// Returns pairs with score >= threshold, sorted descending by score.
173pub 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    // --- similar_pairs / duplicate detection ---
768
769    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        // All should have scores, sorted descending
834        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        // no-desc is skipped; only a/b pair counted
850        assert_eq!(pairs.len(), 1);
851    }
852
853    #[test]
854    fn similar_pairs_stop_words_excluded() {
855        // "the a and for to" are all stop words — empty token sets → no pair
856        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    // --- usage tracking ---
887
888    #[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    // --- cleanup recommendations ---
949
950    #[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    // --- report ---
1079
1080    #[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}