Skip to main content

mars_agents/sync/
rewrite.rs

1//! Unified frontmatter reference rewriting after renames.
2//!
3//! Explicit config renames and automatic collision renames are indexed together
4//! before any agent content is rewritten. Each agent gets one rewrite pass that
5//! resolves skill and subagent references against the post-prune target state,
6//! so guards such as "same source still owns the unrenamed item" see only items
7//! that will actually be installed.
8
9use 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/// All installed-name rewrites indexed by original item name.
24#[derive(Debug, Default)]
25pub struct RenameIndex {
26    /// Skill original_name -> [(installed_name, source_name)].
27    skill_renames: HashMap<ItemName, Vec<(ItemName, SourceName)>>,
28    /// Subagent original_name -> [(installed_name, source_name)].
29    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
89/// Apply explicit and collision renames to each agent in one content update.
90pub 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    // For each agent in target, check if it references any renamed items.
103    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}