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