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}
13
14#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(rename_all = "snake_case")]
16pub enum RuleScope {
17    Project,
18    Global,
19}
20
21const MAX_RULE_BYTES: usize = 100_000;
22
23pub fn scan_migrated_rules(project_root: &Path, home: &Path) -> Vec<MigratedRule> {
24    let mut out = Vec::new();
25
26    for (rel, tool) in &[
27        ("CLAUDE.md", "claude"),
28        ("AGENTS.md", "opencode"),
29        (".cursorrules", "cursor"),
30        ("CONVENTIONS.md", "aider"),
31    ] {
32        let path = project_root.join(rel);
33        if let Some(rule) = load_file(&path, tool, RuleScope::Project) {
34            out.push(rule);
35        }
36    }
37
38    push_dir(
39        &mut out,
40        &project_root.join(".cursor/rules"),
41        "cursor",
42        "md",
43    );
44    push_dir(&mut out, &project_root.join(".kiro/steering"), "kiro", "md");
45
46    for (rel, tool) in &[
47        (".claude/CLAUDE.md", "claude"),
48        (".config/opencode/AGENTS.md", "opencode"),
49    ] {
50        let path = home.join(rel);
51        if let Some(rule) = load_file(&path, tool, RuleScope::Global) {
52            out.push(rule);
53        }
54    }
55
56    scan_aider_yaml(project_root, &mut out);
57    scan_skill_references(home, &mut out);
58
59    out
60}
61
62fn scan_aider_yaml(project_root: &Path, out: &mut Vec<MigratedRule>) {
63    let yml = project_root.join(".aider.conf.yml");
64    let Ok(raw) = std::fs::read_to_string(&yml) else {
65        return;
66    };
67    for path in parse_aider_conventions(&raw) {
68        let full = if path.is_absolute() {
69            path
70        } else {
71            project_root.join(path)
72        };
73        if let Some(rule) = load_file(&full, "aider", RuleScope::Project) {
74            out.push(rule);
75        }
76    }
77}
78
79fn parse_aider_conventions(yaml: &str) -> Vec<PathBuf> {
80    let mut out = Vec::new();
81    let mut inside = false;
82    for line in yaml.lines() {
83        let stripped = strip_yaml_comment(line);
84        if let Some(rest) = stripped.strip_prefix("conventions:") {
85            let rest = rest.trim();
86            inside = true;
87            if let Some(list) = rest.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
88                for item in list.split(',') {
89                    let value = item.trim().trim_matches(|c| c == '"' || c == '\'');
90                    if !value.is_empty() {
91                        out.push(PathBuf::from(value));
92                    }
93                }
94                inside = false;
95            }
96            continue;
97        }
98        if !inside {
99            continue;
100        }
101        let indent = stripped.len() - stripped.trim_start().len();
102        if indent == 0 && !stripped.trim().is_empty() {
103            inside = false;
104            continue;
105        }
106        let trimmed = stripped.trim();
107        if let Some(item) = trimmed.strip_prefix('-') {
108            let value = item.trim().trim_matches(|c| c == '"' || c == '\'');
109            if !value.is_empty() {
110                out.push(PathBuf::from(value));
111            }
112        }
113    }
114    out
115}
116
117fn strip_yaml_comment(line: &str) -> &str {
118    if let Some((code, _)) = line.split_once(" #") {
119        code
120    } else if let Some(rest) = line.strip_prefix('#') {
121        &rest[..0]
122    } else {
123        line
124    }
125}
126
127fn scan_skill_references(home: &Path, out: &mut Vec<MigratedRule>) {
128    let skills_root = home.join(".claude").join("skills");
129    let Ok(entries) = std::fs::read_dir(&skills_root) else {
130        return;
131    };
132    for entry in entries.flatten() {
133        let skill_dir = entry.path();
134        if !skill_dir.is_dir() {
135            continue;
136        }
137        let skill_md = skill_dir.join("SKILL.md");
138        let Ok(body) = std::fs::read_to_string(&skill_md) else {
139            continue;
140        };
141        let skill_name = skill_dir
142            .file_name()
143            .and_then(|s| s.to_str())
144            .unwrap_or("unnamed");
145        for rel in parse_markdown_local_links(&body) {
146            let full = skill_dir.join(&rel);
147            if let Some(mut rule) = load_file(&full, "skill", RuleScope::Global) {
148                rule.name = format!("skill:{skill_name}::{}", rel.display());
149                out.push(rule);
150            }
151        }
152    }
153}
154
155fn parse_markdown_local_links(body: &str) -> Vec<PathBuf> {
156    let mut out = Vec::new();
157    let mut cursor = body;
158    while let Some(open) = cursor.find("](") {
159        let after = &cursor[open + 2..];
160        let Some(close) = after.find(')') else {
161            break;
162        };
163        let target = &after[..close];
164        cursor = &after[close + 1..];
165        if target.starts_with("http")
166            || target.starts_with('/')
167            || target.starts_with('#')
168            || target.contains("://")
169        {
170            continue;
171        }
172        if !target.ends_with(".md") {
173            continue;
174        }
175        if target.starts_with("references/") || target.starts_with("templates/") {
176            out.push(PathBuf::from(target));
177        }
178    }
179    out
180}
181
182fn push_dir(out: &mut Vec<MigratedRule>, dir: &Path, tool: &str, ext: &str) {
183    let Ok(entries) = std::fs::read_dir(dir) else {
184        return;
185    };
186    for entry in entries.flatten() {
187        let path = entry.path();
188        if path
189            .extension()
190            .and_then(|s| s.to_str())
191            .map(|s| s == ext)
192            .unwrap_or(false)
193            && let Some(rule) = load_file(&path, tool, RuleScope::Project)
194        {
195            out.push(rule);
196        }
197    }
198}
199
200fn load_file(path: &Path, tool: &str, scope: RuleScope) -> Option<MigratedRule> {
201    let raw = std::fs::read_to_string(path).ok()?;
202    let content = if raw.len() > MAX_RULE_BYTES {
203        let mut truncated = raw[..MAX_RULE_BYTES].to_string();
204        truncated.push_str(&format!(
205            "\n\n[atman: truncated at {MAX_RULE_BYTES} bytes; full at {}]",
206            path.display()
207        ));
208        truncated
209    } else {
210        raw
211    };
212    let name = extract_rule_name(&content).unwrap_or_else(|| basename(path));
213    Some(MigratedRule {
214        name,
215        source_tool: tool.into(),
216        source_path: path.to_path_buf(),
217        scope,
218        content,
219    })
220}
221
222fn extract_rule_name(content: &str) -> Option<String> {
223    for line in content.lines() {
224        let trimmed = line.trim();
225        if trimmed.is_empty() {
226            continue;
227        }
228        if let Some(rest) = trimmed.strip_prefix("# ") {
229            return Some(rest.trim().to_string());
230        }
231        break;
232    }
233    None
234}
235
236fn basename(path: &Path) -> String {
237    path.file_stem()
238        .and_then(|s| s.to_str())
239        .unwrap_or("unnamed")
240        .to_string()
241}
242
243pub fn resolve_by_name<'a>(rules: &'a [MigratedRule], query: &str) -> Option<&'a MigratedRule> {
244    if let Some((name, tool)) = query.split_once('@') {
245        return rules
246            .iter()
247            .find(|r| r.name == name && r.source_tool == tool);
248    }
249    let matches: Vec<&MigratedRule> = rules.iter().filter(|r| r.name == query).collect();
250    if matches.is_empty() {
251        return None;
252    }
253    if let Some(project) = matches
254        .iter()
255        .find(|r| matches!(r.scope, RuleScope::Project))
256    {
257        return Some(*project);
258    }
259    matches.first().copied()
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    fn write(dir: &Path, rel: &str, content: &str) {
267        let path = dir.join(rel);
268        if let Some(parent) = path.parent() {
269            std::fs::create_dir_all(parent).unwrap();
270        }
271        std::fs::write(path, content).unwrap();
272    }
273
274    #[test]
275    fn detects_claude_md_in_project_root() {
276        let dir = tempfile::tempdir().unwrap();
277        let home = tempfile::tempdir().unwrap();
278        write(dir.path(), "CLAUDE.md", "# atman rules\n\nBe terse.\n");
279        let rules = scan_migrated_rules(dir.path(), home.path());
280        let claude = rules
281            .iter()
282            .find(|r| r.source_tool == "claude")
283            .expect("expected CLAUDE.md rule");
284        assert_eq!(claude.name, "atman rules");
285        assert!(matches!(claude.scope, RuleScope::Project));
286    }
287
288    #[test]
289    fn detects_agents_md_and_cursorrules_and_conventions() {
290        let dir = tempfile::tempdir().unwrap();
291        let home = tempfile::tempdir().unwrap();
292        write(dir.path(), "AGENTS.md", "# opencode-rules\ncontent");
293        write(dir.path(), ".cursorrules", "# cursor-flat\nuse rust");
294        write(dir.path(), "CONVENTIONS.md", "# aider-conv\nx");
295        let rules = scan_migrated_rules(dir.path(), home.path());
296        let tools: Vec<&str> = rules.iter().map(|r| r.source_tool.as_str()).collect();
297        assert!(tools.contains(&"opencode"));
298        assert!(tools.contains(&"cursor"));
299        assert!(tools.contains(&"aider"));
300    }
301
302    #[test]
303    fn detects_cursor_rules_directory() {
304        let dir = tempfile::tempdir().unwrap();
305        let home = tempfile::tempdir().unwrap();
306        write(dir.path(), ".cursor/rules/rust.md", "# rust\nuse borrow");
307        write(dir.path(), ".cursor/rules/style.md", "# style\nno emoji");
308        let rules = scan_migrated_rules(dir.path(), home.path());
309        let cursor_rules: Vec<&str> = rules
310            .iter()
311            .filter(|r| r.source_tool == "cursor")
312            .map(|r| r.name.as_str())
313            .collect();
314        assert!(cursor_rules.contains(&"rust"));
315        assert!(cursor_rules.contains(&"style"));
316    }
317
318    #[test]
319    fn detects_kiro_steering_directory() {
320        let dir = tempfile::tempdir().unwrap();
321        let home = tempfile::tempdir().unwrap();
322        write(dir.path(), ".kiro/steering/api.md", "# api-guide\ncontent");
323        let rules = scan_migrated_rules(dir.path(), home.path());
324        let found = rules.iter().find(|r| r.source_tool == "kiro").unwrap();
325        assert_eq!(found.name, "api-guide");
326    }
327
328    #[test]
329    fn detects_user_scope_files_from_home() {
330        let dir = tempfile::tempdir().unwrap();
331        let home = tempfile::tempdir().unwrap();
332        write(home.path(), ".claude/CLAUDE.md", "# global-claude\nx");
333        write(
334            home.path(),
335            ".config/opencode/AGENTS.md",
336            "# global-opencode\ny",
337        );
338        let rules = scan_migrated_rules(dir.path(), home.path());
339        let global_names: Vec<&str> = rules
340            .iter()
341            .filter(|r| matches!(r.scope, RuleScope::Global))
342            .map(|r| r.name.as_str())
343            .collect();
344        assert!(global_names.contains(&"global-claude"));
345        assert!(global_names.contains(&"global-opencode"));
346    }
347
348    #[test]
349    fn extract_name_from_first_heading_or_basename() {
350        let dir = tempfile::tempdir().unwrap();
351        let home = tempfile::tempdir().unwrap();
352        write(dir.path(), "CLAUDE.md", "no heading here\nblah");
353        let rules = scan_migrated_rules(dir.path(), home.path());
354        let claude = rules.iter().find(|r| r.source_tool == "claude").unwrap();
355        assert_eq!(claude.name, "CLAUDE", "basename fallback");
356    }
357
358    #[test]
359    fn truncates_oversized_rule() {
360        let dir = tempfile::tempdir().unwrap();
361        let home = tempfile::tempdir().unwrap();
362        let big = "# huge\n".to_string() + &"x".repeat(MAX_RULE_BYTES + 1000);
363        write(dir.path(), "CLAUDE.md", &big);
364        let rules = scan_migrated_rules(dir.path(), home.path());
365        let claude = rules.iter().find(|r| r.source_tool == "claude").unwrap();
366        assert!(claude.content.contains("[atman: truncated"));
367        assert!(claude.content.len() < MAX_RULE_BYTES + 200);
368    }
369
370    #[test]
371    fn resolve_by_name_prefers_project_over_global() {
372        let rules = vec![
373            MigratedRule {
374                name: "code-review".into(),
375                source_tool: "opencode".into(),
376                source_path: "/user".into(),
377                scope: RuleScope::Global,
378                content: "global-version".into(),
379            },
380            MigratedRule {
381                name: "code-review".into(),
382                source_tool: "claude".into(),
383                source_path: "/proj".into(),
384                scope: RuleScope::Project,
385                content: "project-version".into(),
386            },
387        ];
388        let r = resolve_by_name(&rules, "code-review").unwrap();
389        assert!(matches!(r.scope, RuleScope::Project));
390        assert_eq!(r.content, "project-version");
391    }
392
393    #[test]
394    fn aider_conf_yml_block_list_loads_convention_markdown() {
395        let dir = tempfile::tempdir().unwrap();
396        let home = tempfile::tempdir().unwrap();
397        write(
398            dir.path(),
399            ".aider.conf.yml",
400            "model: claude-sonnet-4\nconventions:\n  - docs/style.md\n  - \"docs/security.md\"\nedit-format: diff\n",
401        );
402        write(dir.path(), "docs/style.md", "# aider-style\nuse rustfmt\n");
403        write(dir.path(), "docs/security.md", "# aider-sec\nno unsafe\n");
404        let rules = scan_migrated_rules(dir.path(), home.path());
405        let aider_names: Vec<&str> = rules
406            .iter()
407            .filter(|r| r.source_tool == "aider")
408            .map(|r| r.name.as_str())
409            .collect();
410        assert!(aider_names.contains(&"aider-style"), "{aider_names:?}");
411        assert!(aider_names.contains(&"aider-sec"), "{aider_names:?}");
412    }
413
414    #[test]
415    fn aider_conf_yml_flow_style_list_also_loads() {
416        let dir = tempfile::tempdir().unwrap();
417        let home = tempfile::tempdir().unwrap();
418        write(
419            dir.path(),
420            ".aider.conf.yml",
421            "conventions: [docs/inline.md]\n",
422        );
423        write(dir.path(), "docs/inline.md", "# aider-inline\n");
424        let rules = scan_migrated_rules(dir.path(), home.path());
425        assert!(
426            rules
427                .iter()
428                .any(|r| r.source_tool == "aider" && r.name == "aider-inline")
429        );
430    }
431
432    #[test]
433    fn skill_references_are_scanned_from_home_claude_skills() {
434        let dir = tempfile::tempdir().unwrap();
435        let home = tempfile::tempdir().unwrap();
436        write(
437            home.path(),
438            ".claude/skills/demo/SKILL.md",
439            "# demo skill\n\nRead [rule A](references/a.md) and [rule B](templates/b.md).\n\
440             External link https://example.com should be ignored.\n\
441             Local absolute /nope/x.md too.\n",
442        );
443        write(home.path(), ".claude/skills/demo/references/a.md", "# aa\n");
444        write(home.path(), ".claude/skills/demo/templates/b.md", "# bb\n");
445
446        let rules = scan_migrated_rules(dir.path(), home.path());
447        let skill_names: Vec<&str> = rules
448            .iter()
449            .filter(|r| r.source_tool == "skill")
450            .map(|r| r.name.as_str())
451            .collect();
452        assert!(
453            skill_names.contains(&"skill:demo::references/a.md"),
454            "{skill_names:?}"
455        );
456        assert!(
457            skill_names.contains(&"skill:demo::templates/b.md"),
458            "{skill_names:?}"
459        );
460        assert_eq!(skill_names.len(), 2, "external / absolute filtered out");
461    }
462
463    #[test]
464    fn resolve_by_name_with_at_tool_disambiguation() {
465        let rules = vec![
466            MigratedRule {
467                name: "code-review".into(),
468                source_tool: "opencode".into(),
469                source_path: "/x".into(),
470                scope: RuleScope::Global,
471                content: "opencode-version".into(),
472            },
473            MigratedRule {
474                name: "code-review".into(),
475                source_tool: "claude".into(),
476                source_path: "/y".into(),
477                scope: RuleScope::Project,
478                content: "claude-version".into(),
479            },
480        ];
481        let r = resolve_by_name(&rules, "code-review@opencode").unwrap();
482        assert_eq!(r.content, "opencode-version");
483    }
484}