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
325fn 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
340fn 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
366fn 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
377fn 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 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}