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