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            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}