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