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
277fn 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
292fn 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
318fn 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
329fn 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 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}