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}