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 origin: crate::types::SourceOrigin::Dependency(source_name.into()),
322 source_id: SourceId::Path {
323 canonical: source_path.clone(),
324 subpath: None,
325 },
326 source_path,
327 dest_path: dest_path.into(),
328 source_hash,
329 is_flat_skill: false,
330 rewritten_content: None,
331 }
332 }
333
334 fn graph_with_deps(
335 root: &std::path::Path,
336 source_name: &str,
337 deps: Vec<&str>,
338 ) -> ResolvedGraph {
339 let mut nodes = IndexMap::new();
340 nodes.insert(
341 SourceName::from(source_name),
342 crate::resolve::ResolvedNode {
343 source_name: source_name.into(),
344 source_id: SourceId::Path {
345 canonical: root.to_path_buf(),
346 subpath: None,
347 },
348 rooted_ref: crate::resolve::RootedSourceRef {
349 checkout_root: root.to_path_buf(),
350 package_root: root.to_path_buf(),
351 },
352 resolved_ref: crate::source::ResolvedRef {
353 source_name: source_name.into(),
354 version: None,
355 version_tag: None,
356 commit: None,
357 tree_path: root.to_path_buf(),
358 },
359 latest_version: None,
360 manifest: None,
361 deps: deps.into_iter().map(SourceName::from).collect(),
362 },
363 );
364 ResolvedGraph {
365 nodes,
366 order: vec![source_name.into()],
367 filters: std::collections::HashMap::new(),
368 version_constraints: std::collections::HashMap::new(),
369 }
370 }
371
372 fn apply_test_renames(
373 target: &mut TargetState,
374 explicit_skill_renames: &[ExplicitSkillRename],
375 collision_renames: &[CollisionRename],
376 graph: &ResolvedGraph,
377 dep_precedence: &[SourceName],
378 ) {
379 let index = RenameIndex::new(explicit_skill_renames, collision_renames, target);
380 apply_renames(target, &index, graph, dep_precedence).unwrap();
381 }
382
383 #[test]
384 fn apply_renames_uses_exact_skill_matches() {
385 let dir = TempDir::new().unwrap();
386 let agent_path = dir.path().join("agents/coder.md");
387 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
388 fs::write(
389 &agent_path,
390 "---\nskills:\n- plan\n- planner\n---\n# Agent\n",
391 )
392 .unwrap();
393
394 let skill_path = dir.path().join("skills/plan__org_base");
395 fs::create_dir_all(&skill_path).unwrap();
396 fs::write(skill_path.join("SKILL.md"), "# Planning").unwrap();
397
398 let mut items = IndexMap::new();
399 items.insert(
400 "agents/coder.md".into(),
401 TargetItem {
402 id: ItemId {
403 kind: ItemKind::Agent,
404 name: "coder".into(),
405 },
406 source_name: "source-a".into(),
407 origin: crate::types::SourceOrigin::Dependency("source-a".into()),
408 source_id: SourceId::Path {
409 canonical: agent_path.clone(),
410 subpath: None,
411 },
412 source_path: agent_path.clone(),
413 dest_path: "agents/coder.md".into(),
414 source_hash: hash::hash_bytes(fs::read(&agent_path).unwrap().as_slice()).into(),
415 is_flat_skill: false,
416 rewritten_content: None,
417 },
418 );
419 items.insert(
420 "skills/plan__org_base".into(),
421 TargetItem {
422 id: ItemId {
423 kind: ItemKind::Skill,
424 name: "plan__org_base".into(),
425 },
426 source_name: "source-a".into(),
427 origin: crate::types::SourceOrigin::Dependency("source-a".into()),
428 source_id: SourceId::Path {
429 canonical: skill_path.clone(),
430 subpath: None,
431 },
432 source_path: skill_path.clone(),
433 dest_path: "skills/plan__org_base".into(),
434 source_hash: hash::compute_hash(&skill_path, ItemKind::Skill)
435 .unwrap()
436 .into(),
437 is_flat_skill: false,
438 rewritten_content: None,
439 },
440 );
441
442 let mut target = TargetState { items };
443 let renames = vec![ExplicitSkillRename {
444 original_name: "plan".into(),
445 new_name: "plan__org_base".into(),
446 source_name: "source-a".into(),
447 }];
448 let graph = ResolvedGraph {
449 nodes: IndexMap::new(),
450 order: vec![],
451 filters: std::collections::HashMap::new(),
452 version_constraints: std::collections::HashMap::new(),
453 };
454
455 apply_test_renames(&mut target, &renames, &[], &graph, &[]);
456
457 let rewritten = target.items["agents/coder.md"]
458 .rewritten_content
459 .as_ref()
460 .unwrap();
461 let fm = crate::frontmatter::parse(rewritten).unwrap();
462 assert_eq!(fm.skills(), vec!["plan__org_base", "planner"]);
463 }
464
465 #[test]
466 fn apply_renames_leaves_non_matching_agents_unchanged() {
467 let dir = TempDir::new().unwrap();
468 let agent_path = dir.path().join("agents/coder.md");
469 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
470 fs::write(&agent_path, "---\nskills: [review]\n---\n# Agent\n").unwrap();
471
472 let mut items = IndexMap::new();
473 items.insert(
474 "agents/coder.md".into(),
475 TargetItem {
476 id: ItemId {
477 kind: ItemKind::Agent,
478 name: "coder".into(),
479 },
480 source_name: "source-a".into(),
481 origin: crate::types::SourceOrigin::Dependency("source-a".into()),
482 source_id: SourceId::Path {
483 canonical: agent_path.clone(),
484 subpath: None,
485 },
486 source_path: agent_path.clone(),
487 dest_path: "agents/coder.md".into(),
488 source_hash: hash::hash_bytes(fs::read(&agent_path).unwrap().as_slice()).into(),
489 is_flat_skill: false,
490 rewritten_content: None,
491 },
492 );
493
494 let mut target = TargetState { items };
495 let renames = vec![ExplicitSkillRename {
496 original_name: "plan".into(),
497 new_name: "plan__org_base".into(),
498 source_name: "source-a".into(),
499 }];
500 let graph = ResolvedGraph {
501 nodes: IndexMap::new(),
502 order: vec![],
503 filters: std::collections::HashMap::new(),
504 version_constraints: std::collections::HashMap::new(),
505 };
506
507 apply_test_renames(&mut target, &renames, &[], &graph, &[]);
508 assert!(target.items["agents/coder.md"].rewritten_content.is_none());
509 }
510
511 #[test]
512 fn apply_renames_cross_package_uses_dep_graph() {
513 let dir = TempDir::new().unwrap();
514 let agent_path = dir.path().join("agents/coder.md");
515 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
516 fs::write(&agent_path, "---\nskills:\n- planning\n---\n# Agent\n").unwrap();
517
518 let skill_b_path = dir.path().join("skills/planning__org_b");
519 fs::create_dir_all(&skill_b_path).unwrap();
520 fs::write(skill_b_path.join("SKILL.md"), "# Planning from B").unwrap();
521
522 let skill_c_path = dir.path().join("skills/planning__org_c");
523 fs::create_dir_all(&skill_c_path).unwrap();
524 fs::write(skill_c_path.join("SKILL.md"), "# Planning from C").unwrap();
525
526 let mut items = IndexMap::new();
527 items.insert(
528 "agents/coder.md".into(),
529 TargetItem {
530 id: ItemId {
531 kind: ItemKind::Agent,
532 name: "coder".into(),
533 },
534 source_name: "source-a".into(),
535 origin: crate::types::SourceOrigin::Dependency("source-a".into()),
536 source_id: SourceId::Path {
537 canonical: agent_path.clone(),
538 subpath: None,
539 },
540 source_path: agent_path.clone(),
541 dest_path: "agents/coder.md".into(),
542 source_hash: hash::hash_bytes(fs::read(&agent_path).unwrap().as_slice()).into(),
543 is_flat_skill: false,
544 rewritten_content: None,
545 },
546 );
547 items.insert(
548 "skills/planning__org_b".into(),
549 TargetItem {
550 id: ItemId {
551 kind: ItemKind::Skill,
552 name: "planning__org_b".into(),
553 },
554 source_name: "source-b".into(),
555 origin: crate::types::SourceOrigin::Dependency("source-b".into()),
556 source_id: SourceId::Path {
557 canonical: skill_b_path.clone(),
558 subpath: None,
559 },
560 source_path: skill_b_path.clone(),
561 dest_path: "skills/planning__org_b".into(),
562 source_hash: hash::compute_hash(&skill_b_path, ItemKind::Skill)
563 .unwrap()
564 .into(),
565 is_flat_skill: false,
566 rewritten_content: None,
567 },
568 );
569 items.insert(
570 "skills/planning__org_c".into(),
571 TargetItem {
572 id: ItemId {
573 kind: ItemKind::Skill,
574 name: "planning__org_c".into(),
575 },
576 source_name: "source-c".into(),
577 origin: crate::types::SourceOrigin::Dependency("source-c".into()),
578 source_id: SourceId::Path {
579 canonical: skill_c_path.clone(),
580 subpath: None,
581 },
582 source_path: skill_c_path.clone(),
583 dest_path: "skills/planning__org_c".into(),
584 source_hash: hash::compute_hash(&skill_c_path, ItemKind::Skill)
585 .unwrap()
586 .into(),
587 is_flat_skill: false,
588 rewritten_content: None,
589 },
590 );
591
592 let mut target = TargetState { items };
593 let renames = vec![
594 ExplicitSkillRename {
595 original_name: "planning".into(),
596 new_name: "planning__org_b".into(),
597 source_name: "source-b".into(),
598 },
599 ExplicitSkillRename {
600 original_name: "planning".into(),
601 new_name: "planning__org_c".into(),
602 source_name: "source-c".into(),
603 },
604 ];
605
606 let mut nodes = IndexMap::new();
607 nodes.insert(
608 SourceName::from("source-a"),
609 crate::resolve::ResolvedNode {
610 source_name: "source-a".into(),
611 source_id: SourceId::Path {
612 canonical: dir.path().to_path_buf(),
613 subpath: None,
614 },
615 rooted_ref: crate::resolve::RootedSourceRef {
616 checkout_root: dir.path().to_path_buf(),
617 package_root: dir.path().to_path_buf(),
618 },
619 resolved_ref: crate::source::ResolvedRef {
620 source_name: "source-a".into(),
621 version: None,
622 version_tag: None,
623 commit: None,
624 tree_path: dir.path().to_path_buf(),
625 },
626 latest_version: None,
627 manifest: None,
628 deps: vec!["source-b".into()],
629 },
630 );
631 let graph = ResolvedGraph {
632 nodes,
633 order: vec!["source-a".into()],
634 filters: std::collections::HashMap::new(),
635 version_constraints: std::collections::HashMap::new(),
636 };
637
638 apply_test_renames(&mut target, &renames, &[], &graph, &[]);
639
640 let rewritten = target.items["agents/coder.md"]
641 .rewritten_content
642 .as_ref()
643 .expect("agent should have been rewritten");
644 let fm = crate::frontmatter::parse(rewritten).unwrap();
645 assert_eq!(fm.skills(), vec!["planning__org_b"]);
646 }
647
648 #[test]
649 fn collision_rewrites_subagent_refs() {
650 let dir = TempDir::new().unwrap();
651 let source_a_agents = dir.path().join("source-a/agents");
652 let source_b_agents = dir.path().join("source-b/agents");
653 fs::create_dir_all(&source_a_agents).unwrap();
654 fs::create_dir_all(&source_b_agents).unwrap();
655 let orchestrator_path = source_a_agents.join("orchestrator.md");
656 let web_a_path = source_a_agents.join("web-researcher.md");
657 let web_b_path = source_b_agents.join("web-researcher.md");
658 fs::write(
659 &orchestrator_path,
660 "---\nsubagents:\n- web-researcher\n---\n# Orchestrator\n",
661 )
662 .unwrap();
663 fs::write(&web_a_path, "# Web A").unwrap();
664 fs::write(&web_b_path, "# Web B").unwrap();
665
666 let mut items = IndexMap::new();
667 items.insert(
668 "agents/orchestrator.md".into(),
669 test_item(
670 ItemKind::Agent,
671 "orchestrator",
672 "source-a",
673 orchestrator_path.clone(),
674 "agents/orchestrator.md",
675 ),
676 );
677 items.insert(
678 "agents/web-researcher__source-a.md".into(),
679 test_item(
680 ItemKind::Agent,
681 "web-researcher__source-a",
682 "source-a",
683 web_a_path,
684 "agents/web-researcher__source-a.md",
685 ),
686 );
687 items.insert(
688 "agents/web-researcher__source-b.md".into(),
689 test_item(
690 ItemKind::Agent,
691 "web-researcher__source-b",
692 "source-b",
693 web_b_path,
694 "agents/web-researcher__source-b.md",
695 ),
696 );
697
698 let mut target = TargetState { items };
699 let renames = vec![
700 CollisionRename {
701 original_name: "web-researcher".into(),
702 new_name: "web-researcher__source-a".into(),
703 source_name: "source-a".into(),
704 kind: ItemKind::Agent,
705 },
706 CollisionRename {
707 original_name: "web-researcher".into(),
708 new_name: "web-researcher__source-b".into(),
709 source_name: "source-b".into(),
710 kind: ItemKind::Agent,
711 },
712 ];
713 let graph = ResolvedGraph {
714 nodes: IndexMap::new(),
715 order: vec![],
716 filters: std::collections::HashMap::new(),
717 version_constraints: std::collections::HashMap::new(),
718 };
719
720 apply_test_renames(&mut target, &[], &renames, &graph, &[]);
721
722 let rewritten = target.items["agents/orchestrator.md"]
723 .rewritten_content
724 .as_ref()
725 .expect("agent should have been rewritten");
726 let fm = crate::frontmatter::parse(rewritten).unwrap();
727 let subagents = match fm.get("subagents").unwrap() {
728 serde_yaml::Value::Sequence(seq) => seq
729 .iter()
730 .filter_map(serde_yaml::Value::as_str)
731 .collect::<Vec<_>>(),
732 value => panic!("expected subagents sequence, got {value:?}"),
733 };
734 assert_eq!(subagents, vec!["web-researcher__source-a"]);
735 }
736
737 #[test]
738 fn collision_rewrites_skill_refs() {
739 let dir = TempDir::new().unwrap();
740 let agent_path = dir.path().join("source-a/agents/coder.md");
741 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
742 fs::write(&agent_path, "---\nskills: [planning]\n---\n# Agent\n").unwrap();
743
744 let skill_a_path = dir.path().join("source-a/skills/planning");
745 let skill_b_path = dir.path().join("source-b/skills/planning");
746 fs::create_dir_all(&skill_a_path).unwrap();
747 fs::create_dir_all(&skill_b_path).unwrap();
748 fs::write(skill_a_path.join("SKILL.md"), "# Planning A").unwrap();
749 fs::write(skill_b_path.join("SKILL.md"), "# Planning B").unwrap();
750
751 let mut items = IndexMap::new();
752 items.insert(
753 "agents/coder.md".into(),
754 test_item(
755 ItemKind::Agent,
756 "coder",
757 "source-a",
758 agent_path.clone(),
759 "agents/coder.md",
760 ),
761 );
762 items.insert(
763 "skills/planning__source-a".into(),
764 test_item(
765 ItemKind::Skill,
766 "planning__source-a",
767 "source-a",
768 skill_a_path,
769 "skills/planning__source-a",
770 ),
771 );
772 items.insert(
773 "skills/planning__source-b".into(),
774 test_item(
775 ItemKind::Skill,
776 "planning__source-b",
777 "source-b",
778 skill_b_path,
779 "skills/planning__source-b",
780 ),
781 );
782
783 let mut target = TargetState { items };
784 let renames = vec![
785 CollisionRename {
786 original_name: "planning".into(),
787 new_name: "planning__source-a".into(),
788 source_name: "source-a".into(),
789 kind: ItemKind::Skill,
790 },
791 CollisionRename {
792 original_name: "planning".into(),
793 new_name: "planning__source-b".into(),
794 source_name: "source-b".into(),
795 kind: ItemKind::Skill,
796 },
797 ];
798 let graph = ResolvedGraph {
799 nodes: IndexMap::new(),
800 order: vec![],
801 filters: std::collections::HashMap::new(),
802 version_constraints: std::collections::HashMap::new(),
803 };
804
805 apply_test_renames(&mut target, &[], &renames, &graph, &[]);
806
807 let rewritten = target.items["agents/coder.md"]
808 .rewritten_content
809 .as_ref()
810 .expect("agent should have been rewritten");
811 let fm = crate::frontmatter::parse(rewritten).unwrap();
812 assert_eq!(fm.skills(), vec!["planning__source-a"]);
813 }
814
815 #[test]
816 fn explicit_skill_rename_composes_with_collision_rename() {
817 let dir = TempDir::new().unwrap();
818 let agent_path = dir.path().join("source-a/agents/coder.md");
819 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
820 fs::write(&agent_path, "---\nskills: [planning]\n---\n# Agent\n").unwrap();
821
822 let skill_a_path = dir.path().join("source-a/skills/planning");
823 let skill_b_path = dir.path().join("source-b/skills/other");
824 fs::create_dir_all(&skill_a_path).unwrap();
825 fs::create_dir_all(&skill_b_path).unwrap();
826 fs::write(skill_a_path.join("SKILL.md"), "# Planning A").unwrap();
827 fs::write(skill_b_path.join("SKILL.md"), "# Planning B").unwrap();
828
829 let mut items = IndexMap::new();
830 items.insert(
831 "agents/coder.md".into(),
832 test_item(
833 ItemKind::Agent,
834 "coder",
835 "source-a",
836 agent_path,
837 "agents/coder.md",
838 ),
839 );
840 items.insert(
841 "skills/shared__source-a".into(),
842 test_item(
843 ItemKind::Skill,
844 "shared__source-a",
845 "source-a",
846 skill_a_path,
847 "skills/shared__source-a",
848 ),
849 );
850 items.insert(
851 "skills/shared__source-b".into(),
852 test_item(
853 ItemKind::Skill,
854 "shared__source-b",
855 "source-b",
856 skill_b_path,
857 "skills/shared__source-b",
858 ),
859 );
860
861 let mut target = TargetState { items };
862 let explicit_renames = vec![ExplicitSkillRename {
863 original_name: "planning".into(),
864 new_name: "shared".into(),
865 source_name: "source-a".into(),
866 }];
867 let collision_renames = vec![
868 CollisionRename {
869 original_name: "shared".into(),
870 new_name: "shared__source-a".into(),
871 source_name: "source-a".into(),
872 kind: ItemKind::Skill,
873 },
874 CollisionRename {
875 original_name: "shared".into(),
876 new_name: "shared__source-b".into(),
877 source_name: "source-b".into(),
878 kind: ItemKind::Skill,
879 },
880 ];
881 let graph = ResolvedGraph {
882 nodes: IndexMap::new(),
883 order: vec![],
884 filters: std::collections::HashMap::new(),
885 version_constraints: std::collections::HashMap::new(),
886 };
887
888 apply_test_renames(
889 &mut target,
890 &explicit_renames,
891 &collision_renames,
892 &graph,
893 &[],
894 );
895
896 let rewritten = target.items["agents/coder.md"]
897 .rewritten_content
898 .as_ref()
899 .expect("agent should have been rewritten");
900 let fm = crate::frontmatter::parse(rewritten).unwrap();
901 assert_eq!(fm.skills(), vec!["shared__source-a"]);
902 }
903
904 #[test]
905 fn collision_rewrites_local_agent_refs_to_dependency() {
906 let dir = TempDir::new().unwrap();
907 let agent_path = dir.path().join("project/.mars-src/agents/coder.md");
908 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
909 fs::write(&agent_path, "---\nskills: [planning]\n---\n# Local Agent\n").unwrap();
910
911 let skill_a_path = dir.path().join("source-a/skills/planning");
912 let skill_b_path = dir.path().join("source-b/skills/planning");
913 fs::create_dir_all(&skill_a_path).unwrap();
914 fs::create_dir_all(&skill_b_path).unwrap();
915 fs::write(skill_a_path.join("SKILL.md"), "# Planning A").unwrap();
916 fs::write(skill_b_path.join("SKILL.md"), "# Planning B").unwrap();
917
918 let mut items = IndexMap::new();
919 items.insert(
920 "agents/coder.md".into(),
921 test_item(
922 ItemKind::Agent,
923 "coder",
924 "_self",
925 agent_path,
926 "agents/coder.md",
927 ),
928 );
929 items.insert(
930 "skills/planning__source-a".into(),
931 test_item(
932 ItemKind::Skill,
933 "planning__source-a",
934 "source-a",
935 skill_a_path,
936 "skills/planning__source-a",
937 ),
938 );
939 items.insert(
940 "skills/planning__source-b".into(),
941 test_item(
942 ItemKind::Skill,
943 "planning__source-b",
944 "source-b",
945 skill_b_path,
946 "skills/planning__source-b",
947 ),
948 );
949
950 let mut target = TargetState { items };
951 let renames = vec![
952 CollisionRename {
953 original_name: "planning".into(),
954 new_name: "planning__source-a".into(),
955 source_name: "source-a".into(),
956 kind: ItemKind::Skill,
957 },
958 CollisionRename {
959 original_name: "planning".into(),
960 new_name: "planning__source-b".into(),
961 source_name: "source-b".into(),
962 kind: ItemKind::Skill,
963 },
964 ];
965 let graph = ResolvedGraph {
966 nodes: IndexMap::new(),
967 order: vec!["source-a".into(), "source-b".into()],
968 filters: std::collections::HashMap::new(),
969 version_constraints: std::collections::HashMap::new(),
970 };
971
972 apply_test_renames(
973 &mut target,
974 &[],
975 &renames,
976 &graph,
977 &["source-a".into(), "source-b".into()],
978 );
979
980 let rewritten = target.items["agents/coder.md"]
981 .rewritten_content
982 .as_ref()
983 .expect("local agent should have been rewritten");
984 let fm = crate::frontmatter::parse(rewritten).unwrap();
985 assert_eq!(fm.skills(), vec!["planning__source-a"]);
986 }
987
988 #[test]
989 fn local_agent_uses_config_dependency_order_not_graph_order() {
990 let dir = TempDir::new().unwrap();
991 let agent_path = dir.path().join("project/.mars-src/agents/coder.md");
992 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
993 fs::write(&agent_path, "---\nskills: [planning]\n---\n# Local Agent\n").unwrap();
994
995 let skill_a_path = dir.path().join("source-a/skills/planning");
996 let skill_b_path = dir.path().join("source-b/skills/planning");
997 fs::create_dir_all(&skill_a_path).unwrap();
998 fs::create_dir_all(&skill_b_path).unwrap();
999 fs::write(skill_a_path.join("SKILL.md"), "# Planning A").unwrap();
1000 fs::write(skill_b_path.join("SKILL.md"), "# Planning B").unwrap();
1001
1002 let mut items = IndexMap::new();
1003 items.insert(
1004 "agents/coder.md".into(),
1005 test_item(
1006 ItemKind::Agent,
1007 "coder",
1008 "_self",
1009 agent_path,
1010 "agents/coder.md",
1011 ),
1012 );
1013 items.insert(
1014 "skills/planning__source-a".into(),
1015 test_item(
1016 ItemKind::Skill,
1017 "planning__source-a",
1018 "source-a",
1019 skill_a_path,
1020 "skills/planning__source-a",
1021 ),
1022 );
1023 items.insert(
1024 "skills/planning__source-b".into(),
1025 test_item(
1026 ItemKind::Skill,
1027 "planning__source-b",
1028 "source-b",
1029 skill_b_path,
1030 "skills/planning__source-b",
1031 ),
1032 );
1033
1034 let mut target = TargetState { items };
1035 let renames = vec![
1036 CollisionRename {
1037 original_name: "planning".into(),
1038 new_name: "planning__source-a".into(),
1039 source_name: "source-a".into(),
1040 kind: ItemKind::Skill,
1041 },
1042 CollisionRename {
1043 original_name: "planning".into(),
1044 new_name: "planning__source-b".into(),
1045 source_name: "source-b".into(),
1046 kind: ItemKind::Skill,
1047 },
1048 ];
1049 let graph = ResolvedGraph {
1050 nodes: IndexMap::new(),
1051 order: vec!["source-a".into(), "source-b".into()],
1052 filters: std::collections::HashMap::new(),
1053 version_constraints: std::collections::HashMap::new(),
1054 };
1055
1056 apply_test_renames(
1057 &mut target,
1058 &[],
1059 &renames,
1060 &graph,
1061 &["source-b".into(), "source-a".into()],
1062 );
1063
1064 let rewritten = target.items["agents/coder.md"]
1065 .rewritten_content
1066 .as_ref()
1067 .expect("local agent should have been rewritten");
1068 let fm = crate::frontmatter::parse(rewritten).unwrap();
1069 assert_eq!(fm.skills(), vec!["planning__source-b"]);
1070 }
1071
1072 #[test]
1073 fn dependency_agent_uses_config_dependency_order_for_renamed_refs() {
1074 let dir = TempDir::new().unwrap();
1075 let agent_path = dir.path().join("source-a/agents/coder.md");
1076 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
1077 fs::write(&agent_path, "---\nskills: [planning]\n---\n# Agent\n").unwrap();
1078
1079 let skill_b_path = dir.path().join("source-b/skills/planning");
1080 let skill_c_path = dir.path().join("source-c/skills/planning");
1081 fs::create_dir_all(&skill_b_path).unwrap();
1082 fs::create_dir_all(&skill_c_path).unwrap();
1083 fs::write(skill_b_path.join("SKILL.md"), "# Planning B").unwrap();
1084 fs::write(skill_c_path.join("SKILL.md"), "# Planning C").unwrap();
1085
1086 let mut items = IndexMap::new();
1087 items.insert(
1088 "agents/coder.md".into(),
1089 test_item(
1090 ItemKind::Agent,
1091 "coder",
1092 "source-a",
1093 agent_path,
1094 "agents/coder.md",
1095 ),
1096 );
1097 items.insert(
1098 "skills/planning__source-b".into(),
1099 test_item(
1100 ItemKind::Skill,
1101 "planning__source-b",
1102 "source-b",
1103 skill_b_path,
1104 "skills/planning__source-b",
1105 ),
1106 );
1107 items.insert(
1108 "skills/planning__source-c".into(),
1109 test_item(
1110 ItemKind::Skill,
1111 "planning__source-c",
1112 "source-c",
1113 skill_c_path,
1114 "skills/planning__source-c",
1115 ),
1116 );
1117
1118 let mut target = TargetState { items };
1119 let renames = vec![
1120 CollisionRename {
1121 original_name: "planning".into(),
1122 new_name: "planning__source-b".into(),
1123 source_name: "source-b".into(),
1124 kind: ItemKind::Skill,
1125 },
1126 CollisionRename {
1127 original_name: "planning".into(),
1128 new_name: "planning__source-c".into(),
1129 source_name: "source-c".into(),
1130 kind: ItemKind::Skill,
1131 },
1132 ];
1133 let graph = graph_with_deps(dir.path(), "source-a", vec!["source-b", "source-c"]);
1134
1135 apply_test_renames(
1136 &mut target,
1137 &[],
1138 &renames,
1139 &graph,
1140 &["source-c".into(), "source-b".into()],
1141 );
1142
1143 let rewritten = target.items["agents/coder.md"]
1144 .rewritten_content
1145 .as_ref()
1146 .expect("dependency agent should have been rewritten");
1147 let fm = crate::frontmatter::parse(rewritten).unwrap();
1148 assert_eq!(fm.skills(), vec!["planning__source-c"]);
1149 }
1150
1151 #[test]
1152 fn pruned_same_source_unrenamed_item_allows_dependency_rewrite() {
1153 let dir = TempDir::new().unwrap();
1154 let agent_path = dir.path().join("source-a/agents/coder.md");
1155 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
1156 fs::write(&agent_path, "---\nskills: [planning]\n---\n# Agent\n").unwrap();
1157
1158 let skill_b_path = dir.path().join("source-b/skills/planning");
1159 fs::create_dir_all(&skill_b_path).unwrap();
1160 fs::write(skill_b_path.join("SKILL.md"), "# Planning B").unwrap();
1161
1162 let mut items = IndexMap::new();
1163 items.insert(
1164 "agents/coder.md".into(),
1165 test_item(
1166 ItemKind::Agent,
1167 "coder",
1168 "source-a",
1169 agent_path,
1170 "agents/coder.md",
1171 ),
1172 );
1173 items.insert(
1174 "skills/planning__source-b".into(),
1175 test_item(
1176 ItemKind::Skill,
1177 "planning__source-b",
1178 "source-b",
1179 skill_b_path,
1180 "skills/planning__source-b",
1181 ),
1182 );
1183
1184 let mut target = TargetState { items };
1185 let renames = vec![CollisionRename {
1186 original_name: "planning".into(),
1187 new_name: "planning__source-b".into(),
1188 source_name: "source-b".into(),
1189 kind: ItemKind::Skill,
1190 }];
1191 let graph = graph_with_deps(dir.path(), "source-a", vec!["source-b"]);
1192
1193 apply_test_renames(&mut target, &[], &renames, &graph, &["source-b".into()]);
1194
1195 let rewritten = target.items["agents/coder.md"]
1196 .rewritten_content
1197 .as_ref()
1198 .expect("agent should have been rewritten after same-source item was pruned");
1199 let fm = crate::frontmatter::parse(rewritten).unwrap();
1200 assert_eq!(fm.skills(), vec!["planning__source-b"]);
1201 }
1202
1203 #[test]
1204 fn collision_does_not_retarget_existing_same_source_ref_to_dep() {
1205 let dir = TempDir::new().unwrap();
1206 let agent_path = dir.path().join("source-a/agents/coder.md");
1207 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
1208 fs::write(&agent_path, "---\nskills: [planning]\n---\n# Agent\n").unwrap();
1209
1210 let source_a_skill_path = dir.path().join("source-a/skills/planning");
1211 let source_b_skill_path = dir.path().join("source-b/skills/planning");
1212 let source_c_skill_path = dir.path().join("source-c/skills/planning");
1213 fs::create_dir_all(&source_a_skill_path).unwrap();
1214 fs::create_dir_all(&source_b_skill_path).unwrap();
1215 fs::create_dir_all(&source_c_skill_path).unwrap();
1216 fs::write(source_a_skill_path.join("SKILL.md"), "# Planning A").unwrap();
1217 fs::write(source_b_skill_path.join("SKILL.md"), "# Planning B").unwrap();
1218 fs::write(source_c_skill_path.join("SKILL.md"), "# Planning C").unwrap();
1219
1220 let mut items = IndexMap::new();
1221 items.insert(
1222 "agents/coder.md".into(),
1223 test_item(
1224 ItemKind::Agent,
1225 "coder",
1226 "source-a",
1227 agent_path,
1228 "agents/coder.md",
1229 ),
1230 );
1231 items.insert(
1232 "skills/planning".into(),
1233 test_item(
1234 ItemKind::Skill,
1235 "planning",
1236 "source-a",
1237 source_a_skill_path,
1238 "skills/planning",
1239 ),
1240 );
1241 items.insert(
1242 "skills/planning__source-b".into(),
1243 test_item(
1244 ItemKind::Skill,
1245 "planning__source-b",
1246 "source-b",
1247 source_b_skill_path,
1248 "skills/planning__source-b",
1249 ),
1250 );
1251 items.insert(
1252 "skills/planning__source-c".into(),
1253 test_item(
1254 ItemKind::Skill,
1255 "planning__source-c",
1256 "source-c",
1257 source_c_skill_path,
1258 "skills/planning__source-c",
1259 ),
1260 );
1261
1262 let mut target = TargetState { items };
1263 let renames = vec![
1264 CollisionRename {
1265 original_name: "planning".into(),
1266 new_name: "planning__source-b".into(),
1267 source_name: "source-b".into(),
1268 kind: ItemKind::Skill,
1269 },
1270 CollisionRename {
1271 original_name: "planning".into(),
1272 new_name: "planning__source-c".into(),
1273 source_name: "source-c".into(),
1274 kind: ItemKind::Skill,
1275 },
1276 ];
1277 let graph = graph_with_deps(dir.path(), "source-a", vec!["source-b"]);
1278
1279 apply_test_renames(&mut target, &[], &renames, &graph, &[]);
1280
1281 assert!(target.items["agents/coder.md"].rewritten_content.is_none());
1282 }
1283}