1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use indexmap::IndexMap;
5
6use crate::config::{EffectiveConfig, FilterMode};
7use crate::discover;
8use crate::error::MarsError;
9use crate::hash;
10use crate::lock::{ItemId, ItemKind, LockFile};
11use crate::resolve::ResolvedGraph;
12use crate::sync::filter::apply_filter;
13use crate::types::{
14 ContentHash, DestPath, ItemName, RenameMap, SourceId, SourceName, SourceOrigin,
15};
16
17#[derive(Debug, Clone)]
21pub struct TargetState {
22 pub items: IndexMap<DestPath, TargetItem>,
24}
25
26#[derive(Debug, Clone)]
28pub struct TargetItem {
29 pub id: ItemId,
30 pub source_name: SourceName,
31 pub origin: SourceOrigin,
32 pub source_id: SourceId,
33 pub source_path: PathBuf,
35 pub dest_path: DestPath,
37 pub source_hash: ContentHash,
39 pub is_flat_skill: bool,
41 pub rewritten_content: Option<String>,
43}
44
45#[derive(Debug, Clone)]
47pub struct ExplicitSkillRename {
48 pub original_name: ItemName,
49 pub new_name: ItemName,
50 pub source_name: SourceName,
51}
52
53pub fn build_with_collisions(
59 graph: &ResolvedGraph,
60 config: &EffectiveConfig,
61) -> Result<(TargetState, Vec<ExplicitSkillRename>), MarsError> {
62 let mut items: IndexMap<DestPath, TargetItem> = IndexMap::new();
63 let mut explicit_skill_renames = Vec::new();
64
65 for source_name in &graph.order {
66 let node = &graph.nodes[source_name];
67 let source_config = config.dependencies.get(source_name);
68
69 let discovered = discover::discover_resolved_source(
70 &node.rooted_ref.package_root,
71 Some(source_name.as_str()),
72 )?;
73
74 let source_id = source_config
75 .map(|s| s.id.clone())
76 .unwrap_or_else(|| node.source_id.clone());
77
78 let Some(filters) = graph
79 .filters
80 .get(source_name)
81 .filter(|filters| !filters.is_empty())
82 .cloned()
83 .or_else(|| source_config.map(|source| vec![source.filter.clone()]))
84 else {
85 continue;
87 };
88
89 let renames = source_config
90 .map(|s| &s.rename)
91 .cloned()
92 .unwrap_or_default();
93
94 let filtered = apply_filter_union(&discovered, &filters, &node.rooted_ref.package_root)?;
95
96 for item in filtered {
97 let is_flat_skill =
98 item.id.kind == ItemKind::Skill && item.source_path == Path::new(".");
99 let source_content_path = node.rooted_ref.package_root.join(&item.source_path);
100 let source_hash = if is_flat_skill {
101 ContentHash::from(hash::compute_skill_hash_filtered(
102 &source_content_path,
103 crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
104 )?)
105 } else {
106 ContentHash::from(hash::compute_hash(&source_content_path, item.id.kind)?)
107 };
108
109 let (dest_name, dest_path) =
110 apply_item_rename(item.id.kind, &item.id.name, &renames, source_name)?;
111 if item.id.kind == ItemKind::Skill && dest_name != item.id.name {
112 explicit_skill_renames.push(ExplicitSkillRename {
113 original_name: item.id.name.clone(),
114 new_name: dest_name.clone(),
115 source_name: source_name.clone(),
116 });
117 }
118
119 let target_item = TargetItem {
120 id: ItemId {
121 kind: item.id.kind,
122 name: dest_name,
123 },
124 source_name: source_name.clone(),
125 origin: SourceOrigin::Dependency(source_name.clone()),
126 source_id: source_id.clone(),
127 source_path: source_content_path,
128 dest_path,
129 source_hash,
130 is_flat_skill,
131 rewritten_content: None,
132 };
133
134 if let Some(existing) = items.get(&target_item.dest_path) {
135 return Err(MarsError::Collision {
136 item: format!("{} `{}`", target_item.id.kind, target_item.id.name),
137 source_a: existing.source_name.to_string(),
138 source_b: target_item.source_name.to_string(),
139 });
140 }
141
142 items.insert(target_item.dest_path.clone(), target_item);
143 }
144 }
145
146 Ok((TargetState { items }, explicit_skill_renames))
147}
148
149fn apply_filter_union(
150 discovered: &[discover::DiscoveredItem],
151 filters: &[FilterMode],
152 package_root: &Path,
153) -> Result<Vec<discover::DiscoveredItem>, MarsError> {
154 if filters.is_empty() {
155 return Ok(discovered.to_vec());
156 }
157
158 let mut union: HashSet<(ItemKind, ItemName, PathBuf)> = HashSet::new();
159 for filter in filters {
160 let filtered = apply_filter(discovered, filter, package_root)?;
161 union.extend(
162 filtered
163 .iter()
164 .map(|item| (item.id.kind, item.id.name.clone(), item.source_path.clone())),
165 );
166 }
167
168 Ok(discovered
169 .iter()
170 .filter(|item| {
171 union.contains(&(item.id.kind, item.id.name.clone(), item.source_path.clone()))
172 })
173 .cloned()
174 .collect())
175}
176
177pub use crate::sync::rewrite::rewrite_skill_refs;
179
180#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct UnmanagedCollision {
183 pub source_name: SourceName,
184 pub path: DestPath,
185}
186
187pub fn check_unmanaged_collisions(
193 install_target: &Path,
194 lock: &LockFile,
195 target: &TargetState,
196) -> Vec<UnmanagedCollision> {
197 let mut collisions = Vec::new();
198
199 for (dest_key, target_item) in &target.items {
200 if lock.items.contains_key(dest_key) {
201 continue;
202 }
203
204 let disk_path = target_item.dest_path.resolve(install_target);
205 if disk_path.exists() {
206 if let Ok(disk_hash) = hash::compute_hash(&disk_path, target_item.id.kind)
210 && disk_hash == target_item.source_hash.as_str()
211 {
212 continue;
213 }
214
215 collisions.push(UnmanagedCollision {
216 source_name: target_item.source_name.clone(),
217 path: target_item.dest_path.clone(),
218 });
219 }
220 }
221
222 collisions
223}
224
225fn apply_item_rename(
226 kind: ItemKind,
227 item_name: &str,
228 renames: &RenameMap,
229 source_name: &SourceName,
230) -> Result<(ItemName, DestPath), MarsError> {
231 let default_dest = default_dest_path(kind, item_name);
232 let default_key = default_dest.as_str();
233
234 let rename_value = renames.get(default_key).or_else(|| renames.get(item_name));
235
236 let dest_path = match rename_value {
237 Some(value) => parse_rename_dest(kind, value.as_str(), source_name)?,
238 None => default_dest,
239 };
240 let dest_name = dest_name_from_dest(&dest_path, kind);
241
242 Ok((ItemName::from(dest_name), dest_path))
243}
244
245fn default_dest_path(kind: ItemKind, name: &str) -> DestPath {
248 let path_str = match kind {
249 ItemKind::Agent => format!("agents/{name}.md"),
250 ItemKind::Skill => format!("skills/{name}"),
251 };
252 DestPath::new(path_str).expect("internal default path is always valid")
254}
255
256fn parse_rename_dest(
257 kind: ItemKind,
258 rename_value: &str,
259 source_name: &SourceName,
260) -> Result<DestPath, MarsError> {
261 let normalized = rename_value.replace('\\', "/");
263 let has_prefix = normalized.starts_with("agents/") || normalized.starts_with("skills/");
264 let has_parent = normalized.contains('/');
265
266 if has_prefix || has_parent {
267 return DestPath::new(&normalized).map_err(|e| MarsError::Source {
268 source_name: source_name.to_string(),
269 message: format!("invalid rename destination `{rename_value}`: {e}"),
270 });
271 }
272
273 let path_str = match kind {
274 ItemKind::Agent => {
275 if normalized.ends_with(".md") {
276 format!("agents/{normalized}")
277 } else {
278 format!("agents/{normalized}.md")
279 }
280 }
281 ItemKind::Skill => format!("skills/{normalized}"),
282 };
283 DestPath::new(path_str).map_err(|e| MarsError::Source {
284 source_name: source_name.to_string(),
285 message: format!("invalid rename destination `{rename_value}`: {e}"),
286 })
287}
288
289fn dest_name_from_dest(dest_path: &DestPath, kind: ItemKind) -> String {
290 let last = dest_path.as_str().rsplit('/').next().unwrap_or("");
291 match kind {
292 ItemKind::Agent => last.strip_suffix(".md").unwrap_or(last).to_string(),
293 ItemKind::Skill => last.to_string(),
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use crate::config::*;
301 use crate::lock::LockFile;
302 use crate::resolve::{ResolvedGraph, ResolvedNode};
303 use crate::source::ResolvedRef;
304 use indexmap::IndexMap;
305 use std::fs;
306 use tempfile::TempDir;
307
308 fn make_source_tree(agents: &[(&str, &str)], skills: &[(&str, &str)]) -> TempDir {
310 let dir = TempDir::new().unwrap();
311 if !agents.is_empty() {
312 let agents_dir = dir.path().join("agents");
313 fs::create_dir_all(&agents_dir).unwrap();
314 for (name, content) in agents {
315 fs::write(agents_dir.join(name), content).unwrap();
316 }
317 }
318 if !skills.is_empty() {
319 let skills_dir = dir.path().join("skills");
320 fs::create_dir_all(&skills_dir).unwrap();
321 for (name, content) in skills {
322 let skill_dir = skills_dir.join(name);
323 fs::create_dir_all(&skill_dir).unwrap();
324 fs::write(skill_dir.join("SKILL.md"), content).unwrap();
325 }
326 }
327 dir
328 }
329
330 fn make_graph_and_config(
331 sources: Vec<(&str, &TempDir, Option<&str>, FilterMode)>,
332 ) -> (ResolvedGraph, EffectiveConfig) {
333 let mut nodes = IndexMap::new();
334 let mut order = Vec::new();
335 let mut config_dependencies = IndexMap::new();
336
337 for (name, tree, url, filter) in sources {
338 let url_str = url.map(|u| u.to_string());
339 nodes.insert(
340 name.into(),
341 ResolvedNode {
342 source_name: name.into(),
343 source_id: if let Some(u) = url {
344 SourceId::git(crate::types::SourceUrl::from(u))
345 } else {
346 SourceId::Path {
347 canonical: tree.path().to_path_buf(),
348 subpath: None,
349 }
350 },
351 rooted_ref: crate::resolve::RootedSourceRef {
352 checkout_root: tree.path().to_path_buf(),
353 package_root: tree.path().to_path_buf(),
354 },
355 resolved_ref: ResolvedRef {
356 source_name: name.into(),
357 version: None,
358 version_tag: None,
359 commit: None,
360 tree_path: tree.path().to_path_buf(),
361 },
362 latest_version: None,
363 manifest: None,
364 deps: vec![],
365 },
366 );
367 order.push(name.into());
368
369 let spec = if let Some(u) = url {
370 SourceSpec::Git(GitSpec {
371 url: crate::types::SourceUrl::from(u),
372 version: None,
373 })
374 } else {
375 SourceSpec::Path(tree.path().to_path_buf())
376 };
377
378 config_dependencies.insert(
379 name.into(),
380 EffectiveDependency {
381 name: name.into(),
382 id: if let Some(u) = url {
383 SourceId::git(crate::types::SourceUrl::from(u))
384 } else {
385 SourceId::Path {
386 canonical: tree.path().to_path_buf(),
387 subpath: None,
388 }
389 },
390 spec,
391 subpath: None,
392 filter,
393 rename: RenameMap::new(),
394 is_overridden: false,
395 original_git: url_str.map(|u| GitSpec {
396 url: crate::types::SourceUrl::from(u),
397 version: None,
398 }),
399 },
400 );
401 }
402
403 let graph = ResolvedGraph {
404 nodes,
405 order,
406 filters: std::collections::HashMap::new(),
407 };
408 let config = EffectiveConfig {
409 dependencies: config_dependencies,
410 settings: Settings::default(),
411 };
412 (graph, config)
413 }
414
415 #[test]
418 fn build_single_source_no_filter() {
419 let tree = make_source_tree(&[("coder.md", "# coder")], &[("planning", "# planning")]);
420 let (graph, config) = make_graph_and_config(vec![(
421 "base",
422 &tree,
423 Some("https://github.com/org/base"),
424 FilterMode::All,
425 )]);
426
427 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
428 assert!(renames.is_empty());
429 assert_eq!(target.items.len(), 2);
430 assert!(target.items.contains_key("agents/coder.md"));
431 assert!(target.items.contains_key("skills/planning"));
432 }
433
434 #[test]
435 fn build_with_path_rename_mapping() {
436 let tree = make_source_tree(&[("old-name.md", "# old")], &[]);
437
438 let (graph, mut config) = make_graph_and_config(vec![(
439 "base",
440 &tree,
441 Some("https://github.com/org/base"),
442 FilterMode::All,
443 )]);
444
445 config
447 .dependencies
448 .get_mut("base")
449 .unwrap()
450 .rename
451 .insert("agents/old-name.md".into(), "agents/new-name.md".into());
452
453 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
454 assert!(renames.is_empty());
455 assert_eq!(target.items.len(), 1);
456 assert!(target.items.contains_key("agents/new-name.md"));
457 assert_eq!(target.items["agents/new-name.md"].id.name, "new-name");
458 }
459
460 #[test]
461 fn default_dest_path_uses_forward_slashes_for_agents_and_skills() {
462 let agent = default_dest_path(ItemKind::Agent, "coder");
463 let skill = default_dest_path(ItemKind::Skill, "planning");
464
465 assert_eq!(agent.as_str(), "agents/coder.md");
466 assert_eq!(skill.as_str(), "skills/planning");
467 assert!(!agent.as_str().contains('\\'));
468 assert!(!skill.as_str().contains('\\'));
469 }
470
471 #[test]
472 fn parse_rename_dest_normalizes_backslashes_to_forward_slashes() {
473 let source_name = SourceName::from("base");
474
475 let agent =
476 parse_rename_dest(ItemKind::Agent, r"agents\nested\renamed.md", &source_name).unwrap();
477 let skill =
478 parse_rename_dest(ItemKind::Skill, r"skills\nested\planning", &source_name).unwrap();
479
480 assert_eq!(agent.as_str(), "agents/nested/renamed.md");
481 assert_eq!(skill.as_str(), "skills/nested/planning");
482 assert!(!agent.as_str().contains('\\'));
483 assert!(!skill.as_str().contains('\\'));
484 }
485
486 #[test]
487 fn parse_rename_dest_rejects_absolute_and_escape_destinations() {
488 let source_name = SourceName::from("base");
489
490 let absolute = parse_rename_dest(ItemKind::Agent, "/tmp/escape", &source_name)
491 .expect_err("absolute rename should fail");
492 assert!(matches!(absolute, MarsError::Source { .. }));
493
494 let traversal = parse_rename_dest(ItemKind::Skill, "../escape", &source_name)
495 .expect_err("traversal rename should fail");
496 assert!(matches!(traversal, MarsError::Source { .. }));
497 }
498
499 #[test]
500 fn build_with_invalid_rename_destination_returns_error() {
501 let tree = make_source_tree(&[("old-name.md", "# old")], &[]);
502
503 let (graph, mut config) =
504 make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
505
506 config
507 .dependencies
508 .get_mut("base")
509 .unwrap()
510 .rename
511 .insert("agents/old-name.md".into(), "../escape.md".into());
512
513 let err = build_with_collisions(&graph, &config).unwrap_err();
514 assert!(matches!(err, MarsError::Source { .. }));
515 }
516
517 #[test]
520 fn collision_errors_instead_of_auto_renaming() {
521 let tree1 = make_source_tree(&[("coder.md", "# coder from source 1")], &[]);
522 let tree2 = make_source_tree(&[("coder.md", "# coder from source 2")], &[]);
523
524 let (graph, config) = make_graph_and_config(vec![
525 (
526 "source-a",
527 &tree1,
528 Some("https://github.com/alice/agents"),
529 FilterMode::All,
530 ),
531 (
532 "source-b",
533 &tree2,
534 Some("https://github.com/bob/agents"),
535 FilterMode::All,
536 ),
537 ]);
538
539 let err = build_with_collisions(&graph, &config).unwrap_err();
540 assert!(matches!(err, MarsError::Collision { .. }));
541 }
542
543 #[test]
544 fn no_collision_no_renames() {
545 let tree1 = make_source_tree(&[("coder.md", "# coder")], &[]);
546 let tree2 = make_source_tree(&[("reviewer.md", "# reviewer")], &[]);
547
548 let (graph, config) = make_graph_and_config(vec![
549 (
550 "source-a",
551 &tree1,
552 Some("https://github.com/alice/agents"),
553 FilterMode::All,
554 ),
555 (
556 "source-b",
557 &tree2,
558 Some("https://github.com/bob/agents"),
559 FilterMode::All,
560 ),
561 ]);
562
563 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
564 assert!(renames.is_empty());
565 assert_eq!(target.items.len(), 2);
566 }
567
568 #[test]
571 fn build_with_agents_filter_pulls_transitive_skills() {
572 let tree = make_source_tree(
573 &[("coder.md", "---\nskills:\n - planning\n---\n# Coder\n")],
574 &[("planning", "# Planning"), ("unused-skill", "# Unused")],
575 );
576
577 let (graph, config) = make_graph_and_config(vec![(
578 "base",
579 &tree,
580 None,
581 FilterMode::Include {
582 agents: vec!["coder".into()],
583 skills: vec![],
584 },
585 )]);
586
587 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
588 assert!(renames.is_empty());
589 assert_eq!(target.items.len(), 2); assert!(target.items.contains_key("agents/coder.md"));
591 assert!(target.items.contains_key("skills/planning"));
592 assert!(!target.items.contains_key("skills/unused-skill"));
594 }
595
596 #[test]
597 fn build_with_exclude_filter() {
598 let tree = make_source_tree(&[("coder.md", "# coder"), ("deprecated.md", "# old")], &[]);
599
600 let (graph, config) = make_graph_and_config(vec![(
601 "base",
602 &tree,
603 None,
604 FilterMode::Exclude(vec!["deprecated".into()]),
605 )]);
606
607 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
608 assert!(renames.is_empty());
609 assert_eq!(target.items.len(), 1);
610 assert!(target.items.contains_key("agents/coder.md"));
611 }
612
613 #[test]
614 fn build_unions_multiple_include_filters_for_same_source() {
615 let tree = make_source_tree(
616 &[],
617 &[
618 ("skill-a", "# Skill A"),
619 ("skill-b", "# Skill B"),
620 ("skill-c", "# Skill C"),
621 ],
622 );
623
624 let (mut graph, config) =
625 make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
626 graph.filters.insert(
627 "base".into(),
628 vec![
629 FilterMode::Include {
630 agents: vec![],
631 skills: vec!["skill-a".into(), "skill-b".into()],
632 },
633 FilterMode::Include {
634 agents: vec![],
635 skills: vec!["skill-b".into(), "skill-c".into()],
636 },
637 ],
638 );
639
640 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
641 assert!(renames.is_empty());
642 assert_eq!(target.items.len(), 3);
643 assert!(target.items.contains_key("skills/skill-a"));
644 assert!(target.items.contains_key("skills/skill-b"));
645 assert!(target.items.contains_key("skills/skill-c"));
646 }
647
648 #[test]
649 fn build_target_items_have_correct_hashes() {
650 let content = "# agent content for hash test";
651 let tree = make_source_tree(&[("test.md", content)], &[]);
652
653 let (graph, config) = make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
654
655 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
656 assert!(renames.is_empty());
657 let item = &target.items["agents/test.md"];
658 let expected_hash = hash::hash_bytes(content.as_bytes());
659 assert_eq!(item.source_hash, expected_hash);
660 }
661
662 #[test]
663 fn unmanaged_disk_path_collision_reported() {
664 let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
665 let (graph, config) = make_graph_and_config(vec![(
666 "base",
667 &tree,
668 Some("https://github.com/org/base"),
669 FilterMode::All,
670 )]);
671
672 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
673 assert!(renames.is_empty());
674 let install_root = TempDir::new().unwrap();
675
676 let existing = install_root.path().join("agents").join("coder.md");
678 fs::create_dir_all(existing.parent().unwrap()).unwrap();
679 fs::write(&existing, "# user-authored").unwrap();
680
681 let collisions =
682 check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
683 assert_eq!(collisions.len(), 1);
684 assert_eq!(collisions[0].source_name.as_ref(), "base");
685 assert_eq!(collisions[0].path.as_str(), "agents/coder.md");
686 }
687
688 #[test]
689 fn unmanaged_collision_skipped_when_hash_matches() {
690 let content = "# managed agent";
691 let tree = make_source_tree(&[("coder.md", content)], &[]);
692 let (graph, config) = make_graph_and_config(vec![(
693 "base",
694 &tree,
695 Some("https://github.com/org/base"),
696 FilterMode::All,
697 )]);
698
699 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
700 assert!(renames.is_empty());
701 let install_root = TempDir::new().unwrap();
702
703 let existing = install_root.path().join("agents").join("coder.md");
705 fs::create_dir_all(existing.parent().unwrap()).unwrap();
706 fs::write(&existing, content).unwrap();
707
708 let collisions =
710 check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
711 assert!(collisions.is_empty());
712 }
713
714 #[test]
715 fn unmanaged_collision_reported_on_different_content() {
716 let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
717 let (graph, config) = make_graph_and_config(vec![(
718 "base",
719 &tree,
720 Some("https://github.com/org/base"),
721 FilterMode::All,
722 )]);
723
724 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
725 assert!(renames.is_empty());
726 let install_root = TempDir::new().unwrap();
727
728 let existing = install_root.path().join("agents").join("coder.md");
730 fs::create_dir_all(existing.parent().unwrap()).unwrap();
731 fs::write(&existing, "# different user content").unwrap();
732
733 let collisions =
734 check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
735 assert_eq!(collisions.len(), 1);
736 assert_eq!(collisions[0].source_name.as_ref(), "base");
737 assert_eq!(collisions[0].path.as_str(), "agents/coder.md");
738 }
739}