1use std::collections::HashMap;
10
11use indexmap::IndexMap;
12
13use crate::error::MarsError;
14use crate::frontmatter;
15use crate::lock::ItemKind;
16use crate::resolve::ResolvedGraph;
17use crate::sync::target::{CollisionRename, ExplicitSkillRename, TargetState};
18use crate::types::{DestPath, ItemName, SourceName};
19
20type ContentRewriteFn =
21 fn(&str, &IndexMap<String, String>) -> Result<Option<String>, frontmatter::FrontmatterError>;
22
23#[derive(Debug, Default)]
25pub struct RenameIndex {
26 skill_renames: HashMap<ItemName, Vec<(ItemName, SourceName)>>,
28 subagent_renames: HashMap<ItemName, Vec<(ItemName, SourceName)>>,
30}
31
32impl RenameIndex {
33 pub fn new(
34 explicit_skill_renames: &[ExplicitSkillRename],
35 collision_renames: &[CollisionRename],
36 target: &TargetState,
37 ) -> Self {
38 let mut index = Self::default();
39 let mut skill_collision_final_names: HashMap<(SourceName, ItemName), ItemName> =
40 HashMap::new();
41
42 for rename in collision_renames {
43 if !target_has_item(target, &rename.source_name, rename.kind, &rename.new_name) {
44 continue;
45 }
46
47 let renames = match rename.kind {
48 ItemKind::Skill => {
49 skill_collision_final_names.insert(
50 (rename.source_name.clone(), rename.original_name.clone()),
51 rename.new_name.clone(),
52 );
53 &mut index.skill_renames
54 }
55 ItemKind::Agent => &mut index.subagent_renames,
56 _ => continue,
57 };
58 push_rename(
59 renames,
60 rename.original_name.clone(),
61 rename.new_name.clone(),
62 rename.source_name.clone(),
63 );
64 }
65
66 for rename in explicit_skill_renames {
67 let installed_name = skill_collision_final_names
68 .get(&(rename.source_name.clone(), rename.new_name.clone()))
69 .unwrap_or(&rename.new_name);
70 if !target_has_item(target, &rename.source_name, ItemKind::Skill, installed_name) {
71 continue;
72 }
73 push_rename(
74 &mut index.skill_renames,
75 rename.original_name.clone(),
76 installed_name.clone(),
77 rename.source_name.clone(),
78 );
79 }
80
81 index
82 }
83
84 pub fn is_empty(&self) -> bool {
85 self.skill_renames.is_empty() && self.subagent_renames.is_empty()
86 }
87}
88
89pub fn apply_renames(
91 target: &mut TargetState,
92 index: &RenameIndex,
93 graph: &ResolvedGraph,
94 dep_precedence: &[SourceName],
95) -> Result<Vec<String>, MarsError> {
96 let mut warnings = Vec::new();
97
98 if index.is_empty() {
99 return Ok(warnings);
100 }
101
102 let agent_keys: Vec<DestPath> = target
104 .items
105 .iter()
106 .filter(|(_, item)| item.id.kind == ItemKind::Agent)
107 .map(|(key, _)| key.clone())
108 .collect();
109
110 for key in agent_keys {
111 let (source_path, source_name, content) = {
112 let item = &target.items[&key];
113 let content = match &item.rewritten_content {
114 Some(content) => content.clone(),
115 None => match std::fs::read_to_string(&item.source_path) {
116 Ok(content) => content,
117 Err(_) => continue,
118 },
119 };
120 (item.source_path.clone(), item.source_name.clone(), content)
121 };
122
123 let agent_deps = ordered_agent_deps(graph, &source_name, dep_precedence);
124 let skill_renames = renames_for_agent(
125 target,
126 &source_name,
127 &agent_deps,
128 ItemKind::Skill,
129 &index.skill_renames,
130 );
131 let subagent_renames = renames_for_agent(
132 target,
133 &source_name,
134 &agent_deps,
135 ItemKind::Agent,
136 &index.subagent_renames,
137 );
138 if skill_renames.is_empty() && subagent_renames.is_empty() {
139 continue;
140 }
141
142 let mut rewritten_content = content;
143 let mut changed = false;
144 rewrite_content_for_agent(
145 &mut rewritten_content,
146 &skill_renames,
147 "skill",
148 &source_path,
149 frontmatter::rewrite_content_skills,
150 &mut warnings,
151 &mut changed,
152 );
153 rewrite_content_for_agent(
154 &mut rewritten_content,
155 &subagent_renames,
156 "subagent",
157 &source_path,
158 frontmatter::rewrite_content_subagents,
159 &mut warnings,
160 &mut changed,
161 );
162 if changed && let Some(target_item) = target.items.get_mut(&key) {
163 target_item.rewritten_content = Some(rewritten_content);
164 }
165 }
166
167 Ok(warnings)
168}
169
170fn push_rename(
171 renames: &mut HashMap<ItemName, Vec<(ItemName, SourceName)>>,
172 original_name: ItemName,
173 new_name: ItemName,
174 source_name: SourceName,
175) {
176 let entries = renames.entry(original_name).or_default();
177 if entries.iter().any(|(existing_name, existing_source)| {
178 existing_name == &new_name && existing_source == &source_name
179 }) {
180 return;
181 }
182 entries.push((new_name, source_name));
183}
184
185fn target_has_item(
186 target: &TargetState,
187 source_name: &SourceName,
188 kind: ItemKind,
189 name: &ItemName,
190) -> bool {
191 target.items.values().any(|item| {
192 item.source_name == *source_name && item.id.kind == kind && item.id.name == *name
193 })
194}
195
196fn ordered_agent_deps(
197 graph: &ResolvedGraph,
198 source_name: &SourceName,
199 dep_precedence: &[SourceName],
200) -> Vec<SourceName> {
201 if source_name.as_str() == "_self" {
202 return dep_precedence.to_vec();
203 }
204
205 let Some(node) = graph.nodes.get(source_name) else {
206 return Vec::new();
207 };
208
209 let mut ordered = Vec::new();
210 for dep in dep_precedence {
211 if node.deps.contains(dep) {
212 ordered.push(dep.clone());
213 }
214 }
215 for dep in &node.deps {
216 if !ordered.contains(dep) {
217 ordered.push(dep.clone());
218 }
219 }
220 ordered
221}
222
223fn renames_for_agent(
224 target: &TargetState,
225 source_name: &SourceName,
226 agent_deps: &[SourceName],
227 referenced_kind: ItemKind,
228 renames: &HashMap<ItemName, Vec<(ItemName, SourceName)>>,
229) -> IndexMap<String, String> {
230 let mut renames_for_agent = IndexMap::new();
231 for (original_name, entries) in renames {
232 let selected = entries.iter().find(|(_, source)| source == source_name);
233 let selected = if selected.is_none()
234 && source_has_unrenamed_item(target, source_name, referenced_kind, original_name)
235 {
236 None
237 } else {
238 selected.or_else(|| {
239 agent_deps
240 .iter()
241 .find_map(|dep| entries.iter().find(|(_, source)| source == dep))
242 })
243 };
244 if let Some((new_name, _)) = selected {
245 renames_for_agent.insert(original_name.to_string(), new_name.to_string());
246 }
247 }
248 renames_for_agent
249}
250
251fn rewrite_content_for_agent(
252 content: &mut String,
253 renames: &IndexMap<String, String>,
254 label: &str,
255 source_path: &std::path::Path,
256 rewrite_content: ContentRewriteFn,
257 warnings: &mut Vec<String>,
258 changed: &mut bool,
259) {
260 if renames.is_empty() {
261 return;
262 }
263
264 match rewrite_content(content, renames) {
265 Ok(Some(new_content)) => {
266 *content = new_content;
267 *changed = true;
268 }
269 Ok(None) => {}
270 Err(e) => {
271 warnings.push(format!(
272 "warning: could not rewrite {label} refs in {}: {e}",
273 source_path.display()
274 ));
275 }
276 }
277}
278
279fn source_has_unrenamed_item(
280 target: &TargetState,
281 source_name: &SourceName,
282 kind: ItemKind,
283 name: &ItemName,
284) -> bool {
285 target.items.values().any(|item| {
286 item.source_name == *source_name && item.id.kind == kind && item.id.name == *name
287 })
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use crate::hash;
294 use crate::lock::{ItemId, ItemKind};
295 use crate::resolve::ResolvedGraph;
296 use crate::sync::target::{CollisionRename, ExplicitSkillRename, TargetItem, TargetState};
297 use crate::types::SourceId;
298 use indexmap::IndexMap;
299 use std::fs;
300 use tempfile::TempDir;
301
302 fn test_item(
303 kind: ItemKind,
304 name: &str,
305 source_name: &str,
306 source_path: std::path::PathBuf,
307 dest_path: &str,
308 ) -> TargetItem {
309 let source_hash = if kind == ItemKind::Skill {
310 hash::compute_hash(&source_path, kind).unwrap().into()
311 } else {
312 hash::hash_bytes(fs::read(&source_path).unwrap().as_slice()).into()
313 };
314
315 TargetItem {
316 id: ItemId {
317 kind,
318 name: name.into(),
319 },
320 source_name: source_name.into(),
321 source_path,
322 dest_path: dest_path.into(),
323 source_hash,
324 is_flat_skill: false,
325 rewritten_content: None,
326 }
327 }
328
329 fn graph_with_deps(
330 root: &std::path::Path,
331 source_name: &str,
332 deps: Vec<&str>,
333 ) -> ResolvedGraph {
334 let mut nodes = IndexMap::new();
335 nodes.insert(
336 SourceName::from(source_name),
337 crate::resolve::ResolvedNode {
338 source_name: source_name.into(),
339 source_id: SourceId::Path {
340 canonical: root.to_path_buf(),
341 subpath: None,
342 },
343 rooted_ref: crate::resolve::RootedSourceRef {
344 checkout_root: root.to_path_buf(),
345 package_root: root.to_path_buf(),
346 },
347 resolved_ref: crate::source::ResolvedRef {
348 source_name: source_name.into(),
349 version: None,
350 version_tag: None,
351 commit: None,
352 tree_path: root.to_path_buf(),
353 },
354 manifest: None,
355 deps: deps.into_iter().map(SourceName::from).collect(),
356 },
357 );
358 ResolvedGraph {
359 nodes,
360 order: vec![source_name.into()],
361 filters: std::collections::HashMap::new(),
362 version_constraints: std::collections::HashMap::new(),
363 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
364 }
365 }
366
367 fn apply_test_renames(
368 target: &mut TargetState,
369 explicit_skill_renames: &[ExplicitSkillRename],
370 collision_renames: &[CollisionRename],
371 graph: &ResolvedGraph,
372 dep_precedence: &[SourceName],
373 ) {
374 let index = RenameIndex::new(explicit_skill_renames, collision_renames, target);
375 apply_renames(target, &index, graph, dep_precedence).unwrap();
376 }
377
378 #[test]
379 fn apply_renames_uses_exact_skill_matches() {
380 let dir = TempDir::new().unwrap();
381 let agent_path = dir.path().join("agents/coder.md");
382 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
383 fs::write(
384 &agent_path,
385 "---\nskills:\n- plan\n- planner\n---\n# Agent\n",
386 )
387 .unwrap();
388
389 let skill_path = dir.path().join("skills/plan__org_base");
390 fs::create_dir_all(&skill_path).unwrap();
391 fs::write(skill_path.join("SKILL.md"), "# Planning").unwrap();
392
393 let mut items = IndexMap::new();
394 items.insert(
395 "agents/coder.md".into(),
396 TargetItem {
397 id: ItemId {
398 kind: ItemKind::Agent,
399 name: "coder".into(),
400 },
401 source_name: "source-a".into(),
402 source_path: agent_path.clone(),
403 dest_path: "agents/coder.md".into(),
404 source_hash: hash::hash_bytes(fs::read(&agent_path).unwrap().as_slice()).into(),
405 is_flat_skill: false,
406 rewritten_content: None,
407 },
408 );
409 items.insert(
410 "skills/plan__org_base".into(),
411 TargetItem {
412 id: ItemId {
413 kind: ItemKind::Skill,
414 name: "plan__org_base".into(),
415 },
416 source_name: "source-a".into(),
417 source_path: skill_path.clone(),
418 dest_path: "skills/plan__org_base".into(),
419 source_hash: hash::compute_hash(&skill_path, ItemKind::Skill)
420 .unwrap()
421 .into(),
422 is_flat_skill: false,
423 rewritten_content: None,
424 },
425 );
426
427 let mut target = TargetState { items };
428 let renames = vec![ExplicitSkillRename {
429 original_name: "plan".into(),
430 new_name: "plan__org_base".into(),
431 source_name: "source-a".into(),
432 }];
433 let graph = ResolvedGraph {
434 nodes: IndexMap::new(),
435 order: vec![],
436 filters: std::collections::HashMap::new(),
437 version_constraints: std::collections::HashMap::new(),
438 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
439 };
440
441 apply_test_renames(&mut target, &renames, &[], &graph, &[]);
442
443 let rewritten = target.items["agents/coder.md"]
444 .rewritten_content
445 .as_ref()
446 .unwrap();
447 let fm = crate::frontmatter::parse(rewritten).unwrap();
448 assert_eq!(fm.skills(), vec!["plan__org_base", "planner"]);
449 }
450
451 #[test]
452 fn apply_renames_leaves_non_matching_agents_unchanged() {
453 let dir = TempDir::new().unwrap();
454 let agent_path = dir.path().join("agents/coder.md");
455 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
456 fs::write(&agent_path, "---\nskills: [review]\n---\n# Agent\n").unwrap();
457
458 let mut items = IndexMap::new();
459 items.insert(
460 "agents/coder.md".into(),
461 TargetItem {
462 id: ItemId {
463 kind: ItemKind::Agent,
464 name: "coder".into(),
465 },
466 source_name: "source-a".into(),
467 source_path: agent_path.clone(),
468 dest_path: "agents/coder.md".into(),
469 source_hash: hash::hash_bytes(fs::read(&agent_path).unwrap().as_slice()).into(),
470 is_flat_skill: false,
471 rewritten_content: None,
472 },
473 );
474
475 let mut target = TargetState { items };
476 let renames = vec![ExplicitSkillRename {
477 original_name: "plan".into(),
478 new_name: "plan__org_base".into(),
479 source_name: "source-a".into(),
480 }];
481 let graph = ResolvedGraph {
482 nodes: IndexMap::new(),
483 order: vec![],
484 filters: std::collections::HashMap::new(),
485 version_constraints: std::collections::HashMap::new(),
486 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
487 };
488
489 apply_test_renames(&mut target, &renames, &[], &graph, &[]);
490 assert!(target.items["agents/coder.md"].rewritten_content.is_none());
491 }
492
493 #[test]
494 fn apply_renames_cross_package_uses_dep_graph() {
495 let dir = TempDir::new().unwrap();
496 let agent_path = dir.path().join("agents/coder.md");
497 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
498 fs::write(&agent_path, "---\nskills:\n- planning\n---\n# Agent\n").unwrap();
499
500 let skill_b_path = dir.path().join("skills/planning__org_b");
501 fs::create_dir_all(&skill_b_path).unwrap();
502 fs::write(skill_b_path.join("SKILL.md"), "# Planning from B").unwrap();
503
504 let skill_c_path = dir.path().join("skills/planning__org_c");
505 fs::create_dir_all(&skill_c_path).unwrap();
506 fs::write(skill_c_path.join("SKILL.md"), "# Planning from C").unwrap();
507
508 let mut items = IndexMap::new();
509 items.insert(
510 "agents/coder.md".into(),
511 TargetItem {
512 id: ItemId {
513 kind: ItemKind::Agent,
514 name: "coder".into(),
515 },
516 source_name: "source-a".into(),
517 source_path: agent_path.clone(),
518 dest_path: "agents/coder.md".into(),
519 source_hash: hash::hash_bytes(fs::read(&agent_path).unwrap().as_slice()).into(),
520 is_flat_skill: false,
521 rewritten_content: None,
522 },
523 );
524 items.insert(
525 "skills/planning__org_b".into(),
526 TargetItem {
527 id: ItemId {
528 kind: ItemKind::Skill,
529 name: "planning__org_b".into(),
530 },
531 source_name: "source-b".into(),
532 source_path: skill_b_path.clone(),
533 dest_path: "skills/planning__org_b".into(),
534 source_hash: hash::compute_hash(&skill_b_path, ItemKind::Skill)
535 .unwrap()
536 .into(),
537 is_flat_skill: false,
538 rewritten_content: None,
539 },
540 );
541 items.insert(
542 "skills/planning__org_c".into(),
543 TargetItem {
544 id: ItemId {
545 kind: ItemKind::Skill,
546 name: "planning__org_c".into(),
547 },
548 source_name: "source-c".into(),
549 source_path: skill_c_path.clone(),
550 dest_path: "skills/planning__org_c".into(),
551 source_hash: hash::compute_hash(&skill_c_path, ItemKind::Skill)
552 .unwrap()
553 .into(),
554 is_flat_skill: false,
555 rewritten_content: None,
556 },
557 );
558
559 let mut target = TargetState { items };
560 let renames = vec![
561 ExplicitSkillRename {
562 original_name: "planning".into(),
563 new_name: "planning__org_b".into(),
564 source_name: "source-b".into(),
565 },
566 ExplicitSkillRename {
567 original_name: "planning".into(),
568 new_name: "planning__org_c".into(),
569 source_name: "source-c".into(),
570 },
571 ];
572
573 let mut nodes = IndexMap::new();
574 nodes.insert(
575 SourceName::from("source-a"),
576 crate::resolve::ResolvedNode {
577 source_name: "source-a".into(),
578 source_id: SourceId::Path {
579 canonical: dir.path().to_path_buf(),
580 subpath: None,
581 },
582 rooted_ref: crate::resolve::RootedSourceRef {
583 checkout_root: dir.path().to_path_buf(),
584 package_root: dir.path().to_path_buf(),
585 },
586 resolved_ref: crate::source::ResolvedRef {
587 source_name: "source-a".into(),
588 version: None,
589 version_tag: None,
590 commit: None,
591 tree_path: dir.path().to_path_buf(),
592 },
593 manifest: None,
594 deps: vec!["source-b".into()],
595 },
596 );
597 let graph = ResolvedGraph {
598 nodes,
599 order: vec!["source-a".into()],
600 filters: std::collections::HashMap::new(),
601 version_constraints: std::collections::HashMap::new(),
602 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
603 };
604
605 apply_test_renames(&mut target, &renames, &[], &graph, &[]);
606
607 let rewritten = target.items["agents/coder.md"]
608 .rewritten_content
609 .as_ref()
610 .expect("agent should have been rewritten");
611 let fm = crate::frontmatter::parse(rewritten).unwrap();
612 assert_eq!(fm.skills(), vec!["planning__org_b"]);
613 }
614
615 #[test]
616 fn collision_rewrites_subagent_refs() {
617 let dir = TempDir::new().unwrap();
618 let source_a_agents = dir.path().join("source-a/agents");
619 let source_b_agents = dir.path().join("source-b/agents");
620 fs::create_dir_all(&source_a_agents).unwrap();
621 fs::create_dir_all(&source_b_agents).unwrap();
622 let orchestrator_path = source_a_agents.join("orchestrator.md");
623 let web_a_path = source_a_agents.join("web-researcher.md");
624 let web_b_path = source_b_agents.join("web-researcher.md");
625 fs::write(
626 &orchestrator_path,
627 "---\nsubagents:\n- web-researcher\n---\n# Orchestrator\n",
628 )
629 .unwrap();
630 fs::write(&web_a_path, "# Web A").unwrap();
631 fs::write(&web_b_path, "# Web B").unwrap();
632
633 let mut items = IndexMap::new();
634 items.insert(
635 "agents/orchestrator.md".into(),
636 test_item(
637 ItemKind::Agent,
638 "orchestrator",
639 "source-a",
640 orchestrator_path.clone(),
641 "agents/orchestrator.md",
642 ),
643 );
644 items.insert(
645 "agents/web-researcher__source-a.md".into(),
646 test_item(
647 ItemKind::Agent,
648 "web-researcher__source-a",
649 "source-a",
650 web_a_path,
651 "agents/web-researcher__source-a.md",
652 ),
653 );
654 items.insert(
655 "agents/web-researcher__source-b.md".into(),
656 test_item(
657 ItemKind::Agent,
658 "web-researcher__source-b",
659 "source-b",
660 web_b_path,
661 "agents/web-researcher__source-b.md",
662 ),
663 );
664
665 let mut target = TargetState { items };
666 let renames = vec![
667 CollisionRename {
668 original_name: "web-researcher".into(),
669 new_name: "web-researcher__source-a".into(),
670 source_name: "source-a".into(),
671 kind: ItemKind::Agent,
672 },
673 CollisionRename {
674 original_name: "web-researcher".into(),
675 new_name: "web-researcher__source-b".into(),
676 source_name: "source-b".into(),
677 kind: ItemKind::Agent,
678 },
679 ];
680 let graph = ResolvedGraph {
681 nodes: IndexMap::new(),
682 order: vec![],
683 filters: std::collections::HashMap::new(),
684 version_constraints: std::collections::HashMap::new(),
685 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
686 };
687
688 apply_test_renames(&mut target, &[], &renames, &graph, &[]);
689
690 let rewritten = target.items["agents/orchestrator.md"]
691 .rewritten_content
692 .as_ref()
693 .expect("agent should have been rewritten");
694 let fm = crate::frontmatter::parse(rewritten).unwrap();
695 let subagents = match fm.get("subagents").unwrap() {
696 serde_yaml::Value::Sequence(seq) => seq
697 .iter()
698 .filter_map(serde_yaml::Value::as_str)
699 .collect::<Vec<_>>(),
700 value => panic!("expected subagents sequence, got {value:?}"),
701 };
702 assert_eq!(subagents, vec!["web-researcher__source-a"]);
703 }
704
705 #[test]
706 fn collision_rewrites_skill_refs() {
707 let dir = TempDir::new().unwrap();
708 let agent_path = dir.path().join("source-a/agents/coder.md");
709 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
710 fs::write(&agent_path, "---\nskills: [planning]\n---\n# Agent\n").unwrap();
711
712 let skill_a_path = dir.path().join("source-a/skills/planning");
713 let skill_b_path = dir.path().join("source-b/skills/planning");
714 fs::create_dir_all(&skill_a_path).unwrap();
715 fs::create_dir_all(&skill_b_path).unwrap();
716 fs::write(skill_a_path.join("SKILL.md"), "# Planning A").unwrap();
717 fs::write(skill_b_path.join("SKILL.md"), "# Planning B").unwrap();
718
719 let mut items = IndexMap::new();
720 items.insert(
721 "agents/coder.md".into(),
722 test_item(
723 ItemKind::Agent,
724 "coder",
725 "source-a",
726 agent_path.clone(),
727 "agents/coder.md",
728 ),
729 );
730 items.insert(
731 "skills/planning__source-a".into(),
732 test_item(
733 ItemKind::Skill,
734 "planning__source-a",
735 "source-a",
736 skill_a_path,
737 "skills/planning__source-a",
738 ),
739 );
740 items.insert(
741 "skills/planning__source-b".into(),
742 test_item(
743 ItemKind::Skill,
744 "planning__source-b",
745 "source-b",
746 skill_b_path,
747 "skills/planning__source-b",
748 ),
749 );
750
751 let mut target = TargetState { items };
752 let renames = vec![
753 CollisionRename {
754 original_name: "planning".into(),
755 new_name: "planning__source-a".into(),
756 source_name: "source-a".into(),
757 kind: ItemKind::Skill,
758 },
759 CollisionRename {
760 original_name: "planning".into(),
761 new_name: "planning__source-b".into(),
762 source_name: "source-b".into(),
763 kind: ItemKind::Skill,
764 },
765 ];
766 let graph = ResolvedGraph {
767 nodes: IndexMap::new(),
768 order: vec![],
769 filters: std::collections::HashMap::new(),
770 version_constraints: std::collections::HashMap::new(),
771 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
772 };
773
774 apply_test_renames(&mut target, &[], &renames, &graph, &[]);
775
776 let rewritten = target.items["agents/coder.md"]
777 .rewritten_content
778 .as_ref()
779 .expect("agent should have been rewritten");
780 let fm = crate::frontmatter::parse(rewritten).unwrap();
781 assert_eq!(fm.skills(), vec!["planning__source-a"]);
782 }
783
784 #[test]
785 fn explicit_skill_rename_composes_with_collision_rename() {
786 let dir = TempDir::new().unwrap();
787 let agent_path = dir.path().join("source-a/agents/coder.md");
788 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
789 fs::write(&agent_path, "---\nskills: [planning]\n---\n# Agent\n").unwrap();
790
791 let skill_a_path = dir.path().join("source-a/skills/planning");
792 let skill_b_path = dir.path().join("source-b/skills/other");
793 fs::create_dir_all(&skill_a_path).unwrap();
794 fs::create_dir_all(&skill_b_path).unwrap();
795 fs::write(skill_a_path.join("SKILL.md"), "# Planning A").unwrap();
796 fs::write(skill_b_path.join("SKILL.md"), "# Planning B").unwrap();
797
798 let mut items = IndexMap::new();
799 items.insert(
800 "agents/coder.md".into(),
801 test_item(
802 ItemKind::Agent,
803 "coder",
804 "source-a",
805 agent_path,
806 "agents/coder.md",
807 ),
808 );
809 items.insert(
810 "skills/shared__source-a".into(),
811 test_item(
812 ItemKind::Skill,
813 "shared__source-a",
814 "source-a",
815 skill_a_path,
816 "skills/shared__source-a",
817 ),
818 );
819 items.insert(
820 "skills/shared__source-b".into(),
821 test_item(
822 ItemKind::Skill,
823 "shared__source-b",
824 "source-b",
825 skill_b_path,
826 "skills/shared__source-b",
827 ),
828 );
829
830 let mut target = TargetState { items };
831 let explicit_renames = vec![ExplicitSkillRename {
832 original_name: "planning".into(),
833 new_name: "shared".into(),
834 source_name: "source-a".into(),
835 }];
836 let collision_renames = vec![
837 CollisionRename {
838 original_name: "shared".into(),
839 new_name: "shared__source-a".into(),
840 source_name: "source-a".into(),
841 kind: ItemKind::Skill,
842 },
843 CollisionRename {
844 original_name: "shared".into(),
845 new_name: "shared__source-b".into(),
846 source_name: "source-b".into(),
847 kind: ItemKind::Skill,
848 },
849 ];
850 let graph = ResolvedGraph {
851 nodes: IndexMap::new(),
852 order: vec![],
853 filters: std::collections::HashMap::new(),
854 version_constraints: std::collections::HashMap::new(),
855 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
856 };
857
858 apply_test_renames(
859 &mut target,
860 &explicit_renames,
861 &collision_renames,
862 &graph,
863 &[],
864 );
865
866 let rewritten = target.items["agents/coder.md"]
867 .rewritten_content
868 .as_ref()
869 .expect("agent should have been rewritten");
870 let fm = crate::frontmatter::parse(rewritten).unwrap();
871 assert_eq!(fm.skills(), vec!["shared__source-a"]);
872 }
873
874 #[test]
875 fn collision_rewrites_local_agent_refs_to_dependency() {
876 let dir = TempDir::new().unwrap();
877 let agent_path = dir.path().join("project/.mars-src/agents/coder.md");
878 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
879 fs::write(&agent_path, "---\nskills: [planning]\n---\n# Local Agent\n").unwrap();
880
881 let skill_a_path = dir.path().join("source-a/skills/planning");
882 let skill_b_path = dir.path().join("source-b/skills/planning");
883 fs::create_dir_all(&skill_a_path).unwrap();
884 fs::create_dir_all(&skill_b_path).unwrap();
885 fs::write(skill_a_path.join("SKILL.md"), "# Planning A").unwrap();
886 fs::write(skill_b_path.join("SKILL.md"), "# Planning B").unwrap();
887
888 let mut items = IndexMap::new();
889 items.insert(
890 "agents/coder.md".into(),
891 test_item(
892 ItemKind::Agent,
893 "coder",
894 "_self",
895 agent_path,
896 "agents/coder.md",
897 ),
898 );
899 items.insert(
900 "skills/planning__source-a".into(),
901 test_item(
902 ItemKind::Skill,
903 "planning__source-a",
904 "source-a",
905 skill_a_path,
906 "skills/planning__source-a",
907 ),
908 );
909 items.insert(
910 "skills/planning__source-b".into(),
911 test_item(
912 ItemKind::Skill,
913 "planning__source-b",
914 "source-b",
915 skill_b_path,
916 "skills/planning__source-b",
917 ),
918 );
919
920 let mut target = TargetState { items };
921 let renames = vec![
922 CollisionRename {
923 original_name: "planning".into(),
924 new_name: "planning__source-a".into(),
925 source_name: "source-a".into(),
926 kind: ItemKind::Skill,
927 },
928 CollisionRename {
929 original_name: "planning".into(),
930 new_name: "planning__source-b".into(),
931 source_name: "source-b".into(),
932 kind: ItemKind::Skill,
933 },
934 ];
935 let graph = ResolvedGraph {
936 nodes: IndexMap::new(),
937 order: vec!["source-a".into(), "source-b".into()],
938 filters: std::collections::HashMap::new(),
939 version_constraints: std::collections::HashMap::new(),
940 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
941 };
942
943 apply_test_renames(
944 &mut target,
945 &[],
946 &renames,
947 &graph,
948 &["source-a".into(), "source-b".into()],
949 );
950
951 let rewritten = target.items["agents/coder.md"]
952 .rewritten_content
953 .as_ref()
954 .expect("local agent should have been rewritten");
955 let fm = crate::frontmatter::parse(rewritten).unwrap();
956 assert_eq!(fm.skills(), vec!["planning__source-a"]);
957 }
958
959 #[test]
960 fn local_agent_uses_config_dependency_order_not_graph_order() {
961 let dir = TempDir::new().unwrap();
962 let agent_path = dir.path().join("project/.mars-src/agents/coder.md");
963 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
964 fs::write(&agent_path, "---\nskills: [planning]\n---\n# Local Agent\n").unwrap();
965
966 let skill_a_path = dir.path().join("source-a/skills/planning");
967 let skill_b_path = dir.path().join("source-b/skills/planning");
968 fs::create_dir_all(&skill_a_path).unwrap();
969 fs::create_dir_all(&skill_b_path).unwrap();
970 fs::write(skill_a_path.join("SKILL.md"), "# Planning A").unwrap();
971 fs::write(skill_b_path.join("SKILL.md"), "# Planning B").unwrap();
972
973 let mut items = IndexMap::new();
974 items.insert(
975 "agents/coder.md".into(),
976 test_item(
977 ItemKind::Agent,
978 "coder",
979 "_self",
980 agent_path,
981 "agents/coder.md",
982 ),
983 );
984 items.insert(
985 "skills/planning__source-a".into(),
986 test_item(
987 ItemKind::Skill,
988 "planning__source-a",
989 "source-a",
990 skill_a_path,
991 "skills/planning__source-a",
992 ),
993 );
994 items.insert(
995 "skills/planning__source-b".into(),
996 test_item(
997 ItemKind::Skill,
998 "planning__source-b",
999 "source-b",
1000 skill_b_path,
1001 "skills/planning__source-b",
1002 ),
1003 );
1004
1005 let mut target = TargetState { items };
1006 let renames = vec![
1007 CollisionRename {
1008 original_name: "planning".into(),
1009 new_name: "planning__source-a".into(),
1010 source_name: "source-a".into(),
1011 kind: ItemKind::Skill,
1012 },
1013 CollisionRename {
1014 original_name: "planning".into(),
1015 new_name: "planning__source-b".into(),
1016 source_name: "source-b".into(),
1017 kind: ItemKind::Skill,
1018 },
1019 ];
1020 let graph = ResolvedGraph {
1021 nodes: IndexMap::new(),
1022 order: vec!["source-a".into(), "source-b".into()],
1023 filters: std::collections::HashMap::new(),
1024 version_constraints: std::collections::HashMap::new(),
1025 unreadable_hook_surfaces: std::collections::BTreeMap::new(),
1026 };
1027
1028 apply_test_renames(
1029 &mut target,
1030 &[],
1031 &renames,
1032 &graph,
1033 &["source-b".into(), "source-a".into()],
1034 );
1035
1036 let rewritten = target.items["agents/coder.md"]
1037 .rewritten_content
1038 .as_ref()
1039 .expect("local agent should have been rewritten");
1040 let fm = crate::frontmatter::parse(rewritten).unwrap();
1041 assert_eq!(fm.skills(), vec!["planning__source-b"]);
1042 }
1043
1044 #[test]
1045 fn dependency_agent_uses_config_dependency_order_for_renamed_refs() {
1046 let dir = TempDir::new().unwrap();
1047 let agent_path = dir.path().join("source-a/agents/coder.md");
1048 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
1049 fs::write(&agent_path, "---\nskills: [planning]\n---\n# Agent\n").unwrap();
1050
1051 let skill_b_path = dir.path().join("source-b/skills/planning");
1052 let skill_c_path = dir.path().join("source-c/skills/planning");
1053 fs::create_dir_all(&skill_b_path).unwrap();
1054 fs::create_dir_all(&skill_c_path).unwrap();
1055 fs::write(skill_b_path.join("SKILL.md"), "# Planning B").unwrap();
1056 fs::write(skill_c_path.join("SKILL.md"), "# Planning C").unwrap();
1057
1058 let mut items = IndexMap::new();
1059 items.insert(
1060 "agents/coder.md".into(),
1061 test_item(
1062 ItemKind::Agent,
1063 "coder",
1064 "source-a",
1065 agent_path,
1066 "agents/coder.md",
1067 ),
1068 );
1069 items.insert(
1070 "skills/planning__source-b".into(),
1071 test_item(
1072 ItemKind::Skill,
1073 "planning__source-b",
1074 "source-b",
1075 skill_b_path,
1076 "skills/planning__source-b",
1077 ),
1078 );
1079 items.insert(
1080 "skills/planning__source-c".into(),
1081 test_item(
1082 ItemKind::Skill,
1083 "planning__source-c",
1084 "source-c",
1085 skill_c_path,
1086 "skills/planning__source-c",
1087 ),
1088 );
1089
1090 let mut target = TargetState { items };
1091 let renames = vec![
1092 CollisionRename {
1093 original_name: "planning".into(),
1094 new_name: "planning__source-b".into(),
1095 source_name: "source-b".into(),
1096 kind: ItemKind::Skill,
1097 },
1098 CollisionRename {
1099 original_name: "planning".into(),
1100 new_name: "planning__source-c".into(),
1101 source_name: "source-c".into(),
1102 kind: ItemKind::Skill,
1103 },
1104 ];
1105 let graph = graph_with_deps(dir.path(), "source-a", vec!["source-b", "source-c"]);
1106
1107 apply_test_renames(
1108 &mut target,
1109 &[],
1110 &renames,
1111 &graph,
1112 &["source-c".into(), "source-b".into()],
1113 );
1114
1115 let rewritten = target.items["agents/coder.md"]
1116 .rewritten_content
1117 .as_ref()
1118 .expect("dependency agent should have been rewritten");
1119 let fm = crate::frontmatter::parse(rewritten).unwrap();
1120 assert_eq!(fm.skills(), vec!["planning__source-c"]);
1121 }
1122
1123 #[test]
1124 fn pruned_same_source_unrenamed_item_allows_dependency_rewrite() {
1125 let dir = TempDir::new().unwrap();
1126 let agent_path = dir.path().join("source-a/agents/coder.md");
1127 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
1128 fs::write(&agent_path, "---\nskills: [planning]\n---\n# Agent\n").unwrap();
1129
1130 let skill_b_path = dir.path().join("source-b/skills/planning");
1131 fs::create_dir_all(&skill_b_path).unwrap();
1132 fs::write(skill_b_path.join("SKILL.md"), "# Planning B").unwrap();
1133
1134 let mut items = IndexMap::new();
1135 items.insert(
1136 "agents/coder.md".into(),
1137 test_item(
1138 ItemKind::Agent,
1139 "coder",
1140 "source-a",
1141 agent_path,
1142 "agents/coder.md",
1143 ),
1144 );
1145 items.insert(
1146 "skills/planning__source-b".into(),
1147 test_item(
1148 ItemKind::Skill,
1149 "planning__source-b",
1150 "source-b",
1151 skill_b_path,
1152 "skills/planning__source-b",
1153 ),
1154 );
1155
1156 let mut target = TargetState { items };
1157 let renames = vec![CollisionRename {
1158 original_name: "planning".into(),
1159 new_name: "planning__source-b".into(),
1160 source_name: "source-b".into(),
1161 kind: ItemKind::Skill,
1162 }];
1163 let graph = graph_with_deps(dir.path(), "source-a", vec!["source-b"]);
1164
1165 apply_test_renames(&mut target, &[], &renames, &graph, &["source-b".into()]);
1166
1167 let rewritten = target.items["agents/coder.md"]
1168 .rewritten_content
1169 .as_ref()
1170 .expect("agent should have been rewritten after same-source item was pruned");
1171 let fm = crate::frontmatter::parse(rewritten).unwrap();
1172 assert_eq!(fm.skills(), vec!["planning__source-b"]);
1173 }
1174
1175 #[test]
1176 fn collision_does_not_retarget_existing_same_source_ref_to_dep() {
1177 let dir = TempDir::new().unwrap();
1178 let agent_path = dir.path().join("source-a/agents/coder.md");
1179 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
1180 fs::write(&agent_path, "---\nskills: [planning]\n---\n# Agent\n").unwrap();
1181
1182 let source_a_skill_path = dir.path().join("source-a/skills/planning");
1183 let source_b_skill_path = dir.path().join("source-b/skills/planning");
1184 let source_c_skill_path = dir.path().join("source-c/skills/planning");
1185 fs::create_dir_all(&source_a_skill_path).unwrap();
1186 fs::create_dir_all(&source_b_skill_path).unwrap();
1187 fs::create_dir_all(&source_c_skill_path).unwrap();
1188 fs::write(source_a_skill_path.join("SKILL.md"), "# Planning A").unwrap();
1189 fs::write(source_b_skill_path.join("SKILL.md"), "# Planning B").unwrap();
1190 fs::write(source_c_skill_path.join("SKILL.md"), "# Planning C").unwrap();
1191
1192 let mut items = IndexMap::new();
1193 items.insert(
1194 "agents/coder.md".into(),
1195 test_item(
1196 ItemKind::Agent,
1197 "coder",
1198 "source-a",
1199 agent_path,
1200 "agents/coder.md",
1201 ),
1202 );
1203 items.insert(
1204 "skills/planning".into(),
1205 test_item(
1206 ItemKind::Skill,
1207 "planning",
1208 "source-a",
1209 source_a_skill_path,
1210 "skills/planning",
1211 ),
1212 );
1213 items.insert(
1214 "skills/planning__source-b".into(),
1215 test_item(
1216 ItemKind::Skill,
1217 "planning__source-b",
1218 "source-b",
1219 source_b_skill_path,
1220 "skills/planning__source-b",
1221 ),
1222 );
1223 items.insert(
1224 "skills/planning__source-c".into(),
1225 test_item(
1226 ItemKind::Skill,
1227 "planning__source-c",
1228 "source-c",
1229 source_c_skill_path,
1230 "skills/planning__source-c",
1231 ),
1232 );
1233
1234 let mut target = TargetState { items };
1235 let renames = vec![
1236 CollisionRename {
1237 original_name: "planning".into(),
1238 new_name: "planning__source-b".into(),
1239 source_name: "source-b".into(),
1240 kind: ItemKind::Skill,
1241 },
1242 CollisionRename {
1243 original_name: "planning".into(),
1244 new_name: "planning__source-c".into(),
1245 source_name: "source-c".into(),
1246 kind: ItemKind::Skill,
1247 },
1248 ];
1249 let graph = graph_with_deps(dir.path(), "source-a", vec!["source-b"]);
1250
1251 apply_test_renames(&mut target, &[], &renames, &graph, &[]);
1252
1253 assert!(target.items["agents/coder.md"].rewritten_content.is_none());
1254 }
1255}