Skip to main content

mars_agents/sync/
target.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use indexmap::IndexMap;
5
6use crate::config::{EffectiveConfig, FilterMode};
7use crate::diagnostic::{DiagnosticCategory, DiagnosticCollector};
8use crate::discover;
9use crate::error::MarsError;
10use crate::hash;
11use crate::lock::{CANONICAL_TARGET_ROOT, ItemId, ItemKind, LockFile, LockIndex};
12use crate::resolve::ResolvedGraph;
13use crate::sync::filter::apply_filter;
14use crate::types::{
15    ContentHash, DestPath, ItemName, RenameMap, SourceId, SourceName, SourceOrigin,
16};
17
18/// What the `.mars/` canonical store should look like after sync.
19///
20/// Built from the resolved graph with intent-based filtering applied.
21#[derive(Debug, Clone)]
22pub struct TargetState {
23    /// Keyed by dest_path (relative to `.mars/`).
24    pub items: IndexMap<DestPath, TargetItem>,
25}
26
27/// A single item in the desired target state.
28#[derive(Debug, Clone)]
29pub struct TargetItem {
30    pub id: ItemId,
31    pub source_name: SourceName,
32    pub origin: SourceOrigin,
33    pub source_id: SourceId,
34    /// Path to content in fetched source tree.
35    pub source_path: PathBuf,
36    /// Relative path under `.mars/` (reflects rename if any).
37    pub dest_path: DestPath,
38    /// SHA-256 of source content.
39    pub source_hash: ContentHash,
40    /// True when this item comes from root-level `SKILL.md` flat skill discovery.
41    pub is_flat_skill: bool,
42    /// Optional in-memory content override after frontmatter rewrites.
43    pub rewritten_content: Option<String>,
44}
45
46/// Explicit skill rename that changes the installed skill name.
47#[derive(Debug, Clone)]
48pub struct ExplicitSkillRename {
49    pub original_name: ItemName,
50    pub new_name: ItemName,
51    pub source_name: SourceName,
52}
53
54/// Automatic rename applied when multiple sources target the same destination.
55#[derive(Debug, Clone)]
56pub struct CollisionRename {
57    pub original_name: ItemName,
58    pub new_name: ItemName,
59    pub source_name: SourceName,
60    pub kind: ItemKind,
61}
62
63/// Build target state with collision detection integrated.
64///
65/// This is the main entry point — it builds the target, applies explicit
66/// rename mappings, and auto-renames cross-source agent/skill destination
67/// collisions.
68pub fn build_with_collisions(
69    graph: &ResolvedGraph,
70    config: &EffectiveConfig,
71) -> Result<(TargetState, Vec<ExplicitSkillRename>, Vec<CollisionRename>), MarsError> {
72    let mut diag = DiagnosticCollector::new();
73    build_with_collisions_and_diag(graph, config, &mut diag)
74}
75
76pub fn build_with_collisions_and_diag(
77    graph: &ResolvedGraph,
78    config: &EffectiveConfig,
79    diag: &mut DiagnosticCollector,
80) -> Result<(TargetState, Vec<ExplicitSkillRename>, Vec<CollisionRename>), MarsError> {
81    let mut collected_items = Vec::new();
82    let mut explicit_skill_renames = Vec::new();
83
84    for source_name in &graph.order {
85        let node = &graph.nodes[source_name];
86        let source_config = config.dependencies.get(source_name);
87
88        let discovered = discover::discover_resolved_source(
89            &node.rooted_ref.package_root,
90            Some(source_name.as_str()),
91        )?;
92
93        let source_id = source_config
94            .map(|s| s.id.clone())
95            .unwrap_or_else(|| node.source_id.clone());
96
97        let Some(filters) = graph
98            .filters
99            .get(source_name)
100            .filter(|filters| !filters.is_empty())
101            .cloned()
102            .or_else(|| source_config.map(|source| vec![source.filter.clone()]))
103        else {
104            // No materialization request reached this transitive source.
105            continue;
106        };
107
108        let renames = source_config
109            .map(|s| &s.rename)
110            .cloned()
111            .unwrap_or_default();
112
113        let filtered = apply_filter_union(&discovered, &filters, &node.rooted_ref.package_root)?;
114
115        for item in filtered {
116            let is_flat_skill =
117                item.id.kind == ItemKind::Skill && item.source_path == Path::new(".");
118            let source_content_path = node.rooted_ref.package_root.join(&item.source_path);
119            let source_hash = if is_flat_skill {
120                ContentHash::from(hash::compute_skill_hash_filtered(
121                    &source_content_path,
122                    crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
123                )?)
124            } else {
125                ContentHash::from(hash::compute_hash(&source_content_path, item.id.kind)?)
126            };
127
128            let (dest_name, dest_path) =
129                apply_item_rename(item.id.kind, &item.id.name, &renames, source_name)?;
130            if item.id.kind == ItemKind::Agent
131                && let Err(message) = crate::target::validate_agent_filename(dest_name.as_str())
132            {
133                diag.error_with_category(
134                    "invalid-agent-filename",
135                    format!("{message}; skipping agent from source `{source_name}`"),
136                    DiagnosticCategory::Validation,
137                );
138                continue;
139            }
140            if item.id.kind == ItemKind::Skill && dest_name != item.id.name {
141                explicit_skill_renames.push(ExplicitSkillRename {
142                    original_name: item.id.name.clone(),
143                    new_name: dest_name.clone(),
144                    source_name: source_name.clone(),
145                });
146            }
147
148            let target_item = TargetItem {
149                id: ItemId {
150                    kind: item.id.kind,
151                    name: dest_name,
152                },
153                source_name: source_name.clone(),
154                origin: SourceOrigin::Dependency(source_name.clone()),
155                source_id: source_id.clone(),
156                source_path: source_content_path,
157                dest_path,
158                source_hash,
159                is_flat_skill,
160                rewritten_content: None,
161            };
162
163            collected_items.push(target_item);
164        }
165    }
166
167    let collision_renames = rename_destination_collisions(&mut collected_items, diag)?;
168
169    let mut items: IndexMap<DestPath, TargetItem> = IndexMap::new();
170    for target_item in collected_items {
171        if let Some(existing) = items.get(&target_item.dest_path) {
172            return Err(MarsError::Collision {
173                item: format!(
174                    "{} `{}` at `{}` after auto-rename",
175                    target_item.id.kind, target_item.id.name, target_item.dest_path
176                ),
177                source_a: existing.source_name.to_string(),
178                source_b: target_item.source_name.to_string(),
179            });
180        }
181
182        items.insert(target_item.dest_path.clone(), target_item);
183    }
184
185    Ok((
186        TargetState { items },
187        explicit_skill_renames,
188        collision_renames,
189    ))
190}
191
192fn rename_destination_collisions(
193    items: &mut [TargetItem],
194    diag: &mut DiagnosticCollector,
195) -> Result<Vec<CollisionRename>, MarsError> {
196    let mut groups: IndexMap<DestPath, Vec<usize>> = IndexMap::new();
197    for (index, item) in items.iter().enumerate() {
198        groups
199            .entry(item.dest_path.clone())
200            .or_default()
201            .push(index);
202    }
203
204    let mut renames = Vec::new();
205    for indices in groups.values().filter(|indices| indices.len() > 1) {
206        let first = &items[indices[0]];
207        let distinct_sources: HashSet<&SourceName> = indices
208            .iter()
209            .map(|&index| &items[index].source_name)
210            .collect();
211        let auto_renamable = matches!(first.id.kind, ItemKind::Agent | ItemKind::Skill)
212            && indices
213                .iter()
214                .all(|&index| items[index].id.kind == first.id.kind)
215            && distinct_sources.len() == indices.len();
216        if !auto_renamable {
217            let second = &items[indices[1]];
218            return Err(MarsError::Collision {
219                item: format!("{} `{}`", second.id.kind, second.id.name),
220                source_a: first.source_name.to_string(),
221                source_b: second.source_name.to_string(),
222            });
223        }
224
225        for &index in indices {
226            let item = &mut items[index];
227            let original_name = item.id.name.clone();
228            let new_dest_path =
229                suffixed_collision_dest_path(&item.dest_path, item.id.kind, &item.source_name)?;
230            let new_name = ItemName::from(dest_name_from_dest(&new_dest_path, item.id.kind));
231
232            diag.warn(
233                "auto-rename-collision",
234                format!(
235                    "auto-renamed {} `{}` from source `{}` → `{}`",
236                    item.id.kind, original_name, item.source_name, new_name
237                ),
238            );
239
240            item.id.name = new_name.clone();
241            item.dest_path = new_dest_path;
242            renames.push(CollisionRename {
243                original_name,
244                new_name,
245                source_name: item.source_name.clone(),
246                kind: item.id.kind,
247            });
248        }
249    }
250
251    Ok(renames)
252}
253
254fn apply_filter_union(
255    discovered: &[discover::DiscoveredItem],
256    filters: &[FilterMode],
257    package_root: &Path,
258) -> Result<Vec<discover::DiscoveredItem>, MarsError> {
259    if filters.is_empty() {
260        return Ok(discovered.to_vec());
261    }
262
263    let mut union: HashSet<(ItemKind, ItemName, PathBuf)> = HashSet::new();
264    for filter in filters {
265        let filtered = apply_filter(discovered, filter, package_root)?;
266        union.extend(
267            filtered
268                .iter()
269                .map(|item| (item.id.kind, item.id.name.clone(), item.source_path.clone())),
270        );
271    }
272
273    Ok(discovered
274        .iter()
275        .filter(|item| {
276            union.contains(&(item.id.kind, item.id.name.clone(), item.source_path.clone()))
277        })
278        .cloned()
279        .collect())
280}
281
282/// Existing on-disk destination that is not lock-managed.
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct UnmanagedCollision {
285    pub source_name: SourceName,
286    pub path: DestPath,
287}
288
289/// Detect target installs that would overwrite unmanaged on-disk content.
290///
291/// If a target destination already exists but is not tracked in the lock file,
292/// treat it as user-authored content and report it as a collision so callers can
293/// skip installation while leaving existing files untouched.
294pub fn check_unmanaged_collisions(
295    install_target: &Path,
296    lock: &LockFile,
297    target: &TargetState,
298    force: bool,
299) -> Vec<UnmanagedCollision> {
300    let mut collisions = Vec::new();
301    let lock_index = LockIndex::new(lock);
302
303    for (dest_key, target_item) in &target.items {
304        if lock_index.contains_output(CANONICAL_TARGET_ROOT, dest_key) {
305            continue;
306        }
307
308        let disk_path = target_item.dest_path.resolve(install_target);
309        if disk_path.exists() {
310            if force {
311                continue;
312            }
313            // Check if disk content matches what we'd install — if so,
314            // this is a partial prior install (crash recovery), not an
315            // unmanaged user file. Safe to overwrite.
316            let hash_path = hash_path_for_kind(&disk_path, target_item.id.kind);
317            if let Ok(disk_hash) = hash::compute_hash(&hash_path, target_item.id.kind)
318                && disk_hash == target_item.source_hash.as_str()
319            {
320                continue;
321            }
322
323            collisions.push(UnmanagedCollision {
324                source_name: target_item.source_name.clone(),
325                path: target_item.dest_path.clone(),
326            });
327        }
328    }
329
330    collisions
331}
332
333fn apply_item_rename(
334    kind: ItemKind,
335    item_name: &str,
336    renames: &RenameMap,
337    source_name: &SourceName,
338) -> Result<(ItemName, DestPath), MarsError> {
339    let default_dest = default_dest_path(kind, item_name);
340    let default_key = default_dest.as_str();
341
342    let rename_value = renames.get(default_key).or_else(|| renames.get(item_name));
343
344    let dest_path = match rename_value {
345        Some(value) => parse_rename_dest(kind, value.as_str(), source_name)?,
346        None => default_dest,
347    };
348    let dest_name = dest_name_from_dest(&dest_path, kind);
349
350    Ok((ItemName::from(dest_name), dest_path))
351}
352
353/// Construct the default destination path for an item.
354/// Uses string formatting to guarantee forward slashes on all platforms.
355fn default_dest_path(kind: ItemKind, name: &str) -> DestPath {
356    let path_str = match kind {
357        ItemKind::Agent => format!("agents/{name}.md"),
358        ItemKind::Skill => format!("skills/{name}"),
359        ItemKind::Hook => format!("hooks/{name}"),
360        ItemKind::McpServer => format!("mcp/{name}"),
361        ItemKind::BootstrapDoc => format!("bootstrap/{name}/BOOTSTRAP.md"),
362    };
363    // Safe: internal paths constructed from validated item names
364    DestPath::new(path_str).expect("internal default path is always valid")
365}
366
367fn parse_rename_dest(
368    kind: ItemKind,
369    rename_value: &str,
370    source_name: &SourceName,
371) -> Result<DestPath, MarsError> {
372    // Normalize backslashes to forward slashes for cross-platform handling
373    let normalized = rename_value.replace('\\', "/");
374    let has_prefix = normalized.starts_with("agents/")
375        || normalized.starts_with("skills/")
376        || normalized.starts_with("hooks/")
377        || normalized.starts_with("mcp/")
378        || normalized.starts_with("bootstrap/");
379    let has_parent = normalized.contains('/');
380
381    if has_prefix || has_parent {
382        let dest = if kind == ItemKind::BootstrapDoc && !normalized.ends_with("/BOOTSTRAP.md") {
383            format!("{normalized}/BOOTSTRAP.md")
384        } else {
385            normalized.clone()
386        };
387        return DestPath::new(&dest).map_err(|e| MarsError::Source {
388            source_name: source_name.to_string(),
389            message: format!("invalid rename destination `{rename_value}`: {e}"),
390        });
391    }
392
393    let path_str = match kind {
394        ItemKind::Agent => {
395            if normalized.ends_with(".md") {
396                format!("agents/{normalized}")
397            } else {
398                format!("agents/{normalized}.md")
399            }
400        }
401        ItemKind::Skill => format!("skills/{normalized}"),
402        ItemKind::Hook => format!("hooks/{normalized}"),
403        ItemKind::McpServer => format!("mcp/{normalized}"),
404        ItemKind::BootstrapDoc => format!("bootstrap/{normalized}/BOOTSTRAP.md"),
405    };
406    DestPath::new(path_str).map_err(|e| MarsError::Source {
407        source_name: source_name.to_string(),
408        message: format!("invalid rename destination `{rename_value}`: {e}"),
409    })
410}
411
412fn dest_name_from_dest(dest_path: &DestPath, kind: ItemKind) -> String {
413    match kind {
414        ItemKind::BootstrapDoc => dest_path.item_name(kind),
415        _ => {
416            let last = dest_path.as_str().rsplit('/').next().unwrap_or("");
417            match kind {
418                ItemKind::Agent => last.strip_suffix(".md").unwrap_or(last).to_string(),
419                ItemKind::Skill | ItemKind::Hook | ItemKind::McpServer => last.to_string(),
420                ItemKind::BootstrapDoc => unreachable!("handled above"),
421            }
422        }
423    }
424}
425
426fn suffixed_collision_dest_path(
427    dest_path: &DestPath,
428    kind: ItemKind,
429    source_name: &SourceName,
430) -> Result<DestPath, MarsError> {
431    let suffix = format!("__{source_name}");
432    let path = dest_path.as_str();
433    let renamed = match kind {
434        ItemKind::Agent => {
435            let (parent, leaf) = split_parent_leaf(path);
436            let stem = leaf.strip_suffix(".md").unwrap_or(leaf);
437            join_parent_leaf(parent, &format!("{stem}{suffix}.md"))
438        }
439        ItemKind::Skill => {
440            return suffixed_leaf_dest_path(path, &suffix, source_name);
441        }
442        ItemKind::Hook | ItemKind::McpServer | ItemKind::BootstrapDoc => {
443            unreachable!("only agent and skill collisions are auto-renamed")
444        }
445    };
446
447    DestPath::new(&renamed).map_err(|e| MarsError::Source {
448        source_name: source_name.to_string(),
449        message: format!("invalid auto-renamed destination `{renamed}`: {e}"),
450    })
451}
452
453fn suffixed_leaf_dest_path(
454    path: &str,
455    suffix: &str,
456    source_name: &SourceName,
457) -> Result<DestPath, MarsError> {
458    let (parent, leaf) = split_parent_leaf(path);
459    let renamed = join_parent_leaf(parent, &format!("{leaf}{suffix}"));
460    DestPath::new(&renamed).map_err(|e| MarsError::Source {
461        source_name: source_name.to_string(),
462        message: format!("invalid auto-renamed destination `{renamed}`: {e}"),
463    })
464}
465
466fn split_parent_leaf(path: &str) -> (&str, &str) {
467    path.rsplit_once('/').unwrap_or(("", path))
468}
469
470fn join_parent_leaf(parent: &str, leaf: &str) -> String {
471    if parent.is_empty() {
472        leaf.to_string()
473    } else {
474        format!("{parent}/{leaf}")
475    }
476}
477
478fn hash_path_for_kind(path: &Path, kind: ItemKind) -> PathBuf {
479    if kind == ItemKind::BootstrapDoc {
480        path.parent()
481            .map(Path::to_path_buf)
482            .unwrap_or_else(|| path.to_path_buf())
483    } else {
484        path.to_path_buf()
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use crate::config::*;
492    use crate::lock::LockFile;
493    use crate::resolve::{ResolvedGraph, ResolvedNode};
494    use crate::source::ResolvedRef;
495    use indexmap::IndexMap;
496    use std::fs;
497    use tempfile::TempDir;
498
499    /// Helper: create a source tree with agents and skills
500    fn make_source_tree(agents: &[(&str, &str)], skills: &[(&str, &str)]) -> TempDir {
501        let dir = TempDir::new().unwrap();
502        if !agents.is_empty() {
503            let agents_dir = dir.path().join("agents");
504            fs::create_dir_all(&agents_dir).unwrap();
505            for (name, content) in agents {
506                fs::write(agents_dir.join(name), content).unwrap();
507            }
508        }
509        if !skills.is_empty() {
510            let skills_dir = dir.path().join("skills");
511            fs::create_dir_all(&skills_dir).unwrap();
512            for (name, content) in skills {
513                let skill_dir = skills_dir.join(name);
514                fs::create_dir_all(&skill_dir).unwrap();
515                fs::write(skill_dir.join("SKILL.md"), content).unwrap();
516            }
517        }
518        dir
519    }
520
521    fn make_graph_and_config(
522        sources: Vec<(&str, &TempDir, Option<&str>, FilterMode)>,
523    ) -> (ResolvedGraph, EffectiveConfig) {
524        let mut nodes = IndexMap::new();
525        let mut order = Vec::new();
526        let mut config_dependencies = IndexMap::new();
527
528        for (name, tree, url, filter) in sources {
529            let url_str = url.map(|u| u.to_string());
530            nodes.insert(
531                name.into(),
532                ResolvedNode {
533                    source_name: name.into(),
534                    source_id: if let Some(u) = url {
535                        SourceId::git(crate::types::SourceUrl::from(u))
536                    } else {
537                        SourceId::Path {
538                            canonical: tree.path().to_path_buf(),
539                            subpath: None,
540                        }
541                    },
542                    rooted_ref: crate::resolve::RootedSourceRef {
543                        checkout_root: tree.path().to_path_buf(),
544                        package_root: tree.path().to_path_buf(),
545                    },
546                    resolved_ref: ResolvedRef {
547                        source_name: name.into(),
548                        version: None,
549                        version_tag: None,
550                        commit: None,
551                        tree_path: tree.path().to_path_buf(),
552                    },
553                    latest_version: None,
554                    manifest: None,
555                    deps: vec![],
556                },
557            );
558            order.push(name.into());
559
560            let spec = if let Some(u) = url {
561                SourceSpec::Git(GitSpec {
562                    url: crate::types::SourceUrl::from(u),
563                    version: None,
564                })
565            } else {
566                SourceSpec::Path(tree.path().to_path_buf())
567            };
568
569            config_dependencies.insert(
570                name.into(),
571                EffectiveDependency {
572                    name: name.into(),
573                    id: if let Some(u) = url {
574                        SourceId::git(crate::types::SourceUrl::from(u))
575                    } else {
576                        SourceId::Path {
577                            canonical: tree.path().to_path_buf(),
578                            subpath: None,
579                        }
580                    },
581                    spec,
582                    subpath: None,
583                    filter,
584                    rename: RenameMap::new(),
585                    dialect: None,
586                    is_overridden: false,
587                    original_git: url_str.map(|u| GitSpec {
588                        url: crate::types::SourceUrl::from(u),
589                        version: None,
590                    }),
591                },
592            );
593        }
594
595        let graph = ResolvedGraph {
596            nodes,
597            order,
598            filters: std::collections::HashMap::new(),
599            version_constraints: std::collections::HashMap::new(),
600        };
601        let config = EffectiveConfig {
602            dependencies: config_dependencies,
603            settings: Settings::default(),
604            skills: indexmap::IndexMap::new(),
605        };
606        (graph, config)
607    }
608
609    // === Target build tests ===
610
611    #[test]
612    fn build_single_source_no_filter() {
613        let tree = make_source_tree(&[("coder.md", "# coder")], &[("planning", "# planning")]);
614        let (graph, config) = make_graph_and_config(vec![(
615            "base",
616            &tree,
617            Some("https://github.com/org/base"),
618            FilterMode::All,
619        )]);
620
621        let (target, renames, _) = build_with_collisions(&graph, &config).unwrap();
622        assert!(renames.is_empty());
623        assert_eq!(target.items.len(), 2);
624        assert!(target.items.contains_key("agents/coder.md"));
625        assert!(target.items.contains_key("skills/planning"));
626    }
627
628    #[test]
629    #[cfg(not(target_os = "windows"))]
630    fn invalid_windows_agent_filename_emits_diagnostic_and_skips() {
631        // This test creates a file with `:` in the name, which is only possible on
632        // non-Windows. The validation catches names that would break on Windows when
633        // created on POSIX systems.
634        let tree = make_source_tree(&[("bad:name.md", "# bad"), ("coder.md", "# coder")], &[]);
635        let (graph, config) = make_graph_and_config(vec![(
636            "base",
637            &tree,
638            Some("https://github.com/org/base"),
639            FilterMode::All,
640        )]);
641        let mut diag = DiagnosticCollector::new();
642
643        let (target, _, _) = build_with_collisions_and_diag(&graph, &config, &mut diag).unwrap();
644        let diagnostics = diag.drain();
645
646        assert!(!target.items.contains_key("agents/bad:name.md"));
647        assert!(target.items.contains_key("agents/coder.md"));
648        assert_eq!(diagnostics.len(), 1);
649        assert_eq!(diagnostics[0].code, "invalid-agent-filename");
650    }
651
652    #[test]
653    fn build_with_path_rename_mapping() {
654        let tree = make_source_tree(&[("old-name.md", "# old")], &[]);
655
656        let (graph, mut config) = make_graph_and_config(vec![(
657            "base",
658            &tree,
659            Some("https://github.com/org/base"),
660            FilterMode::All,
661        )]);
662
663        // Add rename mapping
664        config
665            .dependencies
666            .get_mut("base")
667            .unwrap()
668            .rename
669            .insert("agents/old-name.md".into(), "agents/new-name.md".into());
670
671        let (target, renames, _) = build_with_collisions(&graph, &config).unwrap();
672        assert!(renames.is_empty());
673        assert_eq!(target.items.len(), 1);
674        assert!(target.items.contains_key("agents/new-name.md"));
675        assert_eq!(target.items["agents/new-name.md"].id.name, "new-name");
676    }
677
678    #[test]
679    fn default_dest_path_uses_forward_slashes_for_agents_and_skills() {
680        let agent = default_dest_path(ItemKind::Agent, "coder");
681        let skill = default_dest_path(ItemKind::Skill, "planning");
682
683        assert_eq!(agent.as_str(), "agents/coder.md");
684        assert_eq!(skill.as_str(), "skills/planning");
685        assert!(!agent.as_str().contains('\\'));
686        assert!(!skill.as_str().contains('\\'));
687    }
688
689    #[test]
690    fn parse_rename_dest_normalizes_backslashes_to_forward_slashes() {
691        let source_name = SourceName::from("base");
692
693        let agent =
694            parse_rename_dest(ItemKind::Agent, r"agents\nested\renamed.md", &source_name).unwrap();
695        let skill =
696            parse_rename_dest(ItemKind::Skill, r"skills\nested\planning", &source_name).unwrap();
697
698        assert_eq!(agent.as_str(), "agents/nested/renamed.md");
699        assert_eq!(skill.as_str(), "skills/nested/planning");
700        assert!(!agent.as_str().contains('\\'));
701        assert!(!skill.as_str().contains('\\'));
702    }
703
704    #[test]
705    fn parse_rename_dest_rejects_absolute_and_escape_destinations() {
706        let source_name = SourceName::from("base");
707
708        let absolute = parse_rename_dest(ItemKind::Agent, "/tmp/escape", &source_name)
709            .expect_err("absolute rename should fail");
710        assert!(matches!(absolute, MarsError::Source { .. }));
711
712        let traversal = parse_rename_dest(ItemKind::Skill, "../escape", &source_name)
713            .expect_err("traversal rename should fail");
714        assert!(matches!(traversal, MarsError::Source { .. }));
715    }
716
717    #[test]
718    fn build_with_invalid_rename_destination_returns_error() {
719        let tree = make_source_tree(&[("old-name.md", "# old")], &[]);
720
721        let (graph, mut config) =
722            make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
723
724        config
725            .dependencies
726            .get_mut("base")
727            .unwrap()
728            .rename
729            .insert("agents/old-name.md".into(), "../escape.md".into());
730
731        let err = build_with_collisions(&graph, &config).unwrap_err();
732        assert!(matches!(err, MarsError::Source { .. }));
733    }
734
735    // === Collision tests ===
736
737    #[test]
738    fn collision_auto_renames_both() {
739        let tree1 = make_source_tree(&[("coder.md", "# coder from source 1")], &[]);
740        let tree2 = make_source_tree(&[("coder.md", "# coder from source 2")], &[]);
741
742        let (graph, config) = make_graph_and_config(vec![
743            (
744                "source-a",
745                &tree1,
746                Some("https://github.com/alice/agents"),
747                FilterMode::All,
748            ),
749            (
750                "source-b",
751                &tree2,
752                Some("https://github.com/bob/agents"),
753                FilterMode::All,
754            ),
755        ]);
756        let mut diag = DiagnosticCollector::new();
757
758        let (target, explicit_renames, collision_renames) =
759            build_with_collisions_and_diag(&graph, &config, &mut diag).unwrap();
760        let diagnostics = diag.drain();
761
762        assert!(explicit_renames.is_empty());
763        assert_eq!(collision_renames.len(), 2);
764        assert!(target.items.contains_key("agents/coder__source-a.md"));
765        assert!(target.items.contains_key("agents/coder__source-b.md"));
766        assert!(!target.items.contains_key("agents/coder.md"));
767        assert_eq!(
768            target.items["agents/coder__source-a.md"].id.name,
769            "coder__source-a"
770        );
771        assert_eq!(
772            target.items["agents/coder__source-b.md"].id.name,
773            "coder__source-b"
774        );
775        assert_eq!(
776            diagnostics
777                .iter()
778                .filter(|diagnostic| diagnostic.code == "auto-rename-collision")
779                .count(),
780            2
781        );
782    }
783
784    #[test]
785    fn skill_collision_auto_renames_both() {
786        let tree1 = make_source_tree(&[], &[("planning", "# planning from source 1")]);
787        let tree2 = make_source_tree(&[], &[("planning", "# planning from source 2")]);
788
789        let (graph, config) = make_graph_and_config(vec![
790            ("source-a", &tree1, None, FilterMode::All),
791            ("source-b", &tree2, None, FilterMode::All),
792        ]);
793
794        let (target, explicit_renames, collision_renames) =
795            build_with_collisions(&graph, &config).unwrap();
796
797        assert!(explicit_renames.is_empty());
798        assert_eq!(collision_renames.len(), 2);
799        assert!(target.items.contains_key("skills/planning__source-a"));
800        assert!(target.items.contains_key("skills/planning__source-b"));
801        assert!(!target.items.contains_key("skills/planning"));
802        assert_eq!(
803            target.items["skills/planning__source-a"].id.name,
804            "planning__source-a"
805        );
806        assert_eq!(
807            target.items["skills/planning__source-b"].id.name,
808            "planning__source-b"
809        );
810    }
811
812    #[test]
813    fn three_way_collision_renames_all() {
814        let tree1 = make_source_tree(&[("coder.md", "# coder from source 1")], &[]);
815        let tree2 = make_source_tree(&[("coder.md", "# coder from source 2")], &[]);
816        let tree3 = make_source_tree(&[("coder.md", "# coder from source 3")], &[]);
817
818        let (graph, config) = make_graph_and_config(vec![
819            ("source-a", &tree1, None, FilterMode::All),
820            ("source-b", &tree2, None, FilterMode::All),
821            ("source-c", &tree3, None, FilterMode::All),
822        ]);
823
824        let (target, _, collision_renames) = build_with_collisions(&graph, &config).unwrap();
825
826        assert_eq!(collision_renames.len(), 3);
827        assert!(target.items.contains_key("agents/coder__source-a.md"));
828        assert!(target.items.contains_key("agents/coder__source-b.md"));
829        assert!(target.items.contains_key("agents/coder__source-c.md"));
830        assert!(!target.items.contains_key("agents/coder.md"));
831    }
832
833    #[test]
834    fn explicit_rename_prevents_collision() {
835        let tree1 = make_source_tree(&[("coder.md", "# coder from source 1")], &[]);
836        let tree2 = make_source_tree(&[("coder.md", "# coder from source 2")], &[]);
837
838        let (graph, mut config) = make_graph_and_config(vec![
839            ("source-a", &tree1, None, FilterMode::All),
840            ("source-b", &tree2, None, FilterMode::All),
841        ]);
842        config
843            .dependencies
844            .get_mut("source-a")
845            .unwrap()
846            .rename
847            .insert("agents/coder.md".into(), "agents/source-a-coder.md".into());
848
849        let (target, _, collision_renames) = build_with_collisions(&graph, &config).unwrap();
850
851        assert!(collision_renames.is_empty());
852        assert!(target.items.contains_key("agents/source-a-coder.md"));
853        assert!(target.items.contains_key("agents/coder.md"));
854        assert!(!target.items.contains_key("agents/coder__source-a.md"));
855        assert!(!target.items.contains_key("agents/coder__source-b.md"));
856    }
857
858    #[test]
859    fn same_source_explicit_rename_collision_stays_hard_error() {
860        let tree = make_source_tree(
861            &[
862                ("coder.md", "# coder"),
863                ("reviewer.md", "# reviewer renamed into coder"),
864            ],
865            &[],
866        );
867        let (graph, mut config) =
868            make_graph_and_config(vec![("source-a", &tree, None, FilterMode::All)]);
869        config
870            .dependencies
871            .get_mut("source-a")
872            .unwrap()
873            .rename
874            .insert("agents/reviewer.md".into(), "agents/coder.md".into());
875
876        let err = build_with_collisions(&graph, &config).unwrap_err();
877
878        assert!(matches!(err, MarsError::Collision { .. }));
879    }
880
881    #[test]
882    fn mixed_kind_explicit_rename_collision_stays_hard_error() {
883        let tree = make_source_tree(&[("coder.md", "# coder")], &[("planning", "# planning")]);
884        let (graph, mut config) =
885            make_graph_and_config(vec![("source-a", &tree, None, FilterMode::All)]);
886        config
887            .dependencies
888            .get_mut("source-a")
889            .unwrap()
890            .rename
891            .insert("agents/coder.md".into(), "skills/planning".into());
892
893        let err = build_with_collisions(&graph, &config).unwrap_err();
894
895        assert!(matches!(err, MarsError::Collision { .. }));
896    }
897
898    #[test]
899    fn duplicate_source_in_cross_source_group_stays_hard_error_without_auto_warning() {
900        let tree1 = make_source_tree(
901            &[
902                ("coder.md", "# coder"),
903                ("reviewer.md", "# reviewer renamed into coder"),
904            ],
905            &[],
906        );
907        let tree2 = make_source_tree(&[("coder.md", "# coder from source 2")], &[]);
908        let (graph, mut config) = make_graph_and_config(vec![
909            ("source-a", &tree1, None, FilterMode::All),
910            ("source-b", &tree2, None, FilterMode::All),
911        ]);
912        config
913            .dependencies
914            .get_mut("source-a")
915            .unwrap()
916            .rename
917            .insert("agents/reviewer.md".into(), "agents/coder.md".into());
918        let mut diag = DiagnosticCollector::new();
919
920        let err = build_with_collisions_and_diag(&graph, &config, &mut diag).unwrap_err();
921        let diagnostics = diag.drain();
922
923        assert!(matches!(err, MarsError::Collision { .. }));
924        assert!(
925            diagnostics
926                .iter()
927                .all(|diagnostic| diagnostic.code != "auto-rename-collision")
928        );
929    }
930
931    #[test]
932    fn no_collision_no_renames() {
933        let tree1 = make_source_tree(&[("coder.md", "# coder")], &[]);
934        let tree2 = make_source_tree(&[("reviewer.md", "# reviewer")], &[]);
935
936        let (graph, config) = make_graph_and_config(vec![
937            (
938                "source-a",
939                &tree1,
940                Some("https://github.com/alice/agents"),
941                FilterMode::All,
942            ),
943            (
944                "source-b",
945                &tree2,
946                Some("https://github.com/bob/agents"),
947                FilterMode::All,
948            ),
949        ]);
950
951        let (target, renames, collision_renames) = build_with_collisions(&graph, &config).unwrap();
952        assert!(renames.is_empty());
953        assert!(collision_renames.is_empty());
954        assert_eq!(target.items.len(), 2);
955    }
956
957    // === Source with agents filter + skill deps ===
958
959    #[test]
960    fn build_with_agents_filter_pulls_transitive_skills() {
961        let tree = make_source_tree(
962            &[("coder.md", "---\nskills:\n  - planning\n---\n# Coder\n")],
963            &[("planning", "# Planning"), ("unused-skill", "# Unused")],
964        );
965
966        let (graph, config) = make_graph_and_config(vec![(
967            "base",
968            &tree,
969            None,
970            FilterMode::Include {
971                agents: vec!["coder".into()],
972                skills: vec![],
973            },
974        )]);
975
976        let (target, renames, _) = build_with_collisions(&graph, &config).unwrap();
977        assert!(renames.is_empty());
978        assert_eq!(target.items.len(), 2); // coder + planning
979        assert!(target.items.contains_key("agents/coder.md"));
980        assert!(target.items.contains_key("skills/planning"));
981        // unused-skill should NOT be present
982        assert!(!target.items.contains_key("skills/unused-skill"));
983    }
984
985    #[test]
986    fn build_with_exclude_filter() {
987        let tree = make_source_tree(&[("coder.md", "# coder"), ("deprecated.md", "# old")], &[]);
988
989        let (graph, config) = make_graph_and_config(vec![(
990            "base",
991            &tree,
992            None,
993            FilterMode::Exclude(vec!["deprecated".into()]),
994        )]);
995
996        let (target, renames, _) = build_with_collisions(&graph, &config).unwrap();
997        assert!(renames.is_empty());
998        assert_eq!(target.items.len(), 1);
999        assert!(target.items.contains_key("agents/coder.md"));
1000    }
1001
1002    #[test]
1003    fn build_unions_multiple_include_filters_for_same_source() {
1004        let tree = make_source_tree(
1005            &[],
1006            &[
1007                ("skill-a", "# Skill A"),
1008                ("skill-b", "# Skill B"),
1009                ("skill-c", "# Skill C"),
1010            ],
1011        );
1012
1013        let (mut graph, config) =
1014            make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
1015        graph.filters.insert(
1016            "base".into(),
1017            vec![
1018                FilterMode::Include {
1019                    agents: vec![],
1020                    skills: vec!["skill-a".into(), "skill-b".into()],
1021                },
1022                FilterMode::Include {
1023                    agents: vec![],
1024                    skills: vec!["skill-b".into(), "skill-c".into()],
1025                },
1026            ],
1027        );
1028
1029        let (target, renames, _) = build_with_collisions(&graph, &config).unwrap();
1030        assert!(renames.is_empty());
1031        assert_eq!(target.items.len(), 3);
1032        assert!(target.items.contains_key("skills/skill-a"));
1033        assert!(target.items.contains_key("skills/skill-b"));
1034        assert!(target.items.contains_key("skills/skill-c"));
1035    }
1036
1037    #[test]
1038    fn build_target_items_have_correct_hashes() {
1039        let content = "# agent content for hash test";
1040        let tree = make_source_tree(&[("test.md", content)], &[]);
1041
1042        let (graph, config) = make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
1043
1044        let (target, renames, _) = build_with_collisions(&graph, &config).unwrap();
1045        assert!(renames.is_empty());
1046        let item = &target.items["agents/test.md"];
1047        let expected_hash = hash::hash_bytes(content.as_bytes());
1048        assert_eq!(item.source_hash, expected_hash);
1049    }
1050
1051    #[test]
1052    fn unmanaged_disk_path_collision_reported() {
1053        let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
1054        let (graph, config) = make_graph_and_config(vec![(
1055            "base",
1056            &tree,
1057            Some("https://github.com/org/base"),
1058            FilterMode::All,
1059        )]);
1060
1061        let (target, renames, _) = build_with_collisions(&graph, &config).unwrap();
1062        assert!(renames.is_empty());
1063        let install_root = TempDir::new().unwrap();
1064
1065        // Existing user-authored file at the same destination.
1066        let existing = install_root.path().join("agents").join("coder.md");
1067        fs::create_dir_all(existing.parent().unwrap()).unwrap();
1068        fs::write(&existing, "# user-authored").unwrap();
1069
1070        let collisions =
1071            check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target, false);
1072        assert_eq!(collisions.len(), 1);
1073        assert_eq!(collisions[0].source_name.as_ref(), "base");
1074        assert_eq!(collisions[0].path.as_str(), "agents/coder.md");
1075    }
1076
1077    #[test]
1078    fn unmanaged_collision_skipped_when_hash_matches() {
1079        let content = "# managed agent";
1080        let tree = make_source_tree(&[("coder.md", content)], &[]);
1081        let (graph, config) = make_graph_and_config(vec![(
1082            "base",
1083            &tree,
1084            Some("https://github.com/org/base"),
1085            FilterMode::All,
1086        )]);
1087
1088        let (target, renames, _) = build_with_collisions(&graph, &config).unwrap();
1089        assert!(renames.is_empty());
1090        let install_root = TempDir::new().unwrap();
1091
1092        // Simulate partial prior install: file on disk with same content
1093        let existing = install_root.path().join("agents").join("coder.md");
1094        fs::create_dir_all(existing.parent().unwrap()).unwrap();
1095        fs::write(&existing, content).unwrap();
1096
1097        // Should skip collision — disk content matches planned install (crash recovery)
1098        let collisions =
1099            check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target, false);
1100        assert!(collisions.is_empty());
1101    }
1102
1103    #[test]
1104    fn unmanaged_collision_reported_on_different_content() {
1105        let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
1106        let (graph, config) = make_graph_and_config(vec![(
1107            "base",
1108            &tree,
1109            Some("https://github.com/org/base"),
1110            FilterMode::All,
1111        )]);
1112
1113        let (target, renames, _) = build_with_collisions(&graph, &config).unwrap();
1114        assert!(renames.is_empty());
1115        let install_root = TempDir::new().unwrap();
1116
1117        // User-authored file with different content
1118        let existing = install_root.path().join("agents").join("coder.md");
1119        fs::create_dir_all(existing.parent().unwrap()).unwrap();
1120        fs::write(&existing, "# different user content").unwrap();
1121
1122        let collisions =
1123            check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target, false);
1124        assert_eq!(collisions.len(), 1);
1125        assert_eq!(collisions[0].source_name.as_ref(), "base");
1126        assert_eq!(collisions[0].path.as_str(), "agents/coder.md");
1127    }
1128
1129    #[test]
1130    fn unmanaged_collision_skipped_under_force() {
1131        let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
1132        let (graph, config) = make_graph_and_config(vec![(
1133            "base",
1134            &tree,
1135            Some("https://github.com/org/base"),
1136            FilterMode::All,
1137        )]);
1138
1139        let (target, renames, _) = build_with_collisions(&graph, &config).unwrap();
1140        assert!(renames.is_empty());
1141        let install_root = TempDir::new().unwrap();
1142
1143        let existing = install_root.path().join("agents").join("coder.md");
1144        fs::create_dir_all(existing.parent().unwrap()).unwrap();
1145        fs::write(&existing, "# stale cache content").unwrap();
1146
1147        let collisions =
1148            check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target, true);
1149        assert!(collisions.is_empty());
1150    }
1151}