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::discover;
8use crate::error::MarsError;
9use crate::hash;
10use crate::lock::{ItemId, ItemKind, LockFile};
11use crate::resolve::ResolvedGraph;
12use crate::sync::filter::apply_filter;
13use crate::types::{
14    ContentHash, DestPath, ItemName, RenameMap, SourceId, SourceName, SourceOrigin,
15};
16
17/// What `.agents/` should look like after sync.
18///
19/// Built from the resolved graph with intent-based filtering applied.
20#[derive(Debug, Clone)]
21pub struct TargetState {
22    /// Keyed by dest_path (relative to .agents/).
23    pub items: IndexMap<DestPath, TargetItem>,
24}
25
26/// A single item in the desired target state.
27#[derive(Debug, Clone)]
28pub struct TargetItem {
29    pub id: ItemId,
30    pub source_name: SourceName,
31    pub origin: SourceOrigin,
32    pub source_id: SourceId,
33    /// Path to content in fetched source tree.
34    pub source_path: PathBuf,
35    /// Relative path under `.agents/` (reflects rename if any).
36    pub dest_path: DestPath,
37    /// SHA-256 of source content.
38    pub source_hash: ContentHash,
39    /// True when this item comes from root-level `SKILL.md` flat skill discovery.
40    pub is_flat_skill: bool,
41    /// Optional in-memory content override after frontmatter rewrites.
42    pub rewritten_content: Option<String>,
43}
44
45/// Explicit skill rename that changes the installed skill name.
46#[derive(Debug, Clone)]
47pub struct ExplicitSkillRename {
48    pub original_name: ItemName,
49    pub new_name: ItemName,
50    pub source_name: SourceName,
51}
52
53/// Build target state with collision detection integrated.
54///
55/// This is the main entry point — it builds the target, applies explicit
56/// rename mappings, and raises hard collisions when two sources want the same
57/// destination.
58pub fn build_with_collisions(
59    graph: &ResolvedGraph,
60    config: &EffectiveConfig,
61) -> Result<(TargetState, Vec<ExplicitSkillRename>), MarsError> {
62    let mut items: IndexMap<DestPath, TargetItem> = IndexMap::new();
63    let mut explicit_skill_renames = Vec::new();
64
65    for source_name in &graph.order {
66        let node = &graph.nodes[source_name];
67        let source_config = config.dependencies.get(source_name);
68
69        let discovered = discover::discover_resolved_source(
70            &node.rooted_ref.package_root,
71            Some(source_name.as_str()),
72        )?;
73
74        let source_id = source_config
75            .map(|s| s.id.clone())
76            .unwrap_or_else(|| node.source_id.clone());
77
78        let Some(filters) = graph
79            .filters
80            .get(source_name)
81            .filter(|filters| !filters.is_empty())
82            .cloned()
83            .or_else(|| source_config.map(|source| vec![source.filter.clone()]))
84        else {
85            // No materialization request reached this transitive source.
86            continue;
87        };
88
89        let renames = source_config
90            .map(|s| &s.rename)
91            .cloned()
92            .unwrap_or_default();
93
94        let filtered = apply_filter_union(&discovered, &filters, &node.rooted_ref.package_root)?;
95
96        for item in filtered {
97            let is_flat_skill =
98                item.id.kind == ItemKind::Skill && item.source_path == Path::new(".");
99            let source_content_path = node.rooted_ref.package_root.join(&item.source_path);
100            let source_hash = if is_flat_skill {
101                ContentHash::from(hash::compute_skill_hash_filtered(
102                    &source_content_path,
103                    crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
104                )?)
105            } else {
106                ContentHash::from(hash::compute_hash(&source_content_path, item.id.kind)?)
107            };
108
109            let (dest_name, dest_path) =
110                apply_item_rename(item.id.kind, &item.id.name, &renames, source_name)?;
111            if item.id.kind == ItemKind::Skill && dest_name != item.id.name {
112                explicit_skill_renames.push(ExplicitSkillRename {
113                    original_name: item.id.name.clone(),
114                    new_name: dest_name.clone(),
115                    source_name: source_name.clone(),
116                });
117            }
118
119            let target_item = TargetItem {
120                id: ItemId {
121                    kind: item.id.kind,
122                    name: dest_name,
123                },
124                source_name: source_name.clone(),
125                origin: SourceOrigin::Dependency(source_name.clone()),
126                source_id: source_id.clone(),
127                source_path: source_content_path,
128                dest_path,
129                source_hash,
130                is_flat_skill,
131                rewritten_content: None,
132            };
133
134            if let Some(existing) = items.get(&target_item.dest_path) {
135                return Err(MarsError::Collision {
136                    item: format!("{} `{}`", target_item.id.kind, target_item.id.name),
137                    source_a: existing.source_name.to_string(),
138                    source_b: target_item.source_name.to_string(),
139                });
140            }
141
142            items.insert(target_item.dest_path.clone(), target_item);
143        }
144    }
145
146    Ok((TargetState { items }, explicit_skill_renames))
147}
148
149fn apply_filter_union(
150    discovered: &[discover::DiscoveredItem],
151    filters: &[FilterMode],
152    package_root: &Path,
153) -> Result<Vec<discover::DiscoveredItem>, MarsError> {
154    if filters.is_empty() {
155        return Ok(discovered.to_vec());
156    }
157
158    let mut union: HashSet<(ItemKind, ItemName, PathBuf)> = HashSet::new();
159    for filter in filters {
160        let filtered = apply_filter(discovered, filter, package_root)?;
161        union.extend(
162            filtered
163                .iter()
164                .map(|item| (item.id.kind, item.id.name.clone(), item.source_path.clone())),
165        );
166    }
167
168    Ok(discovered
169        .iter()
170        .filter(|item| {
171            union.contains(&(item.id.kind, item.id.name.clone(), item.source_path.clone()))
172        })
173        .cloned()
174        .collect())
175}
176
177// Re-export for API compatibility — rewrite_skill_refs moved to sync::rewrite.
178pub use crate::sync::rewrite::rewrite_skill_refs;
179
180/// Existing on-disk destination that is not lock-managed.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct UnmanagedCollision {
183    pub source_name: SourceName,
184    pub path: DestPath,
185}
186
187/// Detect target installs that would overwrite unmanaged on-disk content.
188///
189/// If a target destination already exists but is not tracked in the lock file,
190/// treat it as user-authored content and report it as a collision so callers can
191/// skip installation while leaving existing files untouched.
192pub fn check_unmanaged_collisions(
193    install_target: &Path,
194    lock: &LockFile,
195    target: &TargetState,
196) -> Vec<UnmanagedCollision> {
197    let mut collisions = Vec::new();
198
199    for (dest_key, target_item) in &target.items {
200        if lock.items.contains_key(dest_key) {
201            continue;
202        }
203
204        let disk_path = target_item.dest_path.resolve(install_target);
205        if disk_path.exists() {
206            // Check if disk content matches what we'd install — if so,
207            // this is a partial prior install (crash recovery), not an
208            // unmanaged user file. Safe to overwrite.
209            if let Ok(disk_hash) = hash::compute_hash(&disk_path, target_item.id.kind)
210                && disk_hash == target_item.source_hash.as_str()
211            {
212                continue;
213            }
214
215            collisions.push(UnmanagedCollision {
216                source_name: target_item.source_name.clone(),
217                path: target_item.dest_path.clone(),
218            });
219        }
220    }
221
222    collisions
223}
224
225fn apply_item_rename(
226    kind: ItemKind,
227    item_name: &str,
228    renames: &RenameMap,
229    source_name: &SourceName,
230) -> Result<(ItemName, DestPath), MarsError> {
231    let default_dest = default_dest_path(kind, item_name);
232    let default_key = default_dest.as_str();
233
234    let rename_value = renames.get(default_key).or_else(|| renames.get(item_name));
235
236    let dest_path = match rename_value {
237        Some(value) => parse_rename_dest(kind, value.as_str(), source_name)?,
238        None => default_dest,
239    };
240    let dest_name = dest_name_from_dest(&dest_path, kind);
241
242    Ok((ItemName::from(dest_name), dest_path))
243}
244
245/// Construct the default destination path for an item.
246/// Uses string formatting to guarantee forward slashes on all platforms.
247fn default_dest_path(kind: ItemKind, name: &str) -> DestPath {
248    let path_str = match kind {
249        ItemKind::Agent => format!("agents/{name}.md"),
250        ItemKind::Skill => format!("skills/{name}"),
251    };
252    // Safe: internal paths constructed from validated item names
253    DestPath::new(path_str).expect("internal default path is always valid")
254}
255
256fn parse_rename_dest(
257    kind: ItemKind,
258    rename_value: &str,
259    source_name: &SourceName,
260) -> Result<DestPath, MarsError> {
261    // Normalize backslashes to forward slashes for cross-platform handling
262    let normalized = rename_value.replace('\\', "/");
263    let has_prefix = normalized.starts_with("agents/") || normalized.starts_with("skills/");
264    let has_parent = normalized.contains('/');
265
266    if has_prefix || has_parent {
267        return DestPath::new(&normalized).map_err(|e| MarsError::Source {
268            source_name: source_name.to_string(),
269            message: format!("invalid rename destination `{rename_value}`: {e}"),
270        });
271    }
272
273    let path_str = match kind {
274        ItemKind::Agent => {
275            if normalized.ends_with(".md") {
276                format!("agents/{normalized}")
277            } else {
278                format!("agents/{normalized}.md")
279            }
280        }
281        ItemKind::Skill => format!("skills/{normalized}"),
282    };
283    DestPath::new(path_str).map_err(|e| MarsError::Source {
284        source_name: source_name.to_string(),
285        message: format!("invalid rename destination `{rename_value}`: {e}"),
286    })
287}
288
289fn dest_name_from_dest(dest_path: &DestPath, kind: ItemKind) -> String {
290    let last = dest_path.as_str().rsplit('/').next().unwrap_or("");
291    match kind {
292        ItemKind::Agent => last.strip_suffix(".md").unwrap_or(last).to_string(),
293        ItemKind::Skill => last.to_string(),
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::config::*;
301    use crate::lock::LockFile;
302    use crate::resolve::{ResolvedGraph, ResolvedNode};
303    use crate::source::ResolvedRef;
304    use indexmap::IndexMap;
305    use std::fs;
306    use tempfile::TempDir;
307
308    /// Helper: create a source tree with agents and skills
309    fn make_source_tree(agents: &[(&str, &str)], skills: &[(&str, &str)]) -> TempDir {
310        let dir = TempDir::new().unwrap();
311        if !agents.is_empty() {
312            let agents_dir = dir.path().join("agents");
313            fs::create_dir_all(&agents_dir).unwrap();
314            for (name, content) in agents {
315                fs::write(agents_dir.join(name), content).unwrap();
316            }
317        }
318        if !skills.is_empty() {
319            let skills_dir = dir.path().join("skills");
320            fs::create_dir_all(&skills_dir).unwrap();
321            for (name, content) in skills {
322                let skill_dir = skills_dir.join(name);
323                fs::create_dir_all(&skill_dir).unwrap();
324                fs::write(skill_dir.join("SKILL.md"), content).unwrap();
325            }
326        }
327        dir
328    }
329
330    fn make_graph_and_config(
331        sources: Vec<(&str, &TempDir, Option<&str>, FilterMode)>,
332    ) -> (ResolvedGraph, EffectiveConfig) {
333        let mut nodes = IndexMap::new();
334        let mut order = Vec::new();
335        let mut config_dependencies = IndexMap::new();
336
337        for (name, tree, url, filter) in sources {
338            let url_str = url.map(|u| u.to_string());
339            nodes.insert(
340                name.into(),
341                ResolvedNode {
342                    source_name: name.into(),
343                    source_id: if let Some(u) = url {
344                        SourceId::git(crate::types::SourceUrl::from(u))
345                    } else {
346                        SourceId::Path {
347                            canonical: tree.path().to_path_buf(),
348                            subpath: None,
349                        }
350                    },
351                    rooted_ref: crate::resolve::RootedSourceRef {
352                        checkout_root: tree.path().to_path_buf(),
353                        package_root: tree.path().to_path_buf(),
354                    },
355                    resolved_ref: ResolvedRef {
356                        source_name: name.into(),
357                        version: None,
358                        version_tag: None,
359                        commit: None,
360                        tree_path: tree.path().to_path_buf(),
361                    },
362                    latest_version: None,
363                    manifest: None,
364                    deps: vec![],
365                },
366            );
367            order.push(name.into());
368
369            let spec = if let Some(u) = url {
370                SourceSpec::Git(GitSpec {
371                    url: crate::types::SourceUrl::from(u),
372                    version: None,
373                })
374            } else {
375                SourceSpec::Path(tree.path().to_path_buf())
376            };
377
378            config_dependencies.insert(
379                name.into(),
380                EffectiveDependency {
381                    name: name.into(),
382                    id: if let Some(u) = url {
383                        SourceId::git(crate::types::SourceUrl::from(u))
384                    } else {
385                        SourceId::Path {
386                            canonical: tree.path().to_path_buf(),
387                            subpath: None,
388                        }
389                    },
390                    spec,
391                    subpath: None,
392                    filter,
393                    rename: RenameMap::new(),
394                    is_overridden: false,
395                    original_git: url_str.map(|u| GitSpec {
396                        url: crate::types::SourceUrl::from(u),
397                        version: None,
398                    }),
399                },
400            );
401        }
402
403        let graph = ResolvedGraph {
404            nodes,
405            order,
406            filters: std::collections::HashMap::new(),
407        };
408        let config = EffectiveConfig {
409            dependencies: config_dependencies,
410            settings: Settings::default(),
411        };
412        (graph, config)
413    }
414
415    // === Target build tests ===
416
417    #[test]
418    fn build_single_source_no_filter() {
419        let tree = make_source_tree(&[("coder.md", "# coder")], &[("planning", "# planning")]);
420        let (graph, config) = make_graph_and_config(vec![(
421            "base",
422            &tree,
423            Some("https://github.com/org/base"),
424            FilterMode::All,
425        )]);
426
427        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
428        assert!(renames.is_empty());
429        assert_eq!(target.items.len(), 2);
430        assert!(target.items.contains_key("agents/coder.md"));
431        assert!(target.items.contains_key("skills/planning"));
432    }
433
434    #[test]
435    fn build_with_path_rename_mapping() {
436        let tree = make_source_tree(&[("old-name.md", "# old")], &[]);
437
438        let (graph, mut config) = make_graph_and_config(vec![(
439            "base",
440            &tree,
441            Some("https://github.com/org/base"),
442            FilterMode::All,
443        )]);
444
445        // Add rename mapping
446        config
447            .dependencies
448            .get_mut("base")
449            .unwrap()
450            .rename
451            .insert("agents/old-name.md".into(), "agents/new-name.md".into());
452
453        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
454        assert!(renames.is_empty());
455        assert_eq!(target.items.len(), 1);
456        assert!(target.items.contains_key("agents/new-name.md"));
457        assert_eq!(target.items["agents/new-name.md"].id.name, "new-name");
458    }
459
460    #[test]
461    fn default_dest_path_uses_forward_slashes_for_agents_and_skills() {
462        let agent = default_dest_path(ItemKind::Agent, "coder");
463        let skill = default_dest_path(ItemKind::Skill, "planning");
464
465        assert_eq!(agent.as_str(), "agents/coder.md");
466        assert_eq!(skill.as_str(), "skills/planning");
467        assert!(!agent.as_str().contains('\\'));
468        assert!(!skill.as_str().contains('\\'));
469    }
470
471    #[test]
472    fn parse_rename_dest_normalizes_backslashes_to_forward_slashes() {
473        let source_name = SourceName::from("base");
474
475        let agent =
476            parse_rename_dest(ItemKind::Agent, r"agents\nested\renamed.md", &source_name).unwrap();
477        let skill =
478            parse_rename_dest(ItemKind::Skill, r"skills\nested\planning", &source_name).unwrap();
479
480        assert_eq!(agent.as_str(), "agents/nested/renamed.md");
481        assert_eq!(skill.as_str(), "skills/nested/planning");
482        assert!(!agent.as_str().contains('\\'));
483        assert!(!skill.as_str().contains('\\'));
484    }
485
486    #[test]
487    fn parse_rename_dest_rejects_absolute_and_escape_destinations() {
488        let source_name = SourceName::from("base");
489
490        let absolute = parse_rename_dest(ItemKind::Agent, "/tmp/escape", &source_name)
491            .expect_err("absolute rename should fail");
492        assert!(matches!(absolute, MarsError::Source { .. }));
493
494        let traversal = parse_rename_dest(ItemKind::Skill, "../escape", &source_name)
495            .expect_err("traversal rename should fail");
496        assert!(matches!(traversal, MarsError::Source { .. }));
497    }
498
499    #[test]
500    fn build_with_invalid_rename_destination_returns_error() {
501        let tree = make_source_tree(&[("old-name.md", "# old")], &[]);
502
503        let (graph, mut config) =
504            make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
505
506        config
507            .dependencies
508            .get_mut("base")
509            .unwrap()
510            .rename
511            .insert("agents/old-name.md".into(), "../escape.md".into());
512
513        let err = build_with_collisions(&graph, &config).unwrap_err();
514        assert!(matches!(err, MarsError::Source { .. }));
515    }
516
517    // === Collision tests ===
518
519    #[test]
520    fn collision_errors_instead_of_auto_renaming() {
521        let tree1 = make_source_tree(&[("coder.md", "# coder from source 1")], &[]);
522        let tree2 = make_source_tree(&[("coder.md", "# coder from source 2")], &[]);
523
524        let (graph, config) = make_graph_and_config(vec![
525            (
526                "source-a",
527                &tree1,
528                Some("https://github.com/alice/agents"),
529                FilterMode::All,
530            ),
531            (
532                "source-b",
533                &tree2,
534                Some("https://github.com/bob/agents"),
535                FilterMode::All,
536            ),
537        ]);
538
539        let err = build_with_collisions(&graph, &config).unwrap_err();
540        assert!(matches!(err, MarsError::Collision { .. }));
541    }
542
543    #[test]
544    fn no_collision_no_renames() {
545        let tree1 = make_source_tree(&[("coder.md", "# coder")], &[]);
546        let tree2 = make_source_tree(&[("reviewer.md", "# reviewer")], &[]);
547
548        let (graph, config) = make_graph_and_config(vec![
549            (
550                "source-a",
551                &tree1,
552                Some("https://github.com/alice/agents"),
553                FilterMode::All,
554            ),
555            (
556                "source-b",
557                &tree2,
558                Some("https://github.com/bob/agents"),
559                FilterMode::All,
560            ),
561        ]);
562
563        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
564        assert!(renames.is_empty());
565        assert_eq!(target.items.len(), 2);
566    }
567
568    // === Source with agents filter + skill deps ===
569
570    #[test]
571    fn build_with_agents_filter_pulls_transitive_skills() {
572        let tree = make_source_tree(
573            &[("coder.md", "---\nskills:\n  - planning\n---\n# Coder\n")],
574            &[("planning", "# Planning"), ("unused-skill", "# Unused")],
575        );
576
577        let (graph, config) = make_graph_and_config(vec![(
578            "base",
579            &tree,
580            None,
581            FilterMode::Include {
582                agents: vec!["coder".into()],
583                skills: vec![],
584            },
585        )]);
586
587        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
588        assert!(renames.is_empty());
589        assert_eq!(target.items.len(), 2); // coder + planning
590        assert!(target.items.contains_key("agents/coder.md"));
591        assert!(target.items.contains_key("skills/planning"));
592        // unused-skill should NOT be present
593        assert!(!target.items.contains_key("skills/unused-skill"));
594    }
595
596    #[test]
597    fn build_with_exclude_filter() {
598        let tree = make_source_tree(&[("coder.md", "# coder"), ("deprecated.md", "# old")], &[]);
599
600        let (graph, config) = make_graph_and_config(vec![(
601            "base",
602            &tree,
603            None,
604            FilterMode::Exclude(vec!["deprecated".into()]),
605        )]);
606
607        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
608        assert!(renames.is_empty());
609        assert_eq!(target.items.len(), 1);
610        assert!(target.items.contains_key("agents/coder.md"));
611    }
612
613    #[test]
614    fn build_unions_multiple_include_filters_for_same_source() {
615        let tree = make_source_tree(
616            &[],
617            &[
618                ("skill-a", "# Skill A"),
619                ("skill-b", "# Skill B"),
620                ("skill-c", "# Skill C"),
621            ],
622        );
623
624        let (mut graph, config) =
625            make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
626        graph.filters.insert(
627            "base".into(),
628            vec![
629                FilterMode::Include {
630                    agents: vec![],
631                    skills: vec!["skill-a".into(), "skill-b".into()],
632                },
633                FilterMode::Include {
634                    agents: vec![],
635                    skills: vec!["skill-b".into(), "skill-c".into()],
636                },
637            ],
638        );
639
640        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
641        assert!(renames.is_empty());
642        assert_eq!(target.items.len(), 3);
643        assert!(target.items.contains_key("skills/skill-a"));
644        assert!(target.items.contains_key("skills/skill-b"));
645        assert!(target.items.contains_key("skills/skill-c"));
646    }
647
648    #[test]
649    fn build_target_items_have_correct_hashes() {
650        let content = "# agent content for hash test";
651        let tree = make_source_tree(&[("test.md", content)], &[]);
652
653        let (graph, config) = make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
654
655        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
656        assert!(renames.is_empty());
657        let item = &target.items["agents/test.md"];
658        let expected_hash = hash::hash_bytes(content.as_bytes());
659        assert_eq!(item.source_hash, expected_hash);
660    }
661
662    #[test]
663    fn unmanaged_disk_path_collision_reported() {
664        let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
665        let (graph, config) = make_graph_and_config(vec![(
666            "base",
667            &tree,
668            Some("https://github.com/org/base"),
669            FilterMode::All,
670        )]);
671
672        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
673        assert!(renames.is_empty());
674        let install_root = TempDir::new().unwrap();
675
676        // Existing user-authored file at the same destination.
677        let existing = install_root.path().join("agents").join("coder.md");
678        fs::create_dir_all(existing.parent().unwrap()).unwrap();
679        fs::write(&existing, "# user-authored").unwrap();
680
681        let collisions =
682            check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
683        assert_eq!(collisions.len(), 1);
684        assert_eq!(collisions[0].source_name.as_ref(), "base");
685        assert_eq!(collisions[0].path.as_str(), "agents/coder.md");
686    }
687
688    #[test]
689    fn unmanaged_collision_skipped_when_hash_matches() {
690        let content = "# managed agent";
691        let tree = make_source_tree(&[("coder.md", content)], &[]);
692        let (graph, config) = make_graph_and_config(vec![(
693            "base",
694            &tree,
695            Some("https://github.com/org/base"),
696            FilterMode::All,
697        )]);
698
699        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
700        assert!(renames.is_empty());
701        let install_root = TempDir::new().unwrap();
702
703        // Simulate partial prior install: file on disk with same content
704        let existing = install_root.path().join("agents").join("coder.md");
705        fs::create_dir_all(existing.parent().unwrap()).unwrap();
706        fs::write(&existing, content).unwrap();
707
708        // Should skip collision — disk content matches planned install (crash recovery)
709        let collisions =
710            check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
711        assert!(collisions.is_empty());
712    }
713
714    #[test]
715    fn unmanaged_collision_reported_on_different_content() {
716        let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
717        let (graph, config) = make_graph_and_config(vec![(
718            "base",
719            &tree,
720            Some("https://github.com/org/base"),
721            FilterMode::All,
722        )]);
723
724        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
725        assert!(renames.is_empty());
726        let install_root = TempDir::new().unwrap();
727
728        // User-authored file with different content
729        let existing = install_root.path().join("agents").join("coder.md");
730        fs::create_dir_all(existing.parent().unwrap()).unwrap();
731        fs::write(&existing, "# different user content").unwrap();
732
733        let collisions =
734            check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
735        assert_eq!(collisions.len(), 1);
736        assert_eq!(collisions[0].source_name.as_ref(), "base");
737        assert_eq!(collisions[0].path.as_str(), "agents/coder.md");
738    }
739}