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 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 .unwrap_or_else(|| vec![FilterMode::All]);
85
86 let renames = source_config
87 .map(|s| &s.rename)
88 .cloned()
89 .unwrap_or_default();
90
91 let filtered = apply_filter_union(&discovered, &filters, &node.rooted_ref.package_root)?;
92
93 for item in filtered {
94 let is_flat_skill =
95 item.id.kind == ItemKind::Skill && item.source_path == Path::new(".");
96 let source_content_path = node.rooted_ref.package_root.join(&item.source_path);
97 let source_hash = if is_flat_skill {
98 ContentHash::from(hash::compute_skill_hash_filtered(
99 &source_content_path,
100 crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
101 )?)
102 } else {
103 ContentHash::from(hash::compute_hash(&source_content_path, item.id.kind)?)
104 };
105
106 let (dest_name, dest_path) = apply_item_rename(item.id.kind, &item.id.name, &renames);
107 if item.id.kind == ItemKind::Skill && dest_name != item.id.name {
108 explicit_skill_renames.push(ExplicitSkillRename {
109 original_name: item.id.name.clone(),
110 new_name: dest_name.clone(),
111 source_name: source_name.clone(),
112 });
113 }
114
115 let target_item = TargetItem {
116 id: ItemId {
117 kind: item.id.kind,
118 name: dest_name,
119 },
120 source_name: source_name.clone(),
121 origin: SourceOrigin::Dependency(source_name.clone()),
122 source_id: source_id.clone(),
123 source_path: source_content_path,
124 dest_path,
125 source_hash,
126 is_flat_skill,
127 rewritten_content: None,
128 };
129
130 if let Some(existing) = items.get(&target_item.dest_path) {
131 return Err(MarsError::Collision {
132 item: format!("{} `{}`", target_item.id.kind, target_item.id.name),
133 source_a: existing.source_name.to_string(),
134 source_b: target_item.source_name.to_string(),
135 });
136 }
137
138 items.insert(target_item.dest_path.clone(), target_item);
139 }
140 }
141
142 Ok((TargetState { items }, explicit_skill_renames))
143}
144
145fn apply_filter_union(
146 discovered: &[discover::DiscoveredItem],
147 filters: &[FilterMode],
148 package_root: &Path,
149) -> Result<Vec<discover::DiscoveredItem>, MarsError> {
150 if filters.is_empty() {
151 return Ok(discovered.to_vec());
152 }
153
154 let mut union: HashSet<(ItemKind, ItemName, PathBuf)> = HashSet::new();
155 for filter in filters {
156 let filtered = apply_filter(discovered, filter, package_root)?;
157 union.extend(
158 filtered
159 .iter()
160 .map(|item| (item.id.kind, item.id.name.clone(), item.source_path.clone())),
161 );
162 }
163
164 Ok(discovered
165 .iter()
166 .filter(|item| {
167 union.contains(&(item.id.kind, item.id.name.clone(), item.source_path.clone()))
168 })
169 .cloned()
170 .collect())
171}
172
173pub use crate::sync::rewrite::rewrite_skill_refs;
175
176#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct UnmanagedCollision {
179 pub source_name: SourceName,
180 pub path: DestPath,
181}
182
183pub fn check_unmanaged_collisions(
189 install_target: &Path,
190 lock: &LockFile,
191 target: &TargetState,
192) -> Vec<UnmanagedCollision> {
193 let mut collisions = Vec::new();
194
195 for (dest_key, target_item) in &target.items {
196 if lock.items.contains_key(dest_key) {
197 continue;
198 }
199
200 let disk_path = install_target.join(&target_item.dest_path);
201 if disk_path.exists() {
202 if let Ok(disk_hash) = hash::compute_hash(&disk_path, target_item.id.kind)
206 && disk_hash == target_item.source_hash.as_str()
207 {
208 continue;
209 }
210
211 collisions.push(UnmanagedCollision {
212 source_name: target_item.source_name.clone(),
213 path: target_item.dest_path.clone(),
214 });
215 }
216 }
217
218 collisions
219}
220
221fn apply_item_rename(kind: ItemKind, item_name: &str, renames: &RenameMap) -> (ItemName, DestPath) {
222 let default_dest = default_dest_path(kind, item_name);
223 let default_key = default_dest.to_string_lossy().to_string();
224
225 let rename_value = renames.get(&default_key).or_else(|| renames.get(item_name));
226
227 let dest_path = match rename_value {
228 Some(value) => parse_rename_dest(kind, value.as_str()),
229 None => default_dest,
230 };
231 let dest_name = dest_name_from_path(kind, &dest_path);
232
233 (ItemName::from(dest_name), DestPath::from(dest_path))
234}
235
236fn default_dest_path(kind: ItemKind, name: &str) -> PathBuf {
237 match kind {
238 ItemKind::Agent => PathBuf::from("agents").join(format!("{name}.md")),
239 ItemKind::Skill => PathBuf::from("skills").join(name),
240 }
241}
242
243fn parse_rename_dest(kind: ItemKind, rename_value: &str) -> PathBuf {
244 let value = PathBuf::from(rename_value);
245 let has_prefix = value.starts_with("agents") || value.starts_with("skills");
246 let has_parent = value.parent().is_some_and(|p| p != Path::new(""));
247
248 if has_prefix || has_parent {
249 return value;
250 }
251
252 match kind {
253 ItemKind::Agent => {
254 if rename_value.ends_with(".md") {
255 PathBuf::from("agents").join(rename_value)
256 } else {
257 PathBuf::from("agents").join(format!("{rename_value}.md"))
258 }
259 }
260 ItemKind::Skill => PathBuf::from("skills").join(rename_value),
261 }
262}
263
264fn dest_name_from_path(kind: ItemKind, path: &Path) -> String {
265 match kind {
266 ItemKind::Agent => path
267 .file_stem()
268 .map(|s| s.to_string_lossy().to_string())
269 .unwrap_or_default(),
270 ItemKind::Skill => path
271 .file_name()
272 .map(|s| s.to_string_lossy().to_string())
273 .unwrap_or_default(),
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use crate::config::*;
281 use crate::lock::LockFile;
282 use crate::resolve::{ResolvedGraph, ResolvedNode};
283 use crate::source::ResolvedRef;
284 use indexmap::IndexMap;
285 use std::fs;
286 use tempfile::TempDir;
287
288 fn make_source_tree(agents: &[(&str, &str)], skills: &[(&str, &str)]) -> TempDir {
290 let dir = TempDir::new().unwrap();
291 if !agents.is_empty() {
292 let agents_dir = dir.path().join("agents");
293 fs::create_dir_all(&agents_dir).unwrap();
294 for (name, content) in agents {
295 fs::write(agents_dir.join(name), content).unwrap();
296 }
297 }
298 if !skills.is_empty() {
299 let skills_dir = dir.path().join("skills");
300 fs::create_dir_all(&skills_dir).unwrap();
301 for (name, content) in skills {
302 let skill_dir = skills_dir.join(name);
303 fs::create_dir_all(&skill_dir).unwrap();
304 fs::write(skill_dir.join("SKILL.md"), content).unwrap();
305 }
306 }
307 dir
308 }
309
310 fn make_graph_and_config(
311 sources: Vec<(&str, &TempDir, Option<&str>, FilterMode)>,
312 ) -> (ResolvedGraph, EffectiveConfig) {
313 let mut nodes = IndexMap::new();
314 let mut order = Vec::new();
315 let mut config_dependencies = IndexMap::new();
316
317 for (name, tree, url, filter) in sources {
318 let url_str = url.map(|u| u.to_string());
319 nodes.insert(
320 name.into(),
321 ResolvedNode {
322 source_name: name.into(),
323 source_id: if let Some(u) = url {
324 SourceId::git(crate::types::SourceUrl::from(u))
325 } else {
326 SourceId::Path {
327 canonical: tree.path().to_path_buf(),
328 subpath: None,
329 }
330 },
331 rooted_ref: crate::resolve::RootedSourceRef {
332 checkout_root: tree.path().to_path_buf(),
333 package_root: tree.path().to_path_buf(),
334 },
335 resolved_ref: ResolvedRef {
336 source_name: name.into(),
337 version: None,
338 version_tag: None,
339 commit: None,
340 tree_path: tree.path().to_path_buf(),
341 },
342 latest_version: None,
343 manifest: None,
344 deps: vec![],
345 },
346 );
347 order.push(name.into());
348
349 let spec = if let Some(u) = url {
350 SourceSpec::Git(GitSpec {
351 url: crate::types::SourceUrl::from(u),
352 version: None,
353 })
354 } else {
355 SourceSpec::Path(tree.path().to_path_buf())
356 };
357
358 config_dependencies.insert(
359 name.into(),
360 EffectiveDependency {
361 name: name.into(),
362 id: if let Some(u) = url {
363 SourceId::git(crate::types::SourceUrl::from(u))
364 } else {
365 SourceId::Path {
366 canonical: tree.path().to_path_buf(),
367 subpath: None,
368 }
369 },
370 spec,
371 subpath: None,
372 filter,
373 rename: RenameMap::new(),
374 is_overridden: false,
375 original_git: url_str.map(|u| GitSpec {
376 url: crate::types::SourceUrl::from(u),
377 version: None,
378 }),
379 },
380 );
381 }
382
383 let graph = ResolvedGraph {
384 nodes,
385 order,
386 id_index: std::collections::HashMap::new(),
387 filters: std::collections::HashMap::new(),
388 };
389 let config = EffectiveConfig {
390 dependencies: config_dependencies,
391 settings: Settings::default(),
392 };
393 (graph, config)
394 }
395
396 #[test]
399 fn build_single_source_no_filter() {
400 let tree = make_source_tree(&[("coder.md", "# coder")], &[("planning", "# planning")]);
401 let (graph, config) = make_graph_and_config(vec![(
402 "base",
403 &tree,
404 Some("https://github.com/org/base"),
405 FilterMode::All,
406 )]);
407
408 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
409 assert!(renames.is_empty());
410 assert_eq!(target.items.len(), 2);
411 assert!(target.items.contains_key("agents/coder.md"));
412 assert!(target.items.contains_key("skills/planning"));
413 }
414
415 #[test]
416 fn build_with_path_rename_mapping() {
417 let tree = make_source_tree(&[("old-name.md", "# old")], &[]);
418
419 let (graph, mut config) = make_graph_and_config(vec![(
420 "base",
421 &tree,
422 Some("https://github.com/org/base"),
423 FilterMode::All,
424 )]);
425
426 config
428 .dependencies
429 .get_mut("base")
430 .unwrap()
431 .rename
432 .insert("agents/old-name.md".into(), "agents/new-name.md".into());
433
434 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
435 assert!(renames.is_empty());
436 assert_eq!(target.items.len(), 1);
437 assert!(target.items.contains_key("agents/new-name.md"));
438 assert_eq!(target.items["agents/new-name.md"].id.name, "new-name");
439 }
440
441 #[test]
444 fn collision_errors_instead_of_auto_renaming() {
445 let tree1 = make_source_tree(&[("coder.md", "# coder from source 1")], &[]);
446 let tree2 = make_source_tree(&[("coder.md", "# coder from source 2")], &[]);
447
448 let (graph, config) = make_graph_and_config(vec![
449 (
450 "source-a",
451 &tree1,
452 Some("https://github.com/alice/agents"),
453 FilterMode::All,
454 ),
455 (
456 "source-b",
457 &tree2,
458 Some("https://github.com/bob/agents"),
459 FilterMode::All,
460 ),
461 ]);
462
463 let err = build_with_collisions(&graph, &config).unwrap_err();
464 assert!(matches!(err, MarsError::Collision { .. }));
465 }
466
467 #[test]
468 fn no_collision_no_renames() {
469 let tree1 = make_source_tree(&[("coder.md", "# coder")], &[]);
470 let tree2 = make_source_tree(&[("reviewer.md", "# reviewer")], &[]);
471
472 let (graph, config) = make_graph_and_config(vec![
473 (
474 "source-a",
475 &tree1,
476 Some("https://github.com/alice/agents"),
477 FilterMode::All,
478 ),
479 (
480 "source-b",
481 &tree2,
482 Some("https://github.com/bob/agents"),
483 FilterMode::All,
484 ),
485 ]);
486
487 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
488 assert!(renames.is_empty());
489 assert_eq!(target.items.len(), 2);
490 }
491
492 #[test]
495 fn build_with_agents_filter_pulls_transitive_skills() {
496 let tree = make_source_tree(
497 &[("coder.md", "---\nskills:\n - planning\n---\n# Coder\n")],
498 &[("planning", "# Planning"), ("unused-skill", "# Unused")],
499 );
500
501 let (graph, config) = make_graph_and_config(vec![(
502 "base",
503 &tree,
504 None,
505 FilterMode::Include {
506 agents: vec!["coder".into()],
507 skills: vec![],
508 },
509 )]);
510
511 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
512 assert!(renames.is_empty());
513 assert_eq!(target.items.len(), 2); assert!(target.items.contains_key("agents/coder.md"));
515 assert!(target.items.contains_key("skills/planning"));
516 assert!(!target.items.contains_key("skills/unused-skill"));
518 }
519
520 #[test]
521 fn build_with_exclude_filter() {
522 let tree = make_source_tree(&[("coder.md", "# coder"), ("deprecated.md", "# old")], &[]);
523
524 let (graph, config) = make_graph_and_config(vec![(
525 "base",
526 &tree,
527 None,
528 FilterMode::Exclude(vec!["deprecated".into()]),
529 )]);
530
531 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
532 assert!(renames.is_empty());
533 assert_eq!(target.items.len(), 1);
534 assert!(target.items.contains_key("agents/coder.md"));
535 }
536
537 #[test]
538 fn build_unions_multiple_include_filters_for_same_source() {
539 let tree = make_source_tree(
540 &[],
541 &[
542 ("skill-a", "# Skill A"),
543 ("skill-b", "# Skill B"),
544 ("skill-c", "# Skill C"),
545 ],
546 );
547
548 let (mut graph, config) =
549 make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
550 graph.filters.insert(
551 "base".into(),
552 vec![
553 FilterMode::Include {
554 agents: vec![],
555 skills: vec!["skill-a".into(), "skill-b".into()],
556 },
557 FilterMode::Include {
558 agents: vec![],
559 skills: vec!["skill-b".into(), "skill-c".into()],
560 },
561 ],
562 );
563
564 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
565 assert!(renames.is_empty());
566 assert_eq!(target.items.len(), 3);
567 assert!(target.items.contains_key("skills/skill-a"));
568 assert!(target.items.contains_key("skills/skill-b"));
569 assert!(target.items.contains_key("skills/skill-c"));
570 }
571
572 #[test]
573 fn build_target_items_have_correct_hashes() {
574 let content = "# agent content for hash test";
575 let tree = make_source_tree(&[("test.md", content)], &[]);
576
577 let (graph, config) = make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
578
579 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
580 assert!(renames.is_empty());
581 let item = &target.items["agents/test.md"];
582 let expected_hash = hash::hash_bytes(content.as_bytes());
583 assert_eq!(item.source_hash, expected_hash);
584 }
585
586 #[test]
587 fn unmanaged_disk_path_collision_reported() {
588 let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
589 let (graph, config) = make_graph_and_config(vec![(
590 "base",
591 &tree,
592 Some("https://github.com/org/base"),
593 FilterMode::All,
594 )]);
595
596 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
597 assert!(renames.is_empty());
598 let install_root = TempDir::new().unwrap();
599
600 let existing = install_root.path().join("agents").join("coder.md");
602 fs::create_dir_all(existing.parent().unwrap()).unwrap();
603 fs::write(&existing, "# user-authored").unwrap();
604
605 let collisions =
606 check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
607 assert_eq!(collisions.len(), 1);
608 assert_eq!(collisions[0].source_name.as_ref(), "base");
609 assert_eq!(
610 collisions[0].path.as_ref().to_string_lossy(),
611 "agents/coder.md"
612 );
613 }
614
615 #[test]
616 fn unmanaged_collision_skipped_when_hash_matches() {
617 let content = "# managed agent";
618 let tree = make_source_tree(&[("coder.md", content)], &[]);
619 let (graph, config) = make_graph_and_config(vec![(
620 "base",
621 &tree,
622 Some("https://github.com/org/base"),
623 FilterMode::All,
624 )]);
625
626 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
627 assert!(renames.is_empty());
628 let install_root = TempDir::new().unwrap();
629
630 let existing = install_root.path().join("agents").join("coder.md");
632 fs::create_dir_all(existing.parent().unwrap()).unwrap();
633 fs::write(&existing, content).unwrap();
634
635 let collisions =
637 check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
638 assert!(collisions.is_empty());
639 }
640
641 #[test]
642 fn unmanaged_collision_reported_on_different_content() {
643 let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
644 let (graph, config) = make_graph_and_config(vec![(
645 "base",
646 &tree,
647 Some("https://github.com/org/base"),
648 FilterMode::All,
649 )]);
650
651 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
652 assert!(renames.is_empty());
653 let install_root = TempDir::new().unwrap();
654
655 let existing = install_root.path().join("agents").join("coder.md");
657 fs::create_dir_all(existing.parent().unwrap()).unwrap();
658 fs::write(&existing, "# different user content").unwrap();
659
660 let collisions =
661 check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
662 assert_eq!(collisions.len(), 1);
663 assert_eq!(collisions[0].source_name.as_ref(), "base");
664 assert_eq!(
665 collisions[0].path.as_ref().to_string_lossy(),
666 "agents/coder.md"
667 );
668 }
669}