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(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(home: &Path, out: &mut Vec<MigratedRule>) {
138    let skills_root = home.join(".claude").join("skills");
139    let Ok(entries) = std::fs::read_dir(&skills_root) else {
140        return;
141    };
142    for entry in entries.flatten() {
143        let skill_dir = entry.path();
144        if !skill_dir.is_dir() {
145            continue;
146        }
147        let skill_md = skill_dir.join("SKILL.md");
148        let Ok(body) = std::fs::read_to_string(&skill_md) else {
149            continue;
150        };
151        let front_matter = parse_front_matter(&body);
152        let skill_name = front_matter
153            .as_ref()
154            .and_then(|fm| fm.get("name"))
155            .map(|s| s.as_str())
156            .filter(|s| !s.is_empty())
157            .map(str::to_string)
158            .or_else(|| {
159                skill_dir
160                    .file_name()
161                    .and_then(|s| s.to_str())
162                    .map(str::to_string)
163            })
164            .unwrap_or_else(|| "unnamed".to_string());
165        let skill_description = front_matter
166            .as_ref()
167            .and_then(|fm| fm.get("description"))
168            .map(|s| s.trim().to_string())
169            .filter(|s| !s.is_empty());
170
171        let skill_content = strip_front_matter(&body).to_string();
172        out.push(MigratedRule {
173            name: skill_name.clone(),
174            source_tool: "skill".into(),
175            source_path: skill_md.clone(),
176            scope: RuleScope::Global,
177            content: skill_content,
178            description: skill_description.clone(),
179        });
180
181        for rel in parse_markdown_local_links(&body) {
182            let full = skill_dir.join(&rel);
183            if let Some(mut rule) = load_file(&full, "skill", RuleScope::Global) {
184                rule.name = format!("skill:{skill_name}::{}", rel.display());
185                if let Some(desc) = &skill_description {
186                    rule.description = Some(desc.clone());
187                }
188                out.push(rule);
189            }
190        }
191    }
192}
193
194fn parse_markdown_local_links(body: &str) -> Vec<PathBuf> {
195    let mut out = Vec::new();
196    let mut cursor = body;
197    while let Some(open) = cursor.find("](") {
198        let after = &cursor[open + 2..];
199        let Some(close) = after.find(')') else {
200            break;
201        };
202        let target = &after[..close];
203        cursor = &after[close + 1..];
204        if target.starts_with("http")
205            || target.starts_with('/')
206            || target.starts_with('#')
207            || target.contains("://")
208        {
209            continue;
210        }
211        if !target.ends_with(".md") {
212            continue;
213        }
214        if target.starts_with("references/") || target.starts_with("templates/") {
215            out.push(PathBuf::from(target));
216        }
217    }
218    out
219}
220
221fn push_dir(out: &mut Vec<MigratedRule>, dir: &Path, tool: &str, ext: &str) {
222    let Ok(entries) = std::fs::read_dir(dir) else {
223        return;
224    };
225    for entry in entries.flatten() {
226        let path = entry.path();
227        if path
228            .extension()
229            .and_then(|s| s.to_str())
230            .map(|s| s == ext)
231            .unwrap_or(false)
232            && let Some(rule) = load_file(&path, tool, RuleScope::Project)
233        {
234            out.push(rule);
235        }
236    }
237}
238
239fn load_file(path: &Path, tool: &str, scope: RuleScope) -> Option<MigratedRule> {
240    let raw = std::fs::read_to_string(path).ok()?;
241    let content = if raw.len() > MAX_RULE_BYTES {
242        let mut truncated = raw[..MAX_RULE_BYTES].to_string();
243        truncated.push_str(&format!(
244            "\n\n[atman: truncated at {MAX_RULE_BYTES} bytes; full at {}]",
245            path.display()
246        ));
247        truncated
248    } else {
249        raw
250    };
251    let name = extract_rule_name(&content).unwrap_or_else(|| basename(path));
252    let description = extract_rule_description(&content);
253    Some(MigratedRule {
254        name,
255        source_tool: tool.into(),
256        source_path: path.to_path_buf(),
257        scope,
258        content,
259        description,
260    })
261}
262
263fn extract_rule_name(content: &str) -> Option<String> {
264    for line in content.lines() {
265        let trimmed = line.trim();
266        if trimmed.is_empty() {
267            continue;
268        }
269        if let Some(rest) = trimmed.strip_prefix("# ") {
270            return Some(rest.trim().to_string());
271        }
272        break;
273    }
274    None
275}
276
277/// Extract a human-readable description for a rule.
278/// Priority: YAML front matter `description` key → first non-empty paragraph after
279/// the front matter / leading heading.
280fn extract_rule_description(content: &str) -> Option<String> {
281    if let Some(fm) = parse_front_matter(content) {
282        if let Some(desc) = fm.get("description") {
283            let desc = desc.trim();
284            if !desc.is_empty() {
285                return Some(desc.to_string());
286            }
287        }
288    }
289    first_paragraph(content)
290}
291
292/// Parse `---\nkey: value\n---` YAML front matter. Returns None if absent.
293/// Handles both the simple `key: value` form and (for SKILL.md) the same layout.
294fn parse_front_matter(content: &str) -> Option<std::collections::HashMap<String, String>> {
295    let rest = content.strip_prefix("---")?;
296    let end = rest.find("\n---")?;
297    let block = &rest[..end];
298    let mut map = std::collections::HashMap::new();
299    for line in block.lines() {
300        let trimmed = line.trim();
301        if trimmed.is_empty() || trimmed.starts_with('#') {
302            continue;
303        }
304        let Some((key, value)) = trimmed.split_once(':') else {
305            continue;
306        };
307        map.insert(
308            key.trim().to_string(),
309            value
310                .trim()
311                .trim_matches(|c| c == '"' || c == '\'')
312                .to_string(),
313        );
314    }
315    Some(map)
316}
317
318/// Remove the leading YAML front matter from a skill body.
319fn strip_front_matter(content: &str) -> &str {
320    let Some(rest) = content.strip_prefix("---") else {
321        return content;
322    };
323    let Some(end) = rest.find("\n---") else {
324        return content;
325    };
326    rest[end + 4..].trim_start_matches('\n')
327}
328
329/// First non-empty paragraph (skipping the leading `# ` heading if present) used as
330/// a fallback description when no front matter description exists.
331fn first_paragraph(content: &str) -> Option<String> {
332    let mut para = String::new();
333    let mut in_para = false;
334
335    for line in content.lines() {
336        let trimmed = line.trim();
337        if trimmed.is_empty() {
338            if in_para {
339                break;
340            }
341            continue;
342        }
343        if !in_para && trimmed.starts_with("# ") {
344            // Skip the leading heading (if any), then treat the next non-empty
345            // non-heading line as the start of the description paragraph.
346            continue;
347        }
348        if !in_para {
349            in_para = true;
350            para = trimmed.to_string();
351        } else if trimmed.starts_with("# ") {
352            break;
353        } else {
354            para.push(' ');
355            para.push_str(trimmed);
356        }
357    }
358    if para.is_empty() { None } else { Some(para) }
359}
360
361fn basename(path: &Path) -> String {
362    path.file_stem()
363        .and_then(|s| s.to_str())
364        .unwrap_or("unnamed")
365        .to_string()
366}
367
368pub fn resolve_by_name<'a>(rules: &'a [MigratedRule], query: &str) -> Option<&'a MigratedRule> {
369    if let Some((name, tool)) = query.split_once('@') {
370        return rules
371            .iter()
372            .find(|r| r.name == name && r.source_tool == tool);
373    }
374    let matches: Vec<&MigratedRule> = rules.iter().filter(|r| r.name == query).collect();
375    if matches.is_empty() {
376        return None;
377    }
378    if let Some(project) = matches
379        .iter()
380        .find(|r| matches!(r.scope, RuleScope::Project))
381    {
382        return Some(*project);
383    }
384    matches.first().copied()
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    fn write(dir: &Path, rel: &str, content: &str) {
392        let path = dir.join(rel);
393        if let Some(parent) = path.parent() {
394            std::fs::create_dir_all(parent).unwrap();
395        }
396        std::fs::write(path, content).unwrap();
397    }
398
399    #[test]
400    fn detects_claude_md_in_project_root() {
401        let dir = tempfile::tempdir().unwrap();
402        let home = tempfile::tempdir().unwrap();
403        write(dir.path(), "CLAUDE.md", "# atman rules\n\nBe terse.\n");
404        let rules = scan_migrated_rules(dir.path(), home.path());
405        let claude = rules
406            .iter()
407            .find(|r| r.source_tool == "claude")
408            .expect("expected CLAUDE.md rule");
409        assert_eq!(claude.name, "atman rules");
410        assert!(matches!(claude.scope, RuleScope::Project));
411    }
412
413    #[test]
414    fn detects_agents_md_and_cursorrules_and_conventions() {
415        let dir = tempfile::tempdir().unwrap();
416        let home = tempfile::tempdir().unwrap();
417        write(dir.path(), "AGENTS.md", "# opencode-rules\ncontent");
418        write(dir.path(), ".cursorrules", "# cursor-flat\nuse rust");
419        write(dir.path(), "CONVENTIONS.md", "# aider-conv\nx");
420        let rules = scan_migrated_rules(dir.path(), home.path());
421        let tools: Vec<&str> = rules.iter().map(|r| r.source_tool.as_str()).collect();
422        assert!(tools.contains(&"opencode"));
423        assert!(tools.contains(&"cursor"));
424        assert!(tools.contains(&"aider"));
425    }
426
427    #[test]
428    fn detects_cursor_rules_directory() {
429        let dir = tempfile::tempdir().unwrap();
430        let home = tempfile::tempdir().unwrap();
431        write(dir.path(), ".cursor/rules/rust.md", "# rust\nuse borrow");
432        write(dir.path(), ".cursor/rules/style.md", "# style\nno emoji");
433        let rules = scan_migrated_rules(dir.path(), home.path());
434        let cursor_rules: Vec<&str> = rules
435            .iter()
436            .filter(|r| r.source_tool == "cursor")
437            .map(|r| r.name.as_str())
438            .collect();
439        assert!(cursor_rules.contains(&"rust"));
440        assert!(cursor_rules.contains(&"style"));
441    }
442
443    #[test]
444    fn detects_kiro_steering_directory() {
445        let dir = tempfile::tempdir().unwrap();
446        let home = tempfile::tempdir().unwrap();
447        write(dir.path(), ".kiro/steering/api.md", "# api-guide\ncontent");
448        let rules = scan_migrated_rules(dir.path(), home.path());
449        let found = rules.iter().find(|r| r.source_tool == "kiro").unwrap();
450        assert_eq!(found.name, "api-guide");
451    }
452
453    #[test]
454    fn detects_user_scope_files_from_home() {
455        let dir = tempfile::tempdir().unwrap();
456        let home = tempfile::tempdir().unwrap();
457        write(home.path(), ".claude/CLAUDE.md", "# global-claude\nx");
458        write(
459            home.path(),
460            ".config/opencode/AGENTS.md",
461            "# global-opencode\ny",
462        );
463        let rules = scan_migrated_rules(dir.path(), home.path());
464        let global_names: Vec<&str> = rules
465            .iter()
466            .filter(|r| matches!(r.scope, RuleScope::Global))
467            .map(|r| r.name.as_str())
468            .collect();
469        assert!(global_names.contains(&"global-claude"));
470        assert!(global_names.contains(&"global-opencode"));
471    }
472
473    #[test]
474    fn extract_name_from_first_heading_or_basename() {
475        let dir = tempfile::tempdir().unwrap();
476        let home = tempfile::tempdir().unwrap();
477        write(dir.path(), "CLAUDE.md", "no heading here\nblah");
478        let rules = scan_migrated_rules(dir.path(), home.path());
479        let claude = rules.iter().find(|r| r.source_tool == "claude").unwrap();
480        assert_eq!(claude.name, "CLAUDE", "basename fallback");
481    }
482
483    #[test]
484    fn truncates_oversized_rule() {
485        let dir = tempfile::tempdir().unwrap();
486        let home = tempfile::tempdir().unwrap();
487        let big = "# huge\n".to_string() + &"x".repeat(MAX_RULE_BYTES + 1000);
488        write(dir.path(), "CLAUDE.md", &big);
489        let rules = scan_migrated_rules(dir.path(), home.path());
490        let claude = rules.iter().find(|r| r.source_tool == "claude").unwrap();
491        assert!(claude.content.contains("[atman: truncated"));
492        assert!(claude.content.len() < MAX_RULE_BYTES + 200);
493    }
494
495    #[test]
496    fn resolve_by_name_prefers_project_over_global() {
497        let rules = vec![
498            MigratedRule {
499                name: "code-review".into(),
500                source_tool: "opencode".into(),
501                source_path: "/user".into(),
502                scope: RuleScope::Global,
503                content: "global-version".into(),
504                description: None,
505            },
506            MigratedRule {
507                name: "code-review".into(),
508                source_tool: "claude".into(),
509                source_path: "/proj".into(),
510                scope: RuleScope::Project,
511                content: "project-version".into(),
512                description: None,
513            },
514        ];
515        let r = resolve_by_name(&rules, "code-review").unwrap();
516        assert!(matches!(r.scope, RuleScope::Project));
517        assert_eq!(r.content, "project-version");
518    }
519
520    #[test]
521    fn aider_conf_yml_block_list_loads_convention_markdown() {
522        let dir = tempfile::tempdir().unwrap();
523        let home = tempfile::tempdir().unwrap();
524        write(
525            dir.path(),
526            ".aider.conf.yml",
527            "model: claude-sonnet-4\nconventions:\n  - docs/style.md\n  - \"docs/security.md\"\nedit-format: diff\n",
528        );
529        write(dir.path(), "docs/style.md", "# aider-style\nuse rustfmt\n");
530        write(dir.path(), "docs/security.md", "# aider-sec\nno unsafe\n");
531        let rules = scan_migrated_rules(dir.path(), home.path());
532        let aider_names: Vec<&str> = rules
533            .iter()
534            .filter(|r| r.source_tool == "aider")
535            .map(|r| r.name.as_str())
536            .collect();
537        assert!(aider_names.contains(&"aider-style"), "{aider_names:?}");
538        assert!(aider_names.contains(&"aider-sec"), "{aider_names:?}");
539    }
540
541    #[test]
542    fn aider_conf_yml_flow_style_list_also_loads() {
543        let dir = tempfile::tempdir().unwrap();
544        let home = tempfile::tempdir().unwrap();
545        write(
546            dir.path(),
547            ".aider.conf.yml",
548            "conventions: [docs/inline.md]\n",
549        );
550        write(dir.path(), "docs/inline.md", "# aider-inline\n");
551        let rules = scan_migrated_rules(dir.path(), home.path());
552        assert!(
553            rules
554                .iter()
555                .any(|r| r.source_tool == "aider" && r.name == "aider-inline")
556        );
557    }
558
559    #[test]
560    fn skill_references_are_scanned_from_home_claude_skills() {
561        let dir = tempfile::tempdir().unwrap();
562        let home = tempfile::tempdir().unwrap();
563        write(
564            home.path(),
565            ".claude/skills/demo/SKILL.md",
566            "# demo skill\n\nRead [rule A](references/a.md) and [rule B](templates/b.md).\n\
567             External link https://example.com should be ignored.\n\
568             Local absolute /nope/x.md too.\n",
569        );
570        write(home.path(), ".claude/skills/demo/references/a.md", "# aa\n");
571        write(home.path(), ".claude/skills/demo/templates/b.md", "# bb\n");
572
573        let rules = scan_migrated_rules(dir.path(), home.path());
574        let skill_names: Vec<&str> = rules
575            .iter()
576            .filter(|r| r.source_tool == "skill")
577            .map(|r| r.name.as_str())
578            .collect();
579        assert!(
580            skill_names.contains(&"demo"),
581            "SKILL.md itself must be indexed: {skill_names:?}"
582        );
583        assert!(
584            skill_names.contains(&"skill:demo::references/a.md"),
585            "{skill_names:?}"
586        );
587        assert!(
588            skill_names.contains(&"skill:demo::templates/b.md"),
589            "{skill_names:?}"
590        );
591        assert_eq!(
592            skill_names.len(),
593            3,
594            "skill body + references: {skill_names:?}"
595        );
596    }
597
598    #[test]
599    fn skill_front_matter_description_and_name_are_parsed() {
600        let dir = tempfile::tempdir().unwrap();
601        let home = tempfile::tempdir().unwrap();
602        write(
603            home.path(),
604            ".claude/skills/code-review/SKILL.md",
605            "---\nname: structured-review\ndescription: 结构化代码审查,用于 review 请求。\n---\n\n\
606             # body\n\nRead [rules](references/rules.md).\n",
607        );
608        write(
609            home.path(),
610            ".claude/skills/code-review/references/rules.md",
611            "# rules\n",
612        );
613
614        let rules = scan_migrated_rules(dir.path(), home.path());
615        let body_rule = rules
616            .iter()
617            .find(|r| r.source_tool == "skill" && r.name == "structured-review")
618            .expect("SKILL.md itself must be indexed");
619        assert_eq!(
620            body_rule.description.as_deref(),
621            Some("结构化代码审查,用于 review 请求。")
622        );
623        assert!(body_rule.content.contains("# body"));
624        assert!(!body_rule.content.contains("name: structured-review"));
625
626        let ref_rule = rules
627            .iter()
628            .find(|r| r.name == "skill:structured-review::references/rules.md")
629            .expect("referenced rule must remain indexed");
630        assert_eq!(
631            ref_rule.description.as_deref(),
632            Some("结构化代码审查,用于 review 请求。")
633        );
634    }
635
636    #[test]
637    fn rule_description_falls_back_to_first_paragraph() {
638        let dir = tempfile::tempdir().unwrap();
639        let home = tempfile::tempdir().unwrap();
640        write(
641            dir.path(),
642            "CLAUDE.md",
643            "# atman rules\n\nBe terse and use rust idioms.\nSecond sentence.\n",
644        );
645        let rules = scan_migrated_rules(dir.path(), home.path());
646        let claude = rules.iter().find(|r| r.source_tool == "claude").unwrap();
647        assert_eq!(
648            claude.description.as_deref(),
649            Some("Be terse and use rust idioms. Second sentence."),
650            "first paragraph fallback"
651        );
652    }
653
654    #[test]
655    fn resolve_by_name_with_at_tool_disambiguation() {
656        let rules = vec![
657            MigratedRule {
658                name: "code-review".into(),
659                source_tool: "opencode".into(),
660                source_path: "/x".into(),
661                scope: RuleScope::Global,
662                content: "opencode-version".into(),
663                description: None,
664            },
665            MigratedRule {
666                name: "code-review".into(),
667                source_tool: "claude".into(),
668                source_path: "/y".into(),
669                scope: RuleScope::Project,
670                content: "claude-version".into(),
671                description: None,
672            },
673        ];
674        let r = resolve_by_name(&rules, "code-review@opencode").unwrap();
675        assert_eq!(r.content, "opencode-version");
676    }
677}