1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct VersionEntry {
15 pub content_hash: String,
16 pub seen_at: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SkillEntry {
22 pub skill_id: String,
24 pub name: String,
26 pub description: String,
28 pub project_id: String,
30 pub project_name: String,
32 pub relative_path: String,
34 pub content_hash: String,
36 pub registered_at: String,
38 pub last_seen: String,
40 pub version_history: Vec<VersionEntry>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, Default)]
46pub struct SkillRegistry {
47 pub skills: BTreeMap<String, SkillEntry>,
48}
49
50#[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
59pub fn skill_registry_path() -> PathBuf {
65 store_root().join("skill_registry.json")
66}
67
68fn skill_registry_lock_path() -> PathBuf {
70 store_root().join("skill_registry.lock")
71}
72
73fn 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
86fn 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
92fn 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
103pub fn skill_id(name: &str, project_name: &str) -> String {
105 format!("{name}:{project_name}")
106}
107
108pub 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 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
143pub 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
195pub 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 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 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(®)
250}
251
252pub fn list_skills() -> Vec<SkillEntry> {
254 let reg = load_skill_registry();
255 reg.skills.into_values().collect()
256}
257
258pub 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
267pub fn get_skill(sid: &str) -> Option<SkillEntry> {
269 let reg = load_skill_registry();
270 reg.skills.get(sid).cloned()
271}
272
273pub 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
301pub 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#[cfg(test)]
318mod tests {
319 use super::*;
320
321 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 let content = "---\ndescription: only desc\n---\n";
445 assert!(parse_skill_frontmatter(content).is_none());
446
447 assert!(parse_skill_frontmatter("# Just markdown").is_none());
449
450 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 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 let not_a_skill = skills_dir.join("not-a-skill");
479 std::fs::create_dir_all(¬_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}