1use std::collections::{HashMap, 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, Materialization, 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 materialization: Materialization,
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 RenameAction {
49 pub original_name: ItemName,
50 pub new_name: ItemName,
51 pub source_name: SourceName,
52}
53
54pub fn build_with_collisions(
59 graph: &ResolvedGraph,
60 config: &EffectiveConfig,
61) -> Result<(TargetState, Vec<RenameAction>), MarsError> {
62 let mut all_items: Vec<TargetItem> = 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 =
70 discover::discover_source(&node.resolved_ref.tree_path, Some(source_name.as_str()))?;
71
72 let source_id = source_config
73 .map(|s| s.id.clone())
74 .unwrap_or_else(|| node.source_id.clone());
75
76 let 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 .unwrap_or_else(|| vec![FilterMode::All]);
83
84 let renames = source_config
85 .map(|s| &s.rename)
86 .cloned()
87 .unwrap_or_default();
88
89 let filtered = apply_filter_union(&discovered, &filters, &node.resolved_ref.tree_path)?;
90
91 for item in filtered {
92 let is_flat_skill =
93 item.id.kind == ItemKind::Skill && item.source_path == Path::new(".");
94 let source_content_path = node.resolved_ref.tree_path.join(&item.source_path);
95 let source_hash = if is_flat_skill {
96 ContentHash::from(hash::compute_skill_hash_filtered(
97 &source_content_path,
98 crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
99 )?)
100 } else {
101 ContentHash::from(hash::compute_hash(&source_content_path, item.id.kind)?)
102 };
103
104 let (dest_name, dest_path) = apply_item_rename(item.id.kind, &item.id.name, &renames);
105
106 all_items.push(TargetItem {
107 id: ItemId {
108 kind: item.id.kind,
109 name: dest_name,
110 },
111 source_name: source_name.clone(),
112 origin: SourceOrigin::Dependency(source_name.clone()),
113 materialization: Materialization::Copy,
114 source_id: source_id.clone(),
115 source_path: source_content_path,
116 dest_path,
117 source_hash,
118 is_flat_skill,
119 rewritten_content: None,
120 });
121 }
122 }
123
124 let mut dest_counts: HashMap<DestPath, Vec<usize>> = HashMap::new();
126 for (idx, item) in all_items.iter().enumerate() {
127 let key = item.dest_path.clone();
128 dest_counts.entry(key).or_default().push(idx);
129 }
130
131 let mut rename_actions = Vec::new();
132
133 for indices in dest_counts.values() {
135 if indices.len() <= 1 {
136 continue;
137 }
138
139 for &idx in indices {
141 let item = &all_items[idx];
142 let original_name = item.id.name.clone();
143
144 let suffix = extract_owner_repo_from_id(&item.source_id, &item.source_name);
146 let new_name = format!("{original_name}__{suffix}");
147 let new_item_name = ItemName::from(new_name.clone());
148
149 let new_dest_path = DestPath::from(match item.id.kind {
150 ItemKind::Agent => PathBuf::from("agents").join(format!("{new_name}.md")),
151 ItemKind::Skill => PathBuf::from("skills").join(&new_name),
152 });
153
154 rename_actions.push(RenameAction {
155 original_name: original_name.clone(),
156 new_name: new_item_name.clone(),
157 source_name: item.source_name.clone(),
158 });
159
160 let item_mut = &mut all_items[idx];
162 item_mut.id.name = new_item_name;
163 item_mut.dest_path = new_dest_path;
164 }
165 }
166
167 let mut items = IndexMap::new();
169 for item in all_items {
170 let key = item.dest_path.clone();
171 items.insert(key, item);
172 }
173
174 Ok((TargetState { items }, rename_actions))
175}
176
177fn apply_filter_union(
178 discovered: &[discover::DiscoveredItem],
179 filters: &[FilterMode],
180 tree_path: &Path,
181) -> Result<Vec<discover::DiscoveredItem>, MarsError> {
182 if filters.is_empty() {
183 return Ok(discovered.to_vec());
184 }
185
186 let mut union: HashSet<(ItemKind, ItemName, PathBuf)> = HashSet::new();
187 for filter in filters {
188 let filtered = apply_filter(discovered, filter, tree_path)?;
189 union.extend(
190 filtered
191 .iter()
192 .map(|item| (item.id.kind, item.id.name.clone(), item.source_path.clone())),
193 );
194 }
195
196 Ok(discovered
197 .iter()
198 .filter(|item| {
199 union.contains(&(item.id.kind, item.id.name.clone(), item.source_path.clone()))
200 })
201 .cloned()
202 .collect())
203}
204
205pub use crate::sync::rewrite::rewrite_skill_refs;
207
208#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct UnmanagedCollision {
211 pub source_name: SourceName,
212 pub path: DestPath,
213}
214
215pub fn check_unmanaged_collisions(
221 install_target: &Path,
222 lock: &LockFile,
223 target: &TargetState,
224) -> Vec<UnmanagedCollision> {
225 let mut collisions = Vec::new();
226
227 for (dest_key, target_item) in &target.items {
228 if lock.items.contains_key(dest_key) {
229 continue;
230 }
231
232 let disk_path = install_target.join(&target_item.dest_path);
233 if disk_path.exists() {
234 if let Ok(disk_hash) = hash::compute_hash(&disk_path, target_item.id.kind)
238 && disk_hash == target_item.source_hash.as_str()
239 {
240 continue;
241 }
242
243 collisions.push(UnmanagedCollision {
244 source_name: target_item.source_name.clone(),
245 path: target_item.dest_path.clone(),
246 });
247 }
248 }
249
250 collisions
251}
252
253fn apply_item_rename(kind: ItemKind, item_name: &str, renames: &RenameMap) -> (ItemName, DestPath) {
254 let default_dest = default_dest_path(kind, item_name);
255 let default_key = default_dest.to_string_lossy().to_string();
256
257 let rename_value = renames.get(&default_key).or_else(|| renames.get(item_name));
258
259 let dest_path = match rename_value {
260 Some(value) => parse_rename_dest(kind, value.as_str()),
261 None => default_dest,
262 };
263 let dest_name = dest_name_from_path(kind, &dest_path);
264
265 (ItemName::from(dest_name), DestPath::from(dest_path))
266}
267
268fn default_dest_path(kind: ItemKind, name: &str) -> PathBuf {
269 match kind {
270 ItemKind::Agent => PathBuf::from("agents").join(format!("{name}.md")),
271 ItemKind::Skill => PathBuf::from("skills").join(name),
272 }
273}
274
275fn parse_rename_dest(kind: ItemKind, rename_value: &str) -> PathBuf {
276 let value = PathBuf::from(rename_value);
277 let has_prefix = value.starts_with("agents") || value.starts_with("skills");
278 let has_parent = value.parent().is_some_and(|p| p != Path::new(""));
279
280 if has_prefix || has_parent {
281 return value;
282 }
283
284 match kind {
285 ItemKind::Agent => {
286 if rename_value.ends_with(".md") {
287 PathBuf::from("agents").join(rename_value)
288 } else {
289 PathBuf::from("agents").join(format!("{rename_value}.md"))
290 }
291 }
292 ItemKind::Skill => PathBuf::from("skills").join(rename_value),
293 }
294}
295
296fn dest_name_from_path(kind: ItemKind, path: &Path) -> String {
297 match kind {
298 ItemKind::Agent => path
299 .file_stem()
300 .map(|s| s.to_string_lossy().to_string())
301 .unwrap_or_default(),
302 ItemKind::Skill => path
303 .file_name()
304 .map(|s| s.to_string_lossy().to_string())
305 .unwrap_or_default(),
306 }
307}
308
309pub fn extract_owner_repo(url: Option<&str>, source_name: &str) -> String {
314 if let Some(url) = url {
315 let cleaned = url.trim_end_matches('/').trim_end_matches(".git");
318
319 let without_proto = cleaned
321 .strip_prefix("https://")
322 .or_else(|| cleaned.strip_prefix("http://"))
323 .or_else(|| cleaned.strip_prefix("ssh://"))
324 .or_else(|| cleaned.strip_prefix("git://"))
325 .unwrap_or(cleaned);
326
327 let normalized = if let Some(rest) = without_proto.strip_prefix("git@") {
329 rest.replacen(':', "/", 1)
330 } else {
331 without_proto.to_string()
332 };
333
334 let parts: Vec<&str> = normalized.split('/').collect();
336 if parts.len() >= 2 {
337 let owner = parts[parts.len() - 2];
338 let repo = parts[parts.len() - 1];
339 return format!("{owner}_{repo}");
340 }
341 }
342
343 source_name.to_string()
345}
346
347fn extract_owner_repo_from_id(source_id: &SourceId, source_name: &str) -> String {
348 match source_id {
349 SourceId::Git { url } => extract_owner_repo(Some(url.as_ref()), source_name),
350 SourceId::Path { .. } => extract_owner_repo(None, source_name),
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::config::*;
358 use crate::lock::LockFile;
359 use crate::resolve::{ResolvedGraph, ResolvedNode};
360 use crate::source::ResolvedRef;
361 use indexmap::IndexMap;
362 use std::fs;
363 use tempfile::TempDir;
364
365 fn make_source_tree(agents: &[(&str, &str)], skills: &[(&str, &str)]) -> TempDir {
367 let dir = TempDir::new().unwrap();
368 if !agents.is_empty() {
369 let agents_dir = dir.path().join("agents");
370 fs::create_dir_all(&agents_dir).unwrap();
371 for (name, content) in agents {
372 fs::write(agents_dir.join(name), content).unwrap();
373 }
374 }
375 if !skills.is_empty() {
376 let skills_dir = dir.path().join("skills");
377 fs::create_dir_all(&skills_dir).unwrap();
378 for (name, content) in skills {
379 let skill_dir = skills_dir.join(name);
380 fs::create_dir_all(&skill_dir).unwrap();
381 fs::write(skill_dir.join("SKILL.md"), content).unwrap();
382 }
383 }
384 dir
385 }
386
387 fn make_graph_and_config(
388 sources: Vec<(&str, &TempDir, Option<&str>, FilterMode)>,
389 ) -> (ResolvedGraph, EffectiveConfig) {
390 let mut nodes = IndexMap::new();
391 let mut order = Vec::new();
392 let mut config_dependencies = IndexMap::new();
393
394 for (name, tree, url, filter) in sources {
395 let url_str = url.map(|u| u.to_string());
396 nodes.insert(
397 name.into(),
398 ResolvedNode {
399 source_name: name.into(),
400 source_id: if let Some(u) = url {
401 SourceId::git(crate::types::SourceUrl::from(u))
402 } else {
403 SourceId::Path {
404 canonical: tree.path().to_path_buf(),
405 }
406 },
407 resolved_ref: ResolvedRef {
408 source_name: name.into(),
409 version: None,
410 version_tag: None,
411 commit: None,
412 tree_path: tree.path().to_path_buf(),
413 },
414 manifest: None,
415 deps: vec![],
416 },
417 );
418 order.push(name.into());
419
420 let spec = if let Some(u) = url {
421 SourceSpec::Git(GitSpec {
422 url: crate::types::SourceUrl::from(u),
423 version: None,
424 })
425 } else {
426 SourceSpec::Path(tree.path().to_path_buf())
427 };
428
429 config_dependencies.insert(
430 name.into(),
431 EffectiveDependency {
432 name: name.into(),
433 id: if let Some(u) = url {
434 SourceId::git(crate::types::SourceUrl::from(u))
435 } else {
436 SourceId::Path {
437 canonical: tree.path().to_path_buf(),
438 }
439 },
440 spec,
441 filter,
442 rename: RenameMap::new(),
443 is_overridden: false,
444 original_git: url_str.map(|u| GitSpec {
445 url: crate::types::SourceUrl::from(u),
446 version: None,
447 }),
448 },
449 );
450 }
451
452 let graph = ResolvedGraph {
453 nodes,
454 order,
455 id_index: std::collections::HashMap::new(),
456 filters: std::collections::HashMap::new(),
457 };
458 let config = EffectiveConfig {
459 dependencies: config_dependencies,
460 settings: Settings::default(),
461 };
462 (graph, config)
463 }
464
465 #[test]
468 fn extract_github_https_url() {
469 let result = extract_owner_repo(
470 Some("https://github.com/meridian-flow/meridian-base"),
471 "base",
472 );
473 assert_eq!(result, "meridian-flow_meridian-base");
474 }
475
476 #[test]
477 fn extract_github_https_with_git_suffix() {
478 let result = extract_owner_repo(
479 Some("https://github.com/meridian-flow/meridian-base.git"),
480 "base",
481 );
482 assert_eq!(result, "meridian-flow_meridian-base");
483 }
484
485 #[test]
486 fn extract_github_ssh_url() {
487 let result = extract_owner_repo(
488 Some("git@github.com:meridian-flow/meridian-base.git"),
489 "base",
490 );
491 assert_eq!(result, "meridian-flow_meridian-base");
492 }
493
494 #[test]
495 fn extract_bare_github_url() {
496 let result = extract_owner_repo(Some("github.com/someone/cool-agents"), "cool");
497 assert_eq!(result, "someone_cool-agents");
498 }
499
500 #[test]
501 fn extract_fallback_to_source_name() {
502 let result = extract_owner_repo(None, "my-source");
503 assert_eq!(result, "my-source");
504 }
505
506 #[test]
507 fn extract_from_short_url() {
508 let result = extract_owner_repo(Some("single-segment"), "fallback");
509 assert_eq!(result, "fallback");
510 }
511
512 #[test]
515 fn build_single_source_no_filter() {
516 let tree = make_source_tree(&[("coder.md", "# coder")], &[("planning", "# planning")]);
517 let (graph, config) = make_graph_and_config(vec![(
518 "base",
519 &tree,
520 Some("https://github.com/org/base"),
521 FilterMode::All,
522 )]);
523
524 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
525 assert!(renames.is_empty());
526 assert_eq!(target.items.len(), 2);
527 assert!(target.items.contains_key("agents/coder.md"));
528 assert!(target.items.contains_key("skills/planning"));
529 }
530
531 #[test]
532 fn build_with_path_rename_mapping() {
533 let tree = make_source_tree(&[("old-name.md", "# old")], &[]);
534
535 let (graph, mut config) = make_graph_and_config(vec![(
536 "base",
537 &tree,
538 Some("https://github.com/org/base"),
539 FilterMode::All,
540 )]);
541
542 config
544 .dependencies
545 .get_mut("base")
546 .unwrap()
547 .rename
548 .insert("agents/old-name.md".into(), "agents/new-name.md".into());
549
550 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
551 assert!(renames.is_empty());
552 assert_eq!(target.items.len(), 1);
553 assert!(target.items.contains_key("agents/new-name.md"));
554 assert_eq!(target.items["agents/new-name.md"].id.name, "new-name");
555 }
556
557 #[test]
560 fn collision_auto_renames_both() {
561 let tree1 = make_source_tree(&[("coder.md", "# coder from source 1")], &[]);
562 let tree2 = make_source_tree(&[("coder.md", "# coder from source 2")], &[]);
563
564 let (graph, config) = make_graph_and_config(vec![
565 (
566 "source-a",
567 &tree1,
568 Some("https://github.com/alice/agents"),
569 FilterMode::All,
570 ),
571 (
572 "source-b",
573 &tree2,
574 Some("https://github.com/bob/agents"),
575 FilterMode::All,
576 ),
577 ]);
578
579 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
580 assert_eq!(renames.len(), 2);
581 assert_eq!(target.items.len(), 2);
582
583 let names: Vec<&str> = target.items.values().map(|i| i.id.name.as_str()).collect();
585 assert!(names.contains(&"coder__alice_agents"));
586 assert!(names.contains(&"coder__bob_agents"));
587 }
588
589 #[test]
590 fn no_collision_no_renames() {
591 let tree1 = make_source_tree(&[("coder.md", "# coder")], &[]);
592 let tree2 = make_source_tree(&[("reviewer.md", "# reviewer")], &[]);
593
594 let (graph, config) = make_graph_and_config(vec![
595 (
596 "source-a",
597 &tree1,
598 Some("https://github.com/alice/agents"),
599 FilterMode::All,
600 ),
601 (
602 "source-b",
603 &tree2,
604 Some("https://github.com/bob/agents"),
605 FilterMode::All,
606 ),
607 ]);
608
609 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
610 assert!(renames.is_empty());
611 assert_eq!(target.items.len(), 2);
612 }
613
614 #[test]
617 fn build_with_agents_filter_pulls_transitive_skills() {
618 let tree = make_source_tree(
619 &[("coder.md", "---\nskills:\n - planning\n---\n# Coder\n")],
620 &[("planning", "# Planning"), ("unused-skill", "# Unused")],
621 );
622
623 let (graph, config) = make_graph_and_config(vec![(
624 "base",
625 &tree,
626 None,
627 FilterMode::Include {
628 agents: vec!["coder".into()],
629 skills: vec![],
630 },
631 )]);
632
633 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
634 assert!(renames.is_empty());
635 assert_eq!(target.items.len(), 2); assert!(target.items.contains_key("agents/coder.md"));
637 assert!(target.items.contains_key("skills/planning"));
638 assert!(!target.items.contains_key("skills/unused-skill"));
640 }
641
642 #[test]
643 fn build_with_exclude_filter() {
644 let tree = make_source_tree(&[("coder.md", "# coder"), ("deprecated.md", "# old")], &[]);
645
646 let (graph, config) = make_graph_and_config(vec![(
647 "base",
648 &tree,
649 None,
650 FilterMode::Exclude(vec!["deprecated".into()]),
651 )]);
652
653 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
654 assert!(renames.is_empty());
655 assert_eq!(target.items.len(), 1);
656 assert!(target.items.contains_key("agents/coder.md"));
657 }
658
659 #[test]
660 fn build_unions_multiple_include_filters_for_same_source() {
661 let tree = make_source_tree(
662 &[],
663 &[
664 ("skill-a", "# Skill A"),
665 ("skill-b", "# Skill B"),
666 ("skill-c", "# Skill C"),
667 ],
668 );
669
670 let (mut graph, config) =
671 make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
672 graph.filters.insert(
673 "base".into(),
674 vec![
675 FilterMode::Include {
676 agents: vec![],
677 skills: vec!["skill-a".into(), "skill-b".into()],
678 },
679 FilterMode::Include {
680 agents: vec![],
681 skills: vec!["skill-b".into(), "skill-c".into()],
682 },
683 ],
684 );
685
686 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
687 assert!(renames.is_empty());
688 assert_eq!(target.items.len(), 3);
689 assert!(target.items.contains_key("skills/skill-a"));
690 assert!(target.items.contains_key("skills/skill-b"));
691 assert!(target.items.contains_key("skills/skill-c"));
692 }
693
694 #[test]
695 fn build_target_items_have_correct_hashes() {
696 let content = "# agent content for hash test";
697 let tree = make_source_tree(&[("test.md", content)], &[]);
698
699 let (graph, config) = make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
700
701 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
702 assert!(renames.is_empty());
703 let item = &target.items["agents/test.md"];
704 let expected_hash = hash::hash_bytes(content.as_bytes());
705 assert_eq!(item.source_hash, expected_hash);
706 }
707
708 #[test]
709 fn unmanaged_disk_path_collision_reported() {
710 let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
711 let (graph, config) = make_graph_and_config(vec![(
712 "base",
713 &tree,
714 Some("https://github.com/org/base"),
715 FilterMode::All,
716 )]);
717
718 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
719 assert!(renames.is_empty());
720 let install_root = TempDir::new().unwrap();
721
722 let existing = install_root.path().join("agents").join("coder.md");
724 fs::create_dir_all(existing.parent().unwrap()).unwrap();
725 fs::write(&existing, "# user-authored").unwrap();
726
727 let collisions =
728 check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
729 assert_eq!(collisions.len(), 1);
730 assert_eq!(collisions[0].source_name.as_ref(), "base");
731 assert_eq!(
732 collisions[0].path.as_ref().to_string_lossy(),
733 "agents/coder.md"
734 );
735 }
736
737 #[test]
738 fn unmanaged_collision_skipped_when_hash_matches() {
739 let content = "# managed agent";
740 let tree = make_source_tree(&[("coder.md", content)], &[]);
741 let (graph, config) = make_graph_and_config(vec![(
742 "base",
743 &tree,
744 Some("https://github.com/org/base"),
745 FilterMode::All,
746 )]);
747
748 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
749 assert!(renames.is_empty());
750 let install_root = TempDir::new().unwrap();
751
752 let existing = install_root.path().join("agents").join("coder.md");
754 fs::create_dir_all(existing.parent().unwrap()).unwrap();
755 fs::write(&existing, content).unwrap();
756
757 let collisions =
759 check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
760 assert!(collisions.is_empty());
761 }
762
763 #[test]
764 fn unmanaged_collision_reported_on_different_content() {
765 let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
766 let (graph, config) = make_graph_and_config(vec![(
767 "base",
768 &tree,
769 Some("https://github.com/org/base"),
770 FilterMode::All,
771 )]);
772
773 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
774 assert!(renames.is_empty());
775 let install_root = TempDir::new().unwrap();
776
777 let existing = install_root.path().join("agents").join("coder.md");
779 fs::create_dir_all(existing.parent().unwrap()).unwrap();
780 fs::write(&existing, "# different user content").unwrap();
781
782 let collisions =
783 check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
784 assert_eq!(collisions.len(), 1);
785 assert_eq!(collisions[0].source_name.as_ref(), "base");
786 assert_eq!(
787 collisions[0].path.as_ref().to_string_lossy(),
788 "agents/coder.md"
789 );
790 }
791}