Skip to main content

edda_store/
skill_registry.rs

1//! Skill registry: `~/.edda/skill_registry.json`
2//!
3//! Tracks skills across projects with content-hash versioning.
4//! Follows the same pattern as `registry.rs` (project registry).
5
6use crate::{lock_file, store_root, write_atomic};
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10use time::OffsetDateTime;
11
12/// A version history entry — records when a content hash was first seen.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct VersionEntry {
15    pub content_hash: String,
16    pub seen_at: String,
17}
18
19/// A registered skill entry.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SkillEntry {
22    /// Composite key: `{name}:{project_name}`
23    pub skill_id: String,
24    /// Skill name (from frontmatter or directory name)
25    pub name: String,
26    /// Skill description (from frontmatter)
27    pub description: String,
28    /// BLAKE3 hash of the project's repo path (first 32 hex chars)
29    pub project_id: String,
30    /// Human-readable project name (last path component)
31    pub project_name: String,
32    /// Relative path within the project (e.g. `.claude/skills/issue-plan/SKILL.md`)
33    pub relative_path: String,
34    /// BLAKE3 hash of SKILL.md content (hex string)
35    pub content_hash: String,
36    /// RFC 3339 timestamp when first registered
37    pub registered_at: String,
38    /// RFC 3339 timestamp of last scan/touch
39    pub last_seen: String,
40    /// History of content hash changes
41    pub version_history: Vec<VersionEntry>,
42}
43
44/// The full skill registry: a map of skill_id -> SkillEntry.
45#[derive(Debug, Clone, Serialize, Deserialize, Default)]
46pub struct SkillRegistry {
47    pub skills: BTreeMap<String, SkillEntry>,
48}
49
50/// A skill discovered by scanning a project directory.
51#[derive(Debug, Clone)]
52pub struct ScannedSkill {
53    pub name: String,
54    pub description: String,
55    pub relative_path: String,
56    pub content_hash: String,
57}
58
59// ---------------------------------------------------------------------------
60// Paths
61// ---------------------------------------------------------------------------
62
63/// Path to the skill registry JSON file.
64pub fn skill_registry_path() -> PathBuf {
65    store_root().join("skill_registry.json")
66}
67
68/// Path to the skill registry lock file.
69fn skill_registry_lock_path() -> PathBuf {
70    store_root().join("skill_registry.lock")
71}
72
73// ---------------------------------------------------------------------------
74// Load / Save
75// ---------------------------------------------------------------------------
76
77/// Load the skill registry from disk. Returns empty registry if file doesn't exist.
78fn load_skill_registry() -> SkillRegistry {
79    let path = skill_registry_path();
80    match std::fs::read_to_string(&path) {
81        Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
82        Err(_) => SkillRegistry::default(),
83    }
84}
85
86/// Save the skill registry to disk atomically.
87fn save_skill_registry(reg: &SkillRegistry) -> anyhow::Result<()> {
88    let json = serde_json::to_string_pretty(reg)?;
89    write_atomic(&skill_registry_path(), json.as_bytes())
90}
91
92// ---------------------------------------------------------------------------
93// Helpers
94// ---------------------------------------------------------------------------
95
96/// Get current timestamp as RFC 3339 string.
97fn now_rfc3339() -> String {
98    let now = OffsetDateTime::now_utc();
99    now.format(&time::format_description::well_known::Rfc3339)
100        .unwrap_or_else(|_| "unknown".to_string())
101}
102
103/// Build a composite skill ID: `{name}:{project_name}`.
104pub fn skill_id(name: &str, project_name: &str) -> String {
105    format!("{name}:{project_name}")
106}
107
108// ---------------------------------------------------------------------------
109// Frontmatter parsing
110// ---------------------------------------------------------------------------
111
112/// Parse YAML frontmatter from a SKILL.md file.
113///
114/// Expects the file to start with `---`, followed by YAML, followed by `---`.
115/// Returns `(name, description)` if both fields are found.
116pub fn parse_skill_frontmatter(content: &str) -> Option<(String, String)> {
117    let trimmed = content.trim_start();
118    if !trimmed.starts_with("---") {
119        return None;
120    }
121    // Find the closing ---
122    let after_first = &trimmed[3..];
123    let end = after_first.find("---")?;
124    let yaml_block = &after_first[..end];
125
126    let mut name = None;
127    let mut description = None;
128
129    for line in yaml_block.lines() {
130        let line = line.trim();
131        if let Some(rest) = line.strip_prefix("name:") {
132            name = Some(rest.trim().trim_matches('"').trim_matches('\'').to_string());
133        } else if let Some(rest) = line.strip_prefix("description:") {
134            description = Some(rest.trim().trim_matches('"').trim_matches('\'').to_string());
135        }
136    }
137
138    let name = name.filter(|n| !n.is_empty())?;
139    let description = description.unwrap_or_default();
140    Some((name, description))
141}
142
143// ---------------------------------------------------------------------------
144// Scanning
145// ---------------------------------------------------------------------------
146
147/// Scan a project directory for `.claude/skills/*/SKILL.md` files.
148///
149/// Returns a list of discovered skills with parsed frontmatter and content hashes.
150pub fn scan_project_skills(repo_root: &Path) -> Vec<ScannedSkill> {
151    let skills_dir = repo_root.join(".claude").join("skills");
152    let mut results = Vec::new();
153
154    let entries = match std::fs::read_dir(&skills_dir) {
155        Ok(e) => e,
156        Err(_) => return results,
157    };
158
159    for entry in entries.flatten() {
160        let path = entry.path();
161        if !path.is_dir() {
162            continue;
163        }
164        let skill_md = path.join("SKILL.md");
165        if !skill_md.is_file() {
166            continue;
167        }
168
169        let content = match std::fs::read_to_string(&skill_md) {
170            Ok(c) => c,
171            Err(_) => continue,
172        };
173
174        let content_hash = blake3::hash(content.as_bytes()).to_hex().to_string();
175
176        let dir_name = entry.file_name().to_string_lossy().to_string();
177
178        let (name, description) =
179            parse_skill_frontmatter(&content).unwrap_or_else(|| (dir_name.clone(), String::new()));
180
181        let relative_path = format!(".claude/skills/{}/SKILL.md", dir_name);
182
183        results.push(ScannedSkill {
184            name,
185            description,
186            relative_path,
187            content_hash,
188        });
189    }
190
191    results.sort_by(|a, b| a.name.cmp(&b.name));
192    results
193}
194
195// ---------------------------------------------------------------------------
196// CRUD operations
197// ---------------------------------------------------------------------------
198
199/// Register a skill in the user-level skill registry. Idempotent.
200///
201/// If the skill already exists with a different content_hash, the old hash is
202/// appended to `version_history` and the new hash replaces `content_hash`.
203/// If the hash is unchanged, only `last_seen` is updated.
204pub fn register_skill(
205    project_id: &str,
206    project_name: &str,
207    name: &str,
208    description: &str,
209    relative_path: &str,
210    content_hash: &str,
211) -> anyhow::Result<()> {
212    let _lock = lock_file(&skill_registry_lock_path())?;
213    let mut reg = load_skill_registry();
214    let sid = skill_id(name, project_name);
215    let now = now_rfc3339();
216
217    if let Some(entry) = reg.skills.get_mut(&sid) {
218        // Update mutable fields
219        entry.description = description.to_string();
220        entry.relative_path = relative_path.to_string();
221        entry.last_seen = now.clone();
222
223        if entry.content_hash != content_hash {
224            // Content changed — record old version and update hash
225            entry.version_history.push(VersionEntry {
226                content_hash: entry.content_hash.clone(),
227                seen_at: now,
228            });
229            entry.content_hash = content_hash.to_string();
230        }
231    } else {
232        reg.skills.insert(
233            sid.clone(),
234            SkillEntry {
235                skill_id: sid,
236                name: name.to_string(),
237                description: description.to_string(),
238                project_id: project_id.to_string(),
239                project_name: project_name.to_string(),
240                relative_path: relative_path.to_string(),
241                content_hash: content_hash.to_string(),
242                registered_at: now.clone(),
243                last_seen: now,
244                version_history: Vec::new(),
245            },
246        );
247    }
248
249    save_skill_registry(&reg)
250}
251
252/// List all registered skills.
253pub fn list_skills() -> Vec<SkillEntry> {
254    let reg = load_skill_registry();
255    reg.skills.into_values().collect()
256}
257
258/// List skills for a specific project_id.
259pub fn list_skills_by_project(pid: &str) -> Vec<SkillEntry> {
260    let reg = load_skill_registry();
261    reg.skills
262        .into_values()
263        .filter(|e| e.project_id == pid)
264        .collect()
265}
266
267/// Get a specific skill by skill_id.
268pub fn get_skill(sid: &str) -> Option<SkillEntry> {
269    let reg = load_skill_registry();
270    reg.skills.get(sid).cloned()
271}
272
273/// Register a pre-scanned list of skills for a project.
274///
275/// This avoids re-scanning the filesystem when the caller already has the
276/// scanned results (e.g. `execute_scan` in the CLI).
277/// Returns the number of skills registered or updated.
278pub fn register_scanned_skills(repo_root: &Path, skills: &[ScannedSkill]) -> anyhow::Result<usize> {
279    let pid = crate::project_id(repo_root);
280    let pname = repo_root
281        .file_name()
282        .map(|n| n.to_string_lossy().to_string())
283        .unwrap_or_else(|| "unknown".to_string());
284
285    let count = skills.len();
286
287    for skill in skills {
288        register_skill(
289            &pid,
290            &pname,
291            &skill.name,
292            &skill.description,
293            &skill.relative_path,
294            &skill.content_hash,
295        )?;
296    }
297
298    Ok(count)
299}
300
301/// Scan a project and register/update all discovered skills.
302///
303/// Convenience wrapper that calls `scan_project_skills` then
304/// `register_scanned_skills`. Prefer `register_scanned_skills` when the
305/// caller already holds the scan results to avoid a redundant filesystem walk.
306///
307/// Returns the number of skills registered or updated.
308pub fn scan_and_register(repo_root: &Path) -> anyhow::Result<usize> {
309    let skills = scan_project_skills(repo_root);
310    register_scanned_skills(repo_root, &skills)
311}
312
313// ---------------------------------------------------------------------------
314// Tests
315// ---------------------------------------------------------------------------
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    /// Run a closure with `EDDA_STORE_ROOT` pointing to an isolated tempdir.
322    fn with_isolated_store(f: impl FnOnce()) {
323        let _guard = crate::ENV_STORE_LOCK.lock().unwrap();
324        let store = tempfile::tempdir().unwrap();
325        std::env::set_var("EDDA_STORE_ROOT", store.path());
326        f();
327        std::env::remove_var("EDDA_STORE_ROOT");
328    }
329
330    #[test]
331    fn register_and_list_roundtrip() {
332        with_isolated_store(|| {
333            register_skill(
334                "proj1",
335                "my-project",
336                "issue-plan",
337                "Plan issues",
338                ".claude/skills/issue-plan/SKILL.md",
339                "abc123",
340            )
341            .unwrap();
342
343            let skills = list_skills();
344            assert_eq!(skills.len(), 1);
345            assert_eq!(skills[0].name, "issue-plan");
346            assert_eq!(skills[0].project_name, "my-project");
347            assert_eq!(skills[0].content_hash, "abc123");
348        });
349    }
350
351    #[test]
352    fn register_is_idempotent() {
353        with_isolated_store(|| {
354            register_skill(
355                "proj1",
356                "my-project",
357                "issue-plan",
358                "Plan issues",
359                ".claude/skills/issue-plan/SKILL.md",
360                "abc123",
361            )
362            .unwrap();
363            register_skill(
364                "proj1",
365                "my-project",
366                "issue-plan",
367                "Plan issues",
368                ".claude/skills/issue-plan/SKILL.md",
369                "abc123",
370            )
371            .unwrap();
372
373            let skills = list_skills();
374            assert_eq!(skills.len(), 1, "should not create duplicates");
375            assert!(skills[0].version_history.is_empty(), "no version change");
376        });
377    }
378
379    #[test]
380    fn content_hash_change_appends_version() {
381        with_isolated_store(|| {
382            register_skill(
383                "proj1",
384                "my-project",
385                "issue-plan",
386                "v1",
387                ".claude/skills/issue-plan/SKILL.md",
388                "hash_v1",
389            )
390            .unwrap();
391            register_skill(
392                "proj1",
393                "my-project",
394                "issue-plan",
395                "v2",
396                ".claude/skills/issue-plan/SKILL.md",
397                "hash_v2",
398            )
399            .unwrap();
400
401            let entry = get_skill("issue-plan:my-project").unwrap();
402            assert_eq!(entry.content_hash, "hash_v2");
403            assert_eq!(entry.version_history.len(), 1);
404            assert_eq!(entry.version_history[0].content_hash, "hash_v1");
405        });
406    }
407
408    #[test]
409    fn list_by_project_filters() {
410        with_isolated_store(|| {
411            register_skill("proj1", "project-a", "skill-1", "desc", "path1", "h1").unwrap();
412            register_skill("proj2", "project-b", "skill-2", "desc", "path2", "h2").unwrap();
413
414            let proj1_skills = list_skills_by_project("proj1");
415            assert_eq!(proj1_skills.len(), 1);
416            assert_eq!(proj1_skills[0].name, "skill-1");
417
418            let proj2_skills = list_skills_by_project("proj2");
419            assert_eq!(proj2_skills.len(), 1);
420            assert_eq!(proj2_skills[0].name, "skill-2");
421        });
422    }
423
424    #[test]
425    fn parse_frontmatter_basic() {
426        let content =
427            "---\nname: issue-plan\ndescription: Plan GitHub issues\n---\n# Skill\nBody here.";
428        let (name, desc) = parse_skill_frontmatter(content).unwrap();
429        assert_eq!(name, "issue-plan");
430        assert_eq!(desc, "Plan GitHub issues");
431    }
432
433    #[test]
434    fn parse_frontmatter_quoted() {
435        let content = "---\nname: \"my-skill\"\ndescription: 'A quoted description'\n---\n";
436        let (name, desc) = parse_skill_frontmatter(content).unwrap();
437        assert_eq!(name, "my-skill");
438        assert_eq!(desc, "A quoted description");
439    }
440
441    #[test]
442    fn parse_frontmatter_missing_fields() {
443        // No name field -> should return None
444        let content = "---\ndescription: only desc\n---\n";
445        assert!(parse_skill_frontmatter(content).is_none());
446
447        // No frontmatter at all
448        assert!(parse_skill_frontmatter("# Just markdown").is_none());
449
450        // Empty name
451        let content = "---\nname:\ndescription: desc\n---\n";
452        assert!(parse_skill_frontmatter(content).is_none());
453    }
454
455    #[test]
456    fn scan_project_skills_discovers_files() {
457        let tmp = tempfile::tempdir().unwrap();
458        let skills_dir = tmp.path().join(".claude").join("skills");
459
460        // Create two skill directories
461        let skill_a = skills_dir.join("skill-a");
462        std::fs::create_dir_all(&skill_a).unwrap();
463        std::fs::write(
464            skill_a.join("SKILL.md"),
465            "---\nname: skill-a\ndescription: Skill A\n---\n# Skill A",
466        )
467        .unwrap();
468
469        let skill_b = skills_dir.join("skill-b");
470        std::fs::create_dir_all(&skill_b).unwrap();
471        std::fs::write(
472            skill_b.join("SKILL.md"),
473            "---\nname: skill-b\ndescription: Skill B\n---\n# Skill B",
474        )
475        .unwrap();
476
477        // Create a non-skill directory (no SKILL.md)
478        let not_a_skill = skills_dir.join("not-a-skill");
479        std::fs::create_dir_all(&not_a_skill).unwrap();
480        std::fs::write(not_a_skill.join("README.md"), "not a skill").unwrap();
481
482        let scanned = scan_project_skills(tmp.path());
483        assert_eq!(scanned.len(), 2);
484        assert_eq!(scanned[0].name, "skill-a");
485        assert_eq!(scanned[1].name, "skill-b");
486        assert!(!scanned[0].content_hash.is_empty());
487    }
488
489    #[test]
490    fn scan_and_register_roundtrip() {
491        with_isolated_store(|| {
492            let tmp = tempfile::tempdir().unwrap();
493            let skills_dir = tmp.path().join(".claude").join("skills").join("test-skill");
494            std::fs::create_dir_all(&skills_dir).unwrap();
495            std::fs::write(
496                skills_dir.join("SKILL.md"),
497                "---\nname: test-skill\ndescription: A test\n---\nBody",
498            )
499            .unwrap();
500
501            let count = scan_and_register(tmp.path()).unwrap();
502            assert_eq!(count, 1);
503
504            let skills = list_skills();
505            assert_eq!(skills.len(), 1);
506            assert_eq!(skills[0].name, "test-skill");
507            assert_eq!(skills[0].description, "A test");
508        });
509    }
510}