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