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#[derive(Debug, Clone)]
22pub struct TargetState {
23 pub items: IndexMap<DestPath, TargetItem>,
25}
26
27#[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 pub source_path: PathBuf,
36 pub dest_path: DestPath,
38 pub source_hash: ContentHash,
40 pub is_flat_skill: bool,
42 pub rewritten_content: Option<String>,
44}
45
46#[derive(Debug, Clone)]
48pub struct ExplicitSkillRename {
49 pub original_name: ItemName,
50 pub new_name: ItemName,
51 pub source_name: SourceName,
52}
53
54pub 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 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
197pub use crate::sync::rewrite::rewrite_skill_refs;
199
200#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct UnmanagedCollision {
203 pub source_name: SourceName,
204 pub path: DestPath,
205}
206
207pub 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 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
271fn 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 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 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 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 #[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 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 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 #[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 #[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); assert!(target.items.contains_key("agents/coder.md"));
675 assert!(target.items.contains_key("skills/planning"));
676 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 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 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 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 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}