Skip to main content

mars_agents/sync/
target.rs

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