Skip to main content

mars_agents/sync/
target.rs

1use std::collections::{HashMap, 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, Materialization, 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 materialization: Materialization,
33    pub source_id: SourceId,
34    /// Path to content in fetched source tree.
35    pub source_path: PathBuf,
36    /// Relative path under `.agents/` (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/// Rename action produced by collision detection.
47#[derive(Debug, Clone)]
48pub struct RenameAction {
49    pub original_name: ItemName,
50    pub new_name: ItemName,
51    pub source_name: SourceName,
52}
53
54/// Build target state with collision detection integrated.
55///
56/// This is the main entry point — it builds the target, detects collisions,
57/// applies auto-renames, and returns both the target state and rename actions.
58pub fn build_with_collisions(
59    graph: &ResolvedGraph,
60    config: &EffectiveConfig,
61) -> Result<(TargetState, Vec<RenameAction>), MarsError> {
62    // Phase 1: Collect all items without dedup
63    let mut all_items: Vec<TargetItem> = 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 =
70            discover::discover_source(&node.resolved_ref.tree_path, Some(source_name.as_str()))?;
71
72        let source_id = source_config
73            .map(|s| s.id.clone())
74            .unwrap_or_else(|| node.source_id.clone());
75
76        let 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            .unwrap_or_else(|| vec![FilterMode::All]);
83
84        let renames = source_config
85            .map(|s| &s.rename)
86            .cloned()
87            .unwrap_or_default();
88
89        let filtered = apply_filter_union(&discovered, &filters, &node.resolved_ref.tree_path)?;
90
91        for item in filtered {
92            let is_flat_skill =
93                item.id.kind == ItemKind::Skill && item.source_path == Path::new(".");
94            let source_content_path = node.resolved_ref.tree_path.join(&item.source_path);
95            let source_hash = if is_flat_skill {
96                ContentHash::from(hash::compute_skill_hash_filtered(
97                    &source_content_path,
98                    crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
99                )?)
100            } else {
101                ContentHash::from(hash::compute_hash(&source_content_path, item.id.kind)?)
102            };
103
104            let (dest_name, dest_path) = apply_item_rename(item.id.kind, &item.id.name, &renames);
105
106            all_items.push(TargetItem {
107                id: ItemId {
108                    kind: item.id.kind,
109                    name: dest_name,
110                },
111                source_name: source_name.clone(),
112                origin: SourceOrigin::Dependency(source_name.clone()),
113                materialization: Materialization::Copy,
114                source_id: source_id.clone(),
115                source_path: source_content_path,
116                dest_path,
117                source_hash,
118                is_flat_skill,
119                rewritten_content: None,
120            });
121        }
122    }
123
124    // Phase 2: Detect collisions on dest_path
125    let mut dest_counts: HashMap<DestPath, Vec<usize>> = HashMap::new();
126    for (idx, item) in all_items.iter().enumerate() {
127        let key = item.dest_path.clone();
128        dest_counts.entry(key).or_default().push(idx);
129    }
130
131    let mut rename_actions = Vec::new();
132
133    // Phase 3: Apply auto-rename for collisions
134    for indices in dest_counts.values() {
135        if indices.len() <= 1 {
136            continue;
137        }
138
139        // Collision detected — rename all colliding items
140        for &idx in indices {
141            let item = &all_items[idx];
142            let original_name = item.id.name.clone();
143
144            // Extract owner_repo suffix from source URL or source name
145            let suffix = extract_owner_repo_from_id(&item.source_id, &item.source_name);
146            let new_name = format!("{original_name}__{suffix}");
147            let new_item_name = ItemName::from(new_name.clone());
148
149            let new_dest_path = DestPath::from(match item.id.kind {
150                ItemKind::Agent => PathBuf::from("agents").join(format!("{new_name}.md")),
151                ItemKind::Skill => PathBuf::from("skills").join(&new_name),
152            });
153
154            rename_actions.push(RenameAction {
155                original_name: original_name.clone(),
156                new_name: new_item_name.clone(),
157                source_name: item.source_name.clone(),
158            });
159
160            // Apply rename in-place
161            let item_mut = &mut all_items[idx];
162            item_mut.id.name = new_item_name;
163            item_mut.dest_path = new_dest_path;
164        }
165    }
166
167    // Phase 4: Build final TargetState from (possibly renamed) items
168    let mut items = IndexMap::new();
169    for item in all_items {
170        let key = item.dest_path.clone();
171        items.insert(key, item);
172    }
173
174    Ok((TargetState { items }, rename_actions))
175}
176
177fn apply_filter_union(
178    discovered: &[discover::DiscoveredItem],
179    filters: &[FilterMode],
180    tree_path: &Path,
181) -> Result<Vec<discover::DiscoveredItem>, MarsError> {
182    if filters.is_empty() {
183        return Ok(discovered.to_vec());
184    }
185
186    let mut union: HashSet<(ItemKind, ItemName, PathBuf)> = HashSet::new();
187    for filter in filters {
188        let filtered = apply_filter(discovered, filter, tree_path)?;
189        union.extend(
190            filtered
191                .iter()
192                .map(|item| (item.id.kind, item.id.name.clone(), item.source_path.clone())),
193        );
194    }
195
196    Ok(discovered
197        .iter()
198        .filter(|item| {
199            union.contains(&(item.id.kind, item.id.name.clone(), item.source_path.clone()))
200        })
201        .cloned()
202        .collect())
203}
204
205// Re-export for API compatibility — rewrite_skill_refs moved to sync::rewrite.
206pub use crate::sync::rewrite::rewrite_skill_refs;
207
208/// Existing on-disk destination that is not lock-managed.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct UnmanagedCollision {
211    pub source_name: SourceName,
212    pub path: DestPath,
213}
214
215/// Detect target installs that would overwrite unmanaged on-disk content.
216///
217/// If a target destination already exists but is not tracked in the lock file,
218/// treat it as user-authored content and report it as a collision so callers can
219/// skip installation while leaving existing files untouched.
220pub fn check_unmanaged_collisions(
221    install_target: &Path,
222    lock: &LockFile,
223    target: &TargetState,
224) -> Vec<UnmanagedCollision> {
225    let mut collisions = Vec::new();
226
227    for (dest_key, target_item) in &target.items {
228        if lock.items.contains_key(dest_key) {
229            continue;
230        }
231
232        let disk_path = install_target.join(&target_item.dest_path);
233        if disk_path.exists() {
234            // Check if disk content matches what we'd install — if so,
235            // this is a partial prior install (crash recovery), not an
236            // unmanaged user file. Safe to overwrite.
237            if let Ok(disk_hash) = hash::compute_hash(&disk_path, target_item.id.kind)
238                && disk_hash == target_item.source_hash.as_str()
239            {
240                continue;
241            }
242
243            collisions.push(UnmanagedCollision {
244                source_name: target_item.source_name.clone(),
245                path: target_item.dest_path.clone(),
246            });
247        }
248    }
249
250    collisions
251}
252
253fn apply_item_rename(kind: ItemKind, item_name: &str, renames: &RenameMap) -> (ItemName, DestPath) {
254    let default_dest = default_dest_path(kind, item_name);
255    let default_key = default_dest.to_string_lossy().to_string();
256
257    let rename_value = renames.get(&default_key).or_else(|| renames.get(item_name));
258
259    let dest_path = match rename_value {
260        Some(value) => parse_rename_dest(kind, value.as_str()),
261        None => default_dest,
262    };
263    let dest_name = dest_name_from_path(kind, &dest_path);
264
265    (ItemName::from(dest_name), DestPath::from(dest_path))
266}
267
268fn default_dest_path(kind: ItemKind, name: &str) -> PathBuf {
269    match kind {
270        ItemKind::Agent => PathBuf::from("agents").join(format!("{name}.md")),
271        ItemKind::Skill => PathBuf::from("skills").join(name),
272    }
273}
274
275fn parse_rename_dest(kind: ItemKind, rename_value: &str) -> PathBuf {
276    let value = PathBuf::from(rename_value);
277    let has_prefix = value.starts_with("agents") || value.starts_with("skills");
278    let has_parent = value.parent().is_some_and(|p| p != Path::new(""));
279
280    if has_prefix || has_parent {
281        return value;
282    }
283
284    match kind {
285        ItemKind::Agent => {
286            if rename_value.ends_with(".md") {
287                PathBuf::from("agents").join(rename_value)
288            } else {
289                PathBuf::from("agents").join(format!("{rename_value}.md"))
290            }
291        }
292        ItemKind::Skill => PathBuf::from("skills").join(rename_value),
293    }
294}
295
296fn dest_name_from_path(kind: ItemKind, path: &Path) -> String {
297    match kind {
298        ItemKind::Agent => path
299            .file_stem()
300            .map(|s| s.to_string_lossy().to_string())
301            .unwrap_or_default(),
302        ItemKind::Skill => path
303            .file_name()
304            .map(|s| s.to_string_lossy().to_string())
305            .unwrap_or_default(),
306    }
307}
308
309/// Extract `{owner}_{repo}` from a source URL.
310///
311/// For git URLs like `github.com/meridian-flow/meridian-base`, extracts `meridian-flow_meridian-base`.
312/// For path sources, uses the source name directly.
313pub fn extract_owner_repo(url: Option<&str>, source_name: &str) -> String {
314    if let Some(url) = url {
315        // Try to extract from URL patterns:
316        // github.com/owner/repo, https://github.com/owner/repo.git, etc.
317        let cleaned = url.trim_end_matches('/').trim_end_matches(".git");
318
319        // Strip protocol
320        let without_proto = cleaned
321            .strip_prefix("https://")
322            .or_else(|| cleaned.strip_prefix("http://"))
323            .or_else(|| cleaned.strip_prefix("ssh://"))
324            .or_else(|| cleaned.strip_prefix("git://"))
325            .unwrap_or(cleaned);
326
327        // Handle git@ SSH format: git@github.com:owner/repo
328        let normalized = if let Some(rest) = without_proto.strip_prefix("git@") {
329            rest.replacen(':', "/", 1)
330        } else {
331            without_proto.to_string()
332        };
333
334        // Split by '/' and take last two parts as owner/repo
335        let parts: Vec<&str> = normalized.split('/').collect();
336        if parts.len() >= 2 {
337            let owner = parts[parts.len() - 2];
338            let repo = parts[parts.len() - 1];
339            return format!("{owner}_{repo}");
340        }
341    }
342
343    // Fallback: use source name
344    source_name.to_string()
345}
346
347fn extract_owner_repo_from_id(source_id: &SourceId, source_name: &str) -> String {
348    match source_id {
349        SourceId::Git { url } => extract_owner_repo(Some(url.as_ref()), source_name),
350        SourceId::Path { .. } => extract_owner_repo(None, source_name),
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::config::*;
358    use crate::lock::LockFile;
359    use crate::resolve::{ResolvedGraph, ResolvedNode};
360    use crate::source::ResolvedRef;
361    use indexmap::IndexMap;
362    use std::fs;
363    use tempfile::TempDir;
364
365    /// Helper: create a source tree with agents and skills
366    fn make_source_tree(agents: &[(&str, &str)], skills: &[(&str, &str)]) -> TempDir {
367        let dir = TempDir::new().unwrap();
368        if !agents.is_empty() {
369            let agents_dir = dir.path().join("agents");
370            fs::create_dir_all(&agents_dir).unwrap();
371            for (name, content) in agents {
372                fs::write(agents_dir.join(name), content).unwrap();
373            }
374        }
375        if !skills.is_empty() {
376            let skills_dir = dir.path().join("skills");
377            fs::create_dir_all(&skills_dir).unwrap();
378            for (name, content) in skills {
379                let skill_dir = skills_dir.join(name);
380                fs::create_dir_all(&skill_dir).unwrap();
381                fs::write(skill_dir.join("SKILL.md"), content).unwrap();
382            }
383        }
384        dir
385    }
386
387    fn make_graph_and_config(
388        sources: Vec<(&str, &TempDir, Option<&str>, FilterMode)>,
389    ) -> (ResolvedGraph, EffectiveConfig) {
390        let mut nodes = IndexMap::new();
391        let mut order = Vec::new();
392        let mut config_dependencies = IndexMap::new();
393
394        for (name, tree, url, filter) in sources {
395            let url_str = url.map(|u| u.to_string());
396            nodes.insert(
397                name.into(),
398                ResolvedNode {
399                    source_name: name.into(),
400                    source_id: if let Some(u) = url {
401                        SourceId::git(crate::types::SourceUrl::from(u))
402                    } else {
403                        SourceId::Path {
404                            canonical: tree.path().to_path_buf(),
405                        }
406                    },
407                    resolved_ref: ResolvedRef {
408                        source_name: name.into(),
409                        version: None,
410                        version_tag: None,
411                        commit: None,
412                        tree_path: tree.path().to_path_buf(),
413                    },
414                    manifest: None,
415                    deps: vec![],
416                },
417            );
418            order.push(name.into());
419
420            let spec = if let Some(u) = url {
421                SourceSpec::Git(GitSpec {
422                    url: crate::types::SourceUrl::from(u),
423                    version: None,
424                })
425            } else {
426                SourceSpec::Path(tree.path().to_path_buf())
427            };
428
429            config_dependencies.insert(
430                name.into(),
431                EffectiveDependency {
432                    name: name.into(),
433                    id: if let Some(u) = url {
434                        SourceId::git(crate::types::SourceUrl::from(u))
435                    } else {
436                        SourceId::Path {
437                            canonical: tree.path().to_path_buf(),
438                        }
439                    },
440                    spec,
441                    filter,
442                    rename: RenameMap::new(),
443                    is_overridden: false,
444                    original_git: url_str.map(|u| GitSpec {
445                        url: crate::types::SourceUrl::from(u),
446                        version: None,
447                    }),
448                },
449            );
450        }
451
452        let graph = ResolvedGraph {
453            nodes,
454            order,
455            id_index: std::collections::HashMap::new(),
456            filters: std::collections::HashMap::new(),
457        };
458        let config = EffectiveConfig {
459            dependencies: config_dependencies,
460            settings: Settings::default(),
461        };
462        (graph, config)
463    }
464
465    // === extract_owner_repo tests ===
466
467    #[test]
468    fn extract_github_https_url() {
469        let result = extract_owner_repo(
470            Some("https://github.com/meridian-flow/meridian-base"),
471            "base",
472        );
473        assert_eq!(result, "meridian-flow_meridian-base");
474    }
475
476    #[test]
477    fn extract_github_https_with_git_suffix() {
478        let result = extract_owner_repo(
479            Some("https://github.com/meridian-flow/meridian-base.git"),
480            "base",
481        );
482        assert_eq!(result, "meridian-flow_meridian-base");
483    }
484
485    #[test]
486    fn extract_github_ssh_url() {
487        let result = extract_owner_repo(
488            Some("git@github.com:meridian-flow/meridian-base.git"),
489            "base",
490        );
491        assert_eq!(result, "meridian-flow_meridian-base");
492    }
493
494    #[test]
495    fn extract_bare_github_url() {
496        let result = extract_owner_repo(Some("github.com/someone/cool-agents"), "cool");
497        assert_eq!(result, "someone_cool-agents");
498    }
499
500    #[test]
501    fn extract_fallback_to_source_name() {
502        let result = extract_owner_repo(None, "my-source");
503        assert_eq!(result, "my-source");
504    }
505
506    #[test]
507    fn extract_from_short_url() {
508        let result = extract_owner_repo(Some("single-segment"), "fallback");
509        assert_eq!(result, "fallback");
510    }
511
512    // === Target build tests ===
513
514    #[test]
515    fn build_single_source_no_filter() {
516        let tree = make_source_tree(&[("coder.md", "# coder")], &[("planning", "# planning")]);
517        let (graph, config) = make_graph_and_config(vec![(
518            "base",
519            &tree,
520            Some("https://github.com/org/base"),
521            FilterMode::All,
522        )]);
523
524        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
525        assert!(renames.is_empty());
526        assert_eq!(target.items.len(), 2);
527        assert!(target.items.contains_key("agents/coder.md"));
528        assert!(target.items.contains_key("skills/planning"));
529    }
530
531    #[test]
532    fn build_with_path_rename_mapping() {
533        let tree = make_source_tree(&[("old-name.md", "# old")], &[]);
534
535        let (graph, mut config) = make_graph_and_config(vec![(
536            "base",
537            &tree,
538            Some("https://github.com/org/base"),
539            FilterMode::All,
540        )]);
541
542        // Add rename mapping
543        config
544            .dependencies
545            .get_mut("base")
546            .unwrap()
547            .rename
548            .insert("agents/old-name.md".into(), "agents/new-name.md".into());
549
550        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
551        assert!(renames.is_empty());
552        assert_eq!(target.items.len(), 1);
553        assert!(target.items.contains_key("agents/new-name.md"));
554        assert_eq!(target.items["agents/new-name.md"].id.name, "new-name");
555    }
556
557    // === Collision tests ===
558
559    #[test]
560    fn collision_auto_renames_both() {
561        let tree1 = make_source_tree(&[("coder.md", "# coder from source 1")], &[]);
562        let tree2 = make_source_tree(&[("coder.md", "# coder from source 2")], &[]);
563
564        let (graph, config) = make_graph_and_config(vec![
565            (
566                "source-a",
567                &tree1,
568                Some("https://github.com/alice/agents"),
569                FilterMode::All,
570            ),
571            (
572                "source-b",
573                &tree2,
574                Some("https://github.com/bob/agents"),
575                FilterMode::All,
576            ),
577        ]);
578
579        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
580        assert_eq!(renames.len(), 2);
581        assert_eq!(target.items.len(), 2);
582
583        // Both should have been renamed
584        let names: Vec<&str> = target.items.values().map(|i| i.id.name.as_str()).collect();
585        assert!(names.contains(&"coder__alice_agents"));
586        assert!(names.contains(&"coder__bob_agents"));
587    }
588
589    #[test]
590    fn no_collision_no_renames() {
591        let tree1 = make_source_tree(&[("coder.md", "# coder")], &[]);
592        let tree2 = make_source_tree(&[("reviewer.md", "# reviewer")], &[]);
593
594        let (graph, config) = make_graph_and_config(vec![
595            (
596                "source-a",
597                &tree1,
598                Some("https://github.com/alice/agents"),
599                FilterMode::All,
600            ),
601            (
602                "source-b",
603                &tree2,
604                Some("https://github.com/bob/agents"),
605                FilterMode::All,
606            ),
607        ]);
608
609        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
610        assert!(renames.is_empty());
611        assert_eq!(target.items.len(), 2);
612    }
613
614    // === Source with agents filter + skill deps ===
615
616    #[test]
617    fn build_with_agents_filter_pulls_transitive_skills() {
618        let tree = make_source_tree(
619            &[("coder.md", "---\nskills:\n  - planning\n---\n# Coder\n")],
620            &[("planning", "# Planning"), ("unused-skill", "# Unused")],
621        );
622
623        let (graph, config) = make_graph_and_config(vec![(
624            "base",
625            &tree,
626            None,
627            FilterMode::Include {
628                agents: vec!["coder".into()],
629                skills: vec![],
630            },
631        )]);
632
633        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
634        assert!(renames.is_empty());
635        assert_eq!(target.items.len(), 2); // coder + planning
636        assert!(target.items.contains_key("agents/coder.md"));
637        assert!(target.items.contains_key("skills/planning"));
638        // unused-skill should NOT be present
639        assert!(!target.items.contains_key("skills/unused-skill"));
640    }
641
642    #[test]
643    fn build_with_exclude_filter() {
644        let tree = make_source_tree(&[("coder.md", "# coder"), ("deprecated.md", "# old")], &[]);
645
646        let (graph, config) = make_graph_and_config(vec![(
647            "base",
648            &tree,
649            None,
650            FilterMode::Exclude(vec!["deprecated".into()]),
651        )]);
652
653        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
654        assert!(renames.is_empty());
655        assert_eq!(target.items.len(), 1);
656        assert!(target.items.contains_key("agents/coder.md"));
657    }
658
659    #[test]
660    fn build_unions_multiple_include_filters_for_same_source() {
661        let tree = make_source_tree(
662            &[],
663            &[
664                ("skill-a", "# Skill A"),
665                ("skill-b", "# Skill B"),
666                ("skill-c", "# Skill C"),
667            ],
668        );
669
670        let (mut graph, config) =
671            make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
672        graph.filters.insert(
673            "base".into(),
674            vec![
675                FilterMode::Include {
676                    agents: vec![],
677                    skills: vec!["skill-a".into(), "skill-b".into()],
678                },
679                FilterMode::Include {
680                    agents: vec![],
681                    skills: vec!["skill-b".into(), "skill-c".into()],
682                },
683            ],
684        );
685
686        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
687        assert!(renames.is_empty());
688        assert_eq!(target.items.len(), 3);
689        assert!(target.items.contains_key("skills/skill-a"));
690        assert!(target.items.contains_key("skills/skill-b"));
691        assert!(target.items.contains_key("skills/skill-c"));
692    }
693
694    #[test]
695    fn build_target_items_have_correct_hashes() {
696        let content = "# agent content for hash test";
697        let tree = make_source_tree(&[("test.md", content)], &[]);
698
699        let (graph, config) = make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
700
701        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
702        assert!(renames.is_empty());
703        let item = &target.items["agents/test.md"];
704        let expected_hash = hash::hash_bytes(content.as_bytes());
705        assert_eq!(item.source_hash, expected_hash);
706    }
707
708    #[test]
709    fn unmanaged_disk_path_collision_reported() {
710        let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
711        let (graph, config) = make_graph_and_config(vec![(
712            "base",
713            &tree,
714            Some("https://github.com/org/base"),
715            FilterMode::All,
716        )]);
717
718        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
719        assert!(renames.is_empty());
720        let install_root = TempDir::new().unwrap();
721
722        // Existing user-authored file at the same destination.
723        let existing = install_root.path().join("agents").join("coder.md");
724        fs::create_dir_all(existing.parent().unwrap()).unwrap();
725        fs::write(&existing, "# user-authored").unwrap();
726
727        let collisions =
728            check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
729        assert_eq!(collisions.len(), 1);
730        assert_eq!(collisions[0].source_name.as_ref(), "base");
731        assert_eq!(
732            collisions[0].path.as_ref().to_string_lossy(),
733            "agents/coder.md"
734        );
735    }
736
737    #[test]
738    fn unmanaged_collision_skipped_when_hash_matches() {
739        let content = "# managed agent";
740        let tree = make_source_tree(&[("coder.md", content)], &[]);
741        let (graph, config) = make_graph_and_config(vec![(
742            "base",
743            &tree,
744            Some("https://github.com/org/base"),
745            FilterMode::All,
746        )]);
747
748        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
749        assert!(renames.is_empty());
750        let install_root = TempDir::new().unwrap();
751
752        // Simulate partial prior install: file on disk with same content
753        let existing = install_root.path().join("agents").join("coder.md");
754        fs::create_dir_all(existing.parent().unwrap()).unwrap();
755        fs::write(&existing, content).unwrap();
756
757        // Should skip collision — disk content matches planned install (crash recovery)
758        let collisions =
759            check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
760        assert!(collisions.is_empty());
761    }
762
763    #[test]
764    fn unmanaged_collision_reported_on_different_content() {
765        let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
766        let (graph, config) = make_graph_and_config(vec![(
767            "base",
768            &tree,
769            Some("https://github.com/org/base"),
770            FilterMode::All,
771        )]);
772
773        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
774        assert!(renames.is_empty());
775        let install_root = TempDir::new().unwrap();
776
777        // User-authored file with different content
778        let existing = install_root.path().join("agents").join("coder.md");
779        fs::create_dir_all(existing.parent().unwrap()).unwrap();
780        fs::write(&existing, "# different user content").unwrap();
781
782        let collisions =
783            check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
784        assert_eq!(collisions.len(), 1);
785        assert_eq!(collisions[0].source_name.as_ref(), "base");
786        assert_eq!(
787            collisions[0].path.as_ref().to_string_lossy(),
788            "agents/coder.md"
789        );
790    }
791}