Skip to main content

atman_runtime/
migration.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6pub struct MigratedRule {
7    pub name: String,
8    pub source_tool: String,
9    pub source_path: PathBuf,
10    pub scope: RuleScope,
11    pub content: String,
12    pub description: Option<String>,
13}
14
15#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum RuleScope {
18    Project,
19    Global,
20}
21
22impl RuleScope {
23    pub fn as_str(&self) -> &'static str {
24        match self {
25            RuleScope::Project => "project",
26            RuleScope::Global => "global",
27        }
28    }
29}
30
31const MAX_RULE_BYTES: usize = 100_000;
32
33pub fn scan_migrated_rules(project_root: &Path, home: &Path) -> Vec<MigratedRule> {
34    let mut out = Vec::new();
35
36    for (rel, tool) in &[
37        ("CLAUDE.md", "claude"),
38        ("AGENTS.md", "opencode"),
39        (".cursorrules", "cursor"),
40        ("CONVENTIONS.md", "aider"),
41    ] {
42        let path = project_root.join(rel);
43        if let Some(rule) = load_file(&path, tool, RuleScope::Project) {
44            out.push(rule);
45        }
46    }
47
48    push_dir(
49        &mut out,
50        &project_root.join(".cursor/rules"),
51        "cursor",
52        "md",
53    );
54    push_dir(&mut out, &project_root.join(".kiro/steering"), "kiro", "md");
55
56    for (rel, tool) in &[
57        (".claude/CLAUDE.md", "claude"),
58        (".config/opencode/AGENTS.md", "opencode"),
59    ] {
60        let path = home.join(rel);
61        if let Some(rule) = load_file(&path, tool, RuleScope::Global) {
62            out.push(rule);
63        }
64    }
65
66    scan_aider_yaml(project_root, &mut out);
67    scan_skill_references(project_root, home, &mut out);
68
69    out
70}
71
72fn scan_aider_yaml(project_root: &Path, out: &mut Vec<MigratedRule>) {
73    let yml = project_root.join(".aider.conf.yml");
74    let Ok(raw) = std::fs::read_to_string(&yml) else {
75        return;
76    };
77    for path in parse_aider_conventions(&raw) {
78        let full = if path.is_absolute() {
79            path
80        } else {
81            project_root.join(path)
82        };
83        if let Some(rule) = load_file(&full, "aider", RuleScope::Project) {
84            out.push(rule);
85        }
86    }
87}
88
89fn parse_aider_conventions(yaml: &str) -> Vec<PathBuf> {
90    let mut out = Vec::new();
91    let mut inside = false;
92    for line in yaml.lines() {
93        let stripped = strip_yaml_comment(line);
94        if let Some(rest) = stripped.strip_prefix("conventions:") {
95            let rest = rest.trim();
96            inside = true;
97            if let Some(list) = rest.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
98                for item in list.split(',') {
99                    let value = item.trim().trim_matches(|c| c == '"' || c == '\'');
100                    if !value.is_empty() {
101                        out.push(PathBuf::from(value));
102                    }
103                }
104                inside = false;
105            }
106            continue;
107        }
108        if !inside {
109            continue;
110        }
111        let indent = stripped.len() - stripped.trim_start().len();
112        if indent == 0 && !stripped.trim().is_empty() {
113            inside = false;
114            continue;
115        }
116        let trimmed = stripped.trim();
117        if let Some(item) = trimmed.strip_prefix('-') {
118            let value = item.trim().trim_matches(|c| c == '"' || c == '\'');
119            if !value.is_empty() {
120                out.push(PathBuf::from(value));
121            }
122        }
123    }
124    out
125}
126
127fn strip_yaml_comment(line: &str) -> &str {
128    if let Some((code, _)) = line.split_once(" #") {
129        code
130    } else if let Some(rest) = line.strip_prefix('#') {
131        &rest[..0]
132    } else {
133        line
134    }
135}
136
137fn scan_skill_references(project_root: &Path, home: &Path, out: &mut Vec<MigratedRule>) {
138    let mut seen = std::collections::HashSet::new();
139    for rel in [
140        ".agents/skills",
141        ".codex/skills",
142        ".claude/skills",
143        ".github/skills",
144        ".cursor/skills",
145        ".kiro/skills",
146        ".vscode/skills",
147    ] {
148        scan_skill_root(&project_root.join(rel), RuleScope::Project, out, &mut seen);
149    }
150    scan_skill_root(
151        &home.join(".claude/skills"),
152        RuleScope::Global,
153        out,
154        &mut seen,
155    );
156}
157
158fn scan_skill_root(
159    skills_root: &Path,
160    scope: RuleScope,
161    out: &mut Vec<MigratedRule>,
162    seen: &mut std::collections::HashSet<String>,
163) {
164    let Ok(canonical_root) = skills_root.canonicalize() else {
165        return;
166    };
167    let Ok(entries) = std::fs::read_dir(skills_root) else {
168        return;
169    };
170    let mut entries = entries.flatten().collect::<Vec<_>>();
171    entries.sort_by_key(|entry| entry.file_name());
172    for entry in entries {
173        let skill_dir = entry.path();
174        let Ok(canonical_dir) = skill_dir.canonicalize() else {
175            continue;
176        };
177        if !canonical_dir.starts_with(&canonical_root) || !skill_dir.is_dir() {
178            continue;
179        }
180        let skill_md = skill_dir.join("SKILL.md");
181        if !skill_md
182            .canonicalize()
183            .is_ok_and(|path| path.starts_with(&canonical_dir))
184        {
185            continue;
186        }
187        let Ok(body) = std::fs::read_to_string(&skill_md) else {
188            continue;
189        };
190        let front_matter = parse_front_matter(&body);
191        let skill_name = front_matter
192            .as_ref()
193            .and_then(|fm| fm.get("name"))
194            .map(|s| s.as_str())
195            .filter(|s| !s.is_empty())
196            .map(str::to_string)
197            .or_else(|| {
198                skill_dir
199                    .file_name()
200                    .and_then(|s| s.to_str())
201                    .map(str::to_string)
202            })
203            .unwrap_or_else(|| "unnamed".to_string());
204        if !seen.insert(skill_name.clone()) {
205            continue;
206        }
207        let skill_description = front_matter
208            .as_ref()
209            .and_then(|fm| fm.get("description"))
210            .map(|s| s.trim().to_string())
211            .filter(|s| !s.is_empty());
212
213        let skill_content = strip_front_matter(&body).to_string();
214        out.push(MigratedRule {
215            name: skill_name.clone(),
216            source_tool: "skill".into(),
217            source_path: skill_md.clone(),
218            scope,
219            content: skill_content,
220            description: skill_description.clone(),
221        });
222
223        for rel in parse_markdown_local_links(&body) {
224            let full = skill_dir.join(&rel);
225            if !full
226                .canonicalize()
227                .is_ok_and(|path| path.starts_with(&canonical_dir))
228            {
229                continue;
230            }
231            if let Some(mut rule) = load_file(&full, "skill", scope) {
232                rule.name = format!("skill:{skill_name}::{}", rel.display());
233                if let Some(desc) = &skill_description {
234                    rule.description = Some(desc.clone());
235                }
236                out.push(rule);
237            }
238        }
239    }
240}
241
242fn parse_markdown_local_links(body: &str) -> Vec<PathBuf> {
243    let mut out = Vec::new();
244    let mut cursor = body;
245    while let Some(open) = cursor.find("](") {
246        let after = &cursor[open + 2..];
247        let Some(close) = after.find(')') else {
248            break;
249        };
250        let target = &after[..close];
251        cursor = &after[close + 1..];
252        if target.starts_with("http")
253            || target.starts_with('/')
254            || target.starts_with('#')
255            || target.contains("://")
256        {
257            continue;
258        }
259        if !target.ends_with(".md") {
260            continue;
261        }
262        if target.starts_with("references/") || target.starts_with("templates/") {
263            out.push(PathBuf::from(target));
264        }
265    }
266    out
267}
268
269fn push_dir(out: &mut Vec<MigratedRule>, dir: &Path, tool: &str, ext: &str) {
270    let Ok(entries) = std::fs::read_dir(dir) else {
271        return;
272    };
273    for entry in entries.flatten() {
274        let path = entry.path();
275        if path
276            .extension()
277            .and_then(|s| s.to_str())
278            .map(|s| s == ext)
279            .unwrap_or(false)
280            && let Some(rule) = load_file(&path, tool, RuleScope::Project)
281        {
282            out.push(rule);
283        }
284    }
285}
286
287fn load_file(path: &Path, tool: &str, scope: RuleScope) -> Option<MigratedRule> {
288    let raw = std::fs::read_to_string(path).ok()?;
289    let content = if raw.len() > MAX_RULE_BYTES {
290        let mut truncated = raw[..MAX_RULE_BYTES].to_string();
291        truncated.push_str(&format!(
292            "\n\n[atman: truncated at {MAX_RULE_BYTES} bytes; full at {}]",
293            path.display()
294        ));
295        truncated
296    } else {
297        raw
298    };
299    let name = extract_rule_name(&content).unwrap_or_else(|| basename(path));
300    let description = extract_rule_description(&content);
301    Some(MigratedRule {
302        name,
303        source_tool: tool.into(),
304        source_path: path.to_path_buf(),
305        scope,
306        content,
307        description,
308    })
309}
310
311fn extract_rule_name(content: &str) -> Option<String> {
312    for line in content.lines() {
313        let trimmed = line.trim();
314        if trimmed.is_empty() {
315            continue;
316        }
317        if let Some(rest) = trimmed.strip_prefix("# ") {
318            return Some(rest.trim().to_string());
319        }
320        break;
321    }
322    None
323}
324
325/// Extract a human-readable description for a rule.
326/// Priority: YAML front matter `description` key → first non-empty paragraph after
327/// the front matter / leading heading.
328fn extract_rule_description(content: &str) -> Option<String> {
329    if let Some(fm) = parse_front_matter(content) {
330        if let Some(desc) = fm.get("description") {
331            let desc = desc.trim();
332            if !desc.is_empty() {
333                return Some(desc.to_string());
334            }
335        }
336    }
337    first_paragraph(content)
338}
339
340/// Parse `---\nkey: value\n---` YAML front matter. Returns None if absent.
341/// Handles both the simple `key: value` form and (for SKILL.md) the same layout.
342fn parse_front_matter(content: &str) -> Option<std::collections::HashMap<String, String>> {
343    let rest = content.strip_prefix("---")?;
344    let end = rest.find("\n---")?;
345    let block = &rest[..end];
346    let mut map = std::collections::HashMap::new();
347    for line in block.lines() {
348        let trimmed = line.trim();
349        if trimmed.is_empty() || trimmed.starts_with('#') {
350            continue;
351        }
352        let Some((key, value)) = trimmed.split_once(':') else {
353            continue;
354        };
355        map.insert(
356            key.trim().to_string(),
357            value
358                .trim()
359                .trim_matches(|c| c == '"' || c == '\'')
360                .to_string(),
361        );
362    }
363    Some(map)
364}
365
366/// Remove the leading YAML front matter from a skill body.
367fn strip_front_matter(content: &str) -> &str {
368    let Some(rest) = content.strip_prefix("---") else {
369        return content;
370    };
371    let Some(end) = rest.find("\n---") else {
372        return content;
373    };
374    rest[end + 4..].trim_start_matches('\n')
375}
376
377/// First non-empty paragraph (skipping the leading `# ` heading if present) used as
378/// a fallback description when no front matter description exists.
379fn first_paragraph(content: &str) -> Option<String> {
380    let mut para = String::new();
381    let mut in_para = false;
382
383    for line in content.lines() {
384        let trimmed = line.trim();
385        if trimmed.is_empty() {
386            if in_para {
387                break;
388            }
389            continue;
390        }
391        if !in_para && trimmed.starts_with("# ") {
392            // Skip the leading heading (if any), then treat the next non-empty
393            // non-heading line as the start of the description paragraph.
394            continue;
395        }
396        if !in_para {
397            in_para = true;
398            para = trimmed.to_string();
399        } else if trimmed.starts_with("# ") {
400            break;
401        } else {
402            para.push(' ');
403            para.push_str(trimmed);
404        }
405    }
406    if para.is_empty() { None } else { Some(para) }
407}
408
409fn basename(path: &Path) -> String {
410    path.file_stem()
411        .and_then(|s| s.to_str())
412        .unwrap_or("unnamed")
413        .to_string()
414}
415
416pub fn resolve_by_name<'a>(rules: &'a [MigratedRule], query: &str) -> Option<&'a MigratedRule> {
417    if let Some((name, tool)) = query.split_once('@') {
418        return rules
419            .iter()
420            .find(|r| r.name == name && r.source_tool == tool);
421    }
422    let matches: Vec<&MigratedRule> = rules.iter().filter(|r| r.name == query).collect();
423    if matches.is_empty() {
424        return None;
425    }
426    if let Some(project) = matches
427        .iter()
428        .find(|r| matches!(r.scope, RuleScope::Project))
429    {
430        return Some(*project);
431    }
432    matches.first().copied()
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    fn write(dir: &Path, rel: &str, content: &str) {
440        let path = dir.join(rel);
441        if let Some(parent) = path.parent() {
442            std::fs::create_dir_all(parent).unwrap();
443        }
444        std::fs::write(path, content).unwrap();
445    }
446
447    #[test]
448    fn detects_claude_md_in_project_root() {
449        let dir = tempfile::tempdir().unwrap();
450        let home = tempfile::tempdir().unwrap();
451        write(dir.path(), "CLAUDE.md", "# atman rules\n\nBe terse.\n");
452        let rules = scan_migrated_rules(dir.path(), home.path());
453        let claude = rules
454            .iter()
455            .find(|r| r.source_tool == "claude")
456            .expect("expected CLAUDE.md rule");
457        assert_eq!(claude.name, "atman rules");
458        assert!(matches!(claude.scope, RuleScope::Project));
459    }
460
461    #[test]
462    fn detects_agents_md_and_cursorrules_and_conventions() {
463        let dir = tempfile::tempdir().unwrap();
464        let home = tempfile::tempdir().unwrap();
465        write(dir.path(), "AGENTS.md", "# opencode-rules\ncontent");
466        write(dir.path(), ".cursorrules", "# cursor-flat\nuse rust");
467        write(dir.path(), "CONVENTIONS.md", "# aider-conv\nx");
468        let rules = scan_migrated_rules(dir.path(), home.path());
469        let tools: Vec<&str> = rules.iter().map(|r| r.source_tool.as_str()).collect();
470        assert!(tools.contains(&"opencode"));
471        assert!(tools.contains(&"cursor"));
472        assert!(tools.contains(&"aider"));
473    }
474
475    #[test]
476    fn detects_cursor_rules_directory() {
477        let dir = tempfile::tempdir().unwrap();
478        let home = tempfile::tempdir().unwrap();
479        write(dir.path(), ".cursor/rules/rust.md", "# rust\nuse borrow");
480        write(dir.path(), ".cursor/rules/style.md", "# style\nno emoji");
481        let rules = scan_migrated_rules(dir.path(), home.path());
482        let cursor_rules: Vec<&str> = rules
483            .iter()
484            .filter(|r| r.source_tool == "cursor")
485            .map(|r| r.name.as_str())
486            .collect();
487        assert!(cursor_rules.contains(&"rust"));
488        assert!(cursor_rules.contains(&"style"));
489    }
490
491    #[test]
492    fn detects_kiro_steering_directory() {
493        let dir = tempfile::tempdir().unwrap();
494        let home = tempfile::tempdir().unwrap();
495        write(dir.path(), ".kiro/steering/api.md", "# api-guide\ncontent");
496        let rules = scan_migrated_rules(dir.path(), home.path());
497        let found = rules.iter().find(|r| r.source_tool == "kiro").unwrap();
498        assert_eq!(found.name, "api-guide");
499    }
500
501    #[test]
502    fn detects_user_scope_files_from_home() {
503        let dir = tempfile::tempdir().unwrap();
504        let home = tempfile::tempdir().unwrap();
505        write(home.path(), ".claude/CLAUDE.md", "# global-claude\nx");
506        write(
507            home.path(),
508            ".config/opencode/AGENTS.md",
509            "# global-opencode\ny",
510        );
511        let rules = scan_migrated_rules(dir.path(), home.path());
512        let global_names: Vec<&str> = rules
513            .iter()
514            .filter(|r| matches!(r.scope, RuleScope::Global))
515            .map(|r| r.name.as_str())
516            .collect();
517        assert!(global_names.contains(&"global-claude"));
518        assert!(global_names.contains(&"global-opencode"));
519    }
520
521    #[test]
522    fn extract_name_from_first_heading_or_basename() {
523        let dir = tempfile::tempdir().unwrap();
524        let home = tempfile::tempdir().unwrap();
525        write(dir.path(), "CLAUDE.md", "no heading here\nblah");
526        let rules = scan_migrated_rules(dir.path(), home.path());
527        let claude = rules.iter().find(|r| r.source_tool == "claude").unwrap();
528        assert_eq!(claude.name, "CLAUDE", "basename fallback");
529    }
530
531    #[test]
532    fn truncates_oversized_rule() {
533        let dir = tempfile::tempdir().unwrap();
534        let home = tempfile::tempdir().unwrap();
535        let big = "# huge\n".to_string() + &"x".repeat(MAX_RULE_BYTES + 1000);
536        write(dir.path(), "CLAUDE.md", &big);
537        let rules = scan_migrated_rules(dir.path(), home.path());
538        let claude = rules.iter().find(|r| r.source_tool == "claude").unwrap();
539        assert!(claude.content.contains("[atman: truncated"));
540        assert!(claude.content.len() < MAX_RULE_BYTES + 200);
541    }
542
543    #[test]
544    fn resolve_by_name_prefers_project_over_global() {
545        let rules = vec![
546            MigratedRule {
547                name: "code-review".into(),
548                source_tool: "opencode".into(),
549                source_path: "/user".into(),
550                scope: RuleScope::Global,
551                content: "global-version".into(),
552                description: None,
553            },
554            MigratedRule {
555                name: "code-review".into(),
556                source_tool: "claude".into(),
557                source_path: "/proj".into(),
558                scope: RuleScope::Project,
559                content: "project-version".into(),
560                description: None,
561            },
562        ];
563        let r = resolve_by_name(&rules, "code-review").unwrap();
564        assert!(matches!(r.scope, RuleScope::Project));
565        assert_eq!(r.content, "project-version");
566    }
567
568    #[test]
569    fn aider_conf_yml_block_list_loads_convention_markdown() {
570        let dir = tempfile::tempdir().unwrap();
571        let home = tempfile::tempdir().unwrap();
572        write(
573            dir.path(),
574            ".aider.conf.yml",
575            "model: claude-sonnet-4\nconventions:\n  - docs/style.md\n  - \"docs/security.md\"\nedit-format: diff\n",
576        );
577        write(dir.path(), "docs/style.md", "# aider-style\nuse rustfmt\n");
578        write(dir.path(), "docs/security.md", "# aider-sec\nno unsafe\n");
579        let rules = scan_migrated_rules(dir.path(), home.path());
580        let aider_names: Vec<&str> = rules
581            .iter()
582            .filter(|r| r.source_tool == "aider")
583            .map(|r| r.name.as_str())
584            .collect();
585        assert!(aider_names.contains(&"aider-style"), "{aider_names:?}");
586        assert!(aider_names.contains(&"aider-sec"), "{aider_names:?}");
587    }
588
589    #[test]
590    fn aider_conf_yml_flow_style_list_also_loads() {
591        let dir = tempfile::tempdir().unwrap();
592        let home = tempfile::tempdir().unwrap();
593        write(
594            dir.path(),
595            ".aider.conf.yml",
596            "conventions: [docs/inline.md]\n",
597        );
598        write(dir.path(), "docs/inline.md", "# aider-inline\n");
599        let rules = scan_migrated_rules(dir.path(), home.path());
600        assert!(
601            rules
602                .iter()
603                .any(|r| r.source_tool == "aider" && r.name == "aider-inline")
604        );
605    }
606
607    #[test]
608    fn skill_references_are_scanned_from_home_claude_skills() {
609        let dir = tempfile::tempdir().unwrap();
610        let home = tempfile::tempdir().unwrap();
611        write(
612            home.path(),
613            ".claude/skills/demo/SKILL.md",
614            "# demo skill\n\nRead [rule A](references/a.md) and [rule B](templates/b.md).\n\
615             External link https://example.com should be ignored.\n\
616             Local absolute /nope/x.md too.\n",
617        );
618        write(home.path(), ".claude/skills/demo/references/a.md", "# aa\n");
619        write(home.path(), ".claude/skills/demo/templates/b.md", "# bb\n");
620
621        let rules = scan_migrated_rules(dir.path(), home.path());
622        let skill_names: Vec<&str> = rules
623            .iter()
624            .filter(|r| r.source_tool == "skill")
625            .map(|r| r.name.as_str())
626            .collect();
627        assert!(
628            skill_names.contains(&"demo"),
629            "SKILL.md itself must be indexed: {skill_names:?}"
630        );
631        assert!(
632            skill_names.contains(&"skill:demo::references/a.md"),
633            "{skill_names:?}"
634        );
635        assert!(
636            skill_names.contains(&"skill:demo::templates/b.md"),
637            "{skill_names:?}"
638        );
639        assert_eq!(
640            skill_names.len(),
641            3,
642            "skill body + references: {skill_names:?}"
643        );
644    }
645
646    #[test]
647    fn skill_front_matter_description_and_name_are_parsed() {
648        let dir = tempfile::tempdir().unwrap();
649        let home = tempfile::tempdir().unwrap();
650        write(
651            home.path(),
652            ".claude/skills/code-review/SKILL.md",
653            "---\nname: structured-review\ndescription: 结构化代码审查,用于 review 请求。\n---\n\n\
654             # body\n\nRead [rules](references/rules.md).\n",
655        );
656        write(
657            home.path(),
658            ".claude/skills/code-review/references/rules.md",
659            "# rules\n",
660        );
661
662        let rules = scan_migrated_rules(dir.path(), home.path());
663        let body_rule = rules
664            .iter()
665            .find(|r| r.source_tool == "skill" && r.name == "structured-review")
666            .expect("SKILL.md itself must be indexed");
667        assert_eq!(
668            body_rule.description.as_deref(),
669            Some("结构化代码审查,用于 review 请求。")
670        );
671        assert!(body_rule.content.contains("# body"));
672        assert!(!body_rule.content.contains("name: structured-review"));
673
674        let ref_rule = rules
675            .iter()
676            .find(|r| r.name == "skill:structured-review::references/rules.md")
677            .expect("referenced rule must remain indexed");
678        assert_eq!(
679            ref_rule.description.as_deref(),
680            Some("结构化代码审查,用于 review 请求。")
681        );
682    }
683
684    #[test]
685    fn project_skill_roots_are_scanned_before_home_skills() {
686        let project = tempfile::tempdir().unwrap();
687        let home = tempfile::tempdir().unwrap();
688        for root in [
689            ".agents", ".codex", ".claude", ".github", ".cursor", ".kiro", ".vscode",
690        ] {
691            write(
692                project.path(),
693                &format!("{root}/skills/{root}/SKILL.md"),
694                &format!("---\nname: {root}\ndescription: project skill\n---\nbody\n"),
695            );
696        }
697        write(
698            home.path(),
699            ".claude/skills/duplicate/SKILL.md",
700            "---\nname: .codex\n---\nhome body\n",
701        );
702        let rules = scan_migrated_rules(project.path(), home.path());
703        let skills = rules
704            .iter()
705            .filter(|rule| rule.source_tool == "skill")
706            .collect::<Vec<_>>();
707        assert_eq!(skills.len(), 7);
708        assert!(skills.iter().all(|rule| rule.scope == RuleScope::Project));
709        assert_eq!(resolve_by_name(&rules, ".codex").unwrap().content, "body\n");
710    }
711
712    #[cfg(unix)]
713    #[test]
714    fn linked_skill_reference_cannot_escape_its_directory() {
715        use std::os::unix::fs::symlink;
716
717        let project = tempfile::tempdir().unwrap();
718        let home = tempfile::tempdir().unwrap();
719        write(
720            project.path(),
721            ".codex/skills/demo/SKILL.md",
722            "---\nname: demo\n---\nRead [private](references/private.md).\n",
723        );
724        write(project.path(), "private.md", "private text");
725        let target = project.path().join("private.md");
726        let link = project
727            .path()
728            .join(".codex/skills/demo/references/private.md");
729        std::fs::create_dir_all(link.parent().unwrap()).unwrap();
730        symlink(target, link).unwrap();
731
732        let rules = scan_migrated_rules(project.path(), home.path());
733        assert!(rules.iter().any(|rule| rule.name == "demo"));
734        assert!(
735            !rules
736                .iter()
737                .any(|rule| rule.name == "skill:demo::references/private.md")
738        );
739    }
740
741    #[test]
742    fn rule_description_falls_back_to_first_paragraph() {
743        let dir = tempfile::tempdir().unwrap();
744        let home = tempfile::tempdir().unwrap();
745        write(
746            dir.path(),
747            "CLAUDE.md",
748            "# atman rules\n\nBe terse and use rust idioms.\nSecond sentence.\n",
749        );
750        let rules = scan_migrated_rules(dir.path(), home.path());
751        let claude = rules.iter().find(|r| r.source_tool == "claude").unwrap();
752        assert_eq!(
753            claude.description.as_deref(),
754            Some("Be terse and use rust idioms. Second sentence."),
755            "first paragraph fallback"
756        );
757    }
758
759    #[test]
760    fn resolve_by_name_with_at_tool_disambiguation() {
761        let rules = vec![
762            MigratedRule {
763                name: "code-review".into(),
764                source_tool: "opencode".into(),
765                source_path: "/x".into(),
766                scope: RuleScope::Global,
767                content: "opencode-version".into(),
768                description: None,
769            },
770            MigratedRule {
771                name: "code-review".into(),
772                source_tool: "claude".into(),
773                source_path: "/y".into(),
774                scope: RuleScope::Project,
775                content: "claude-version".into(),
776                description: None,
777            },
778        ];
779        let r = resolve_by_name(&rules, "code-review@opencode").unwrap();
780        assert_eq!(r.content, "opencode-version");
781    }
782}