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::frontmatter;
10use crate::hash;
11use crate::lock::{ItemId, ItemKind, LockFile};
12use crate::resolve::ResolvedGraph;
13use crate::types::{ContentHash, DestPath, ItemName, RenameMap, SourceId, SourceName};
14use crate::validate;
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_id: SourceId,
31 pub source_path: PathBuf,
33 pub dest_path: DestPath,
35 pub source_hash: ContentHash,
37 pub is_flat_skill: bool,
39 pub rewritten_content: Option<String>,
41}
42
43#[derive(Debug, Clone)]
45pub struct RenameAction {
46 pub original_name: ItemName,
47 pub new_name: ItemName,
48 pub source_name: SourceName,
49}
50
51pub fn build_with_collisions(
56 graph: &ResolvedGraph,
57 config: &EffectiveConfig,
58) -> Result<(TargetState, Vec<RenameAction>), MarsError> {
59 let mut all_items: Vec<TargetItem> = Vec::new();
61
62 for source_name in &graph.order {
63 let node = &graph.nodes[source_name];
64 let source_config = config.sources.get(source_name);
65
66 let discovered =
67 discover::discover_source(&node.resolved_ref.tree_path, Some(source_name.as_str()))?;
68
69 let source_id = source_config
70 .map(|s| s.id.clone())
71 .unwrap_or_else(|| node.source_id.clone());
72
73 let filter = source_config
74 .map(|s| &s.filter)
75 .cloned()
76 .unwrap_or(FilterMode::All);
77
78 let renames = source_config
79 .map(|s| &s.rename)
80 .cloned()
81 .unwrap_or_default();
82
83 let filtered = apply_filter(&discovered, &filter, &node.resolved_ref.tree_path)?;
84
85 for item in filtered {
86 let is_flat_skill =
87 item.id.kind == ItemKind::Skill && item.source_path == Path::new(".");
88 let source_content_path = node.resolved_ref.tree_path.join(&item.source_path);
89 let source_hash = if is_flat_skill {
90 ContentHash::from(hash::compute_skill_hash_filtered(
91 &source_content_path,
92 crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
93 )?)
94 } else {
95 ContentHash::from(hash::compute_hash(&source_content_path, item.id.kind)?)
96 };
97
98 let (dest_name, dest_path) = apply_item_rename(item.id.kind, &item.id.name, &renames);
99
100 all_items.push(TargetItem {
101 id: ItemId {
102 kind: item.id.kind,
103 name: dest_name,
104 },
105 source_name: source_name.clone(),
106 source_id: source_id.clone(),
107 source_path: source_content_path,
108 dest_path,
109 source_hash,
110 is_flat_skill,
111 rewritten_content: None,
112 });
113 }
114 }
115
116 let mut dest_counts: HashMap<DestPath, Vec<usize>> = HashMap::new();
118 for (idx, item) in all_items.iter().enumerate() {
119 let key = item.dest_path.clone();
120 dest_counts.entry(key).or_default().push(idx);
121 }
122
123 let mut rename_actions = Vec::new();
124
125 for indices in dest_counts.values() {
127 if indices.len() <= 1 {
128 continue;
129 }
130
131 for &idx in indices {
133 let item = &all_items[idx];
134 let original_name = item.id.name.clone();
135
136 let suffix = extract_owner_repo_from_id(&item.source_id, &item.source_name);
138 let new_name = format!("{original_name}__{suffix}");
139 let new_item_name = ItemName::from(new_name.clone());
140
141 let new_dest_path = DestPath::from(match item.id.kind {
142 ItemKind::Agent => PathBuf::from("agents").join(format!("{new_name}.md")),
143 ItemKind::Skill => PathBuf::from("skills").join(&new_name),
144 });
145
146 rename_actions.push(RenameAction {
147 original_name: original_name.clone(),
148 new_name: new_item_name.clone(),
149 source_name: item.source_name.clone(),
150 });
151
152 let item_mut = &mut all_items[idx];
154 item_mut.id.name = new_item_name;
155 item_mut.dest_path = new_dest_path;
156 }
157 }
158
159 let mut items = IndexMap::new();
161 for item in all_items {
162 let key = item.dest_path.clone();
163 items.insert(key, item);
164 }
165
166 Ok((TargetState { items }, rename_actions))
167}
168
169pub fn rewrite_skill_refs(
175 target: &mut TargetState,
176 renames: &[RenameAction],
177 graph: &ResolvedGraph,
178) -> Result<Vec<String>, MarsError> {
179 let mut warnings = Vec::new();
180
181 if renames.is_empty() {
182 return Ok(warnings);
183 }
184
185 let mut skill_renames: HashMap<ItemName, Vec<(ItemName, SourceName)>> = HashMap::new();
188 for ra in renames {
189 let is_skill = target
190 .items
191 .values()
192 .any(|item| item.id.kind == ItemKind::Skill && item.id.name == ra.new_name);
193 if is_skill {
194 skill_renames
195 .entry(ra.original_name.clone())
196 .or_default()
197 .push((ra.new_name.clone(), ra.source_name.clone()));
198 }
199 }
200
201 if skill_renames.is_empty() {
202 return Ok(warnings);
203 }
204
205 let agent_keys: Vec<DestPath> = target
207 .items
208 .iter()
209 .filter(|(_, item)| item.id.kind == ItemKind::Agent)
210 .map(|(key, _)| key.clone())
211 .collect();
212
213 for key in agent_keys {
214 let (source_path, source_name) = {
215 let item = &target.items[&key];
216 (item.source_path.clone(), item.source_name.clone())
217 };
218 let content = match std::fs::read_to_string(&source_path) {
219 Ok(c) => c,
220 Err(_) => continue,
221 };
222
223 let mut renames_for_agent: IndexMap<String, String> = IndexMap::new();
224 let agent_deps: &[SourceName] = graph
225 .nodes
226 .get(&source_name)
227 .map(|n| n.deps.as_slice())
228 .unwrap_or(&[]);
229
230 for (original_name, entries) in &skill_renames {
231 let selected = entries
232 .iter()
233 .find(|(_, source)| source == &source_name)
234 .or_else(|| {
235 entries
236 .iter()
237 .find(|(_, source)| agent_deps.contains(source))
238 });
239 if let Some((new_name, _)) = selected {
240 renames_for_agent.insert(original_name.to_string(), new_name.to_string());
241 }
242 }
243 if renames_for_agent.is_empty() {
244 continue;
245 }
246
247 match frontmatter::rewrite_content_skills(&content, &renames_for_agent) {
248 Ok(Some(new_content)) => {
249 if let Some(target_item) = target.items.get_mut(&key) {
250 target_item.rewritten_content = Some(new_content);
251 }
252 }
253 Ok(None) => {}
254 Err(e) => {
255 warnings.push(format!(
256 "warning: could not rewrite skill refs in {}: {e}",
257 source_path.display()
258 ));
259 }
260 }
261 }
262
263 Ok(warnings)
264}
265
266pub fn check_unmanaged_collisions(
271 install_target: &Path,
272 lock: &LockFile,
273 target: &TargetState,
274) -> Result<(), MarsError> {
275 for (dest_key, target_item) in &target.items {
276 if lock.items.contains_key(dest_key) {
277 continue;
278 }
279
280 let disk_path = install_target.join(&target_item.dest_path);
281 if disk_path.exists() {
282 if let Ok(disk_hash) = hash::compute_hash(&disk_path, target_item.id.kind)
286 && disk_hash == target_item.source_hash.as_str()
287 {
288 continue;
289 }
290
291 return Err(MarsError::UnmanagedCollision {
292 source_name: target_item.source_name.to_string(),
293 path: target_item.dest_path.to_path_buf(),
294 });
295 }
296 }
297
298 Ok(())
299}
300
301fn apply_item_rename(kind: ItemKind, item_name: &str, renames: &RenameMap) -> (ItemName, DestPath) {
302 let default_dest = default_dest_path(kind, item_name);
303 let default_key = default_dest.to_string_lossy().to_string();
304
305 let rename_value = renames.get(&default_key).or_else(|| renames.get(item_name));
306
307 let dest_path = match rename_value {
308 Some(value) => parse_rename_dest(kind, value.as_str()),
309 None => default_dest,
310 };
311 let dest_name = dest_name_from_path(kind, &dest_path);
312
313 (ItemName::from(dest_name), DestPath::from(dest_path))
314}
315
316fn default_dest_path(kind: ItemKind, name: &str) -> PathBuf {
317 match kind {
318 ItemKind::Agent => PathBuf::from("agents").join(format!("{name}.md")),
319 ItemKind::Skill => PathBuf::from("skills").join(name),
320 }
321}
322
323fn parse_rename_dest(kind: ItemKind, rename_value: &str) -> PathBuf {
324 let value = PathBuf::from(rename_value);
325 let has_prefix = value.starts_with("agents") || value.starts_with("skills");
326 let has_parent = value.parent().is_some_and(|p| p != Path::new(""));
327
328 if has_prefix || has_parent {
329 return value;
330 }
331
332 match kind {
333 ItemKind::Agent => {
334 if rename_value.ends_with(".md") {
335 PathBuf::from("agents").join(rename_value)
336 } else {
337 PathBuf::from("agents").join(format!("{rename_value}.md"))
338 }
339 }
340 ItemKind::Skill => PathBuf::from("skills").join(rename_value),
341 }
342}
343
344fn dest_name_from_path(kind: ItemKind, path: &Path) -> String {
345 match kind {
346 ItemKind::Agent => path
347 .file_stem()
348 .map(|s| s.to_string_lossy().to_string())
349 .unwrap_or_default(),
350 ItemKind::Skill => path
351 .file_name()
352 .map(|s| s.to_string_lossy().to_string())
353 .unwrap_or_default(),
354 }
355}
356
357fn apply_filter(
362 discovered: &[discover::DiscoveredItem],
363 filter: &FilterMode,
364 tree_path: &Path,
365) -> Result<Vec<discover::DiscoveredItem>, MarsError> {
366 match filter {
367 FilterMode::All => Ok(discovered.to_vec()),
368
369 FilterMode::Exclude(excluded) => {
370 Ok(discovered
371 .iter()
372 .filter(|item| {
373 let path_str = item.source_path.to_string_lossy();
374 !excluded.iter().any(|e| {
375 path_str == e.as_ref() || item.id.name == *e
377 })
378 })
379 .cloned()
380 .collect())
381 }
382
383 FilterMode::Include { agents, skills } => {
384 let mut include_set: std::collections::HashSet<ItemName> =
386 std::collections::HashSet::new();
387
388 for a in agents {
390 include_set.insert(a.clone());
391 }
392 for s in skills {
393 include_set.insert(s.clone());
394 }
395
396 for agent_name in agents {
398 if let Some(agent_item) = discovered
400 .iter()
401 .find(|i| i.id.kind == ItemKind::Agent && i.id.name == *agent_name)
402 {
403 let agent_path = tree_path.join(&agent_item.source_path);
404 let skill_deps = validate::parse_agent_skills(&agent_path).unwrap_or_default();
405 for skill in skill_deps {
406 include_set.insert(ItemName::from(skill));
407 }
408 }
409 }
410
411 Ok(discovered
412 .iter()
413 .filter(|item| include_set.contains(&item.id.name))
414 .cloned()
415 .collect())
416 }
417 }
418}
419
420pub fn extract_owner_repo(url: Option<&str>, source_name: &str) -> String {
425 if let Some(url) = url {
426 let cleaned = url.trim_end_matches('/').trim_end_matches(".git");
429
430 let without_proto = cleaned
432 .strip_prefix("https://")
433 .or_else(|| cleaned.strip_prefix("http://"))
434 .or_else(|| cleaned.strip_prefix("ssh://"))
435 .or_else(|| cleaned.strip_prefix("git://"))
436 .unwrap_or(cleaned);
437
438 let normalized = if let Some(rest) = without_proto.strip_prefix("git@") {
440 rest.replacen(':', "/", 1)
441 } else {
442 without_proto.to_string()
443 };
444
445 let parts: Vec<&str> = normalized.split('/').collect();
447 if parts.len() >= 2 {
448 let owner = parts[parts.len() - 2];
449 let repo = parts[parts.len() - 1];
450 return format!("{owner}_{repo}");
451 }
452 }
453
454 source_name.to_string()
456}
457
458fn extract_owner_repo_from_id(source_id: &SourceId, source_name: &str) -> String {
459 match source_id {
460 SourceId::Git { url } => extract_owner_repo(Some(url.as_ref()), source_name),
461 SourceId::Path { .. } => extract_owner_repo(None, source_name),
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use crate::config::*;
469 use crate::lock::LockFile;
470 use crate::resolve::{ResolvedGraph, ResolvedNode};
471 use crate::source::ResolvedRef;
472 use indexmap::IndexMap;
473 use std::fs;
474 use tempfile::TempDir;
475
476 fn make_source_tree(agents: &[(&str, &str)], skills: &[(&str, &str)]) -> TempDir {
478 let dir = TempDir::new().unwrap();
479 if !agents.is_empty() {
480 let agents_dir = dir.path().join("agents");
481 fs::create_dir_all(&agents_dir).unwrap();
482 for (name, content) in agents {
483 fs::write(agents_dir.join(name), content).unwrap();
484 }
485 }
486 if !skills.is_empty() {
487 let skills_dir = dir.path().join("skills");
488 fs::create_dir_all(&skills_dir).unwrap();
489 for (name, content) in skills {
490 let skill_dir = skills_dir.join(name);
491 fs::create_dir_all(&skill_dir).unwrap();
492 fs::write(skill_dir.join("SKILL.md"), content).unwrap();
493 }
494 }
495 dir
496 }
497
498 fn make_graph_and_config(
499 sources: Vec<(&str, &TempDir, Option<&str>, FilterMode)>,
500 ) -> (ResolvedGraph, EffectiveConfig) {
501 let mut nodes = IndexMap::new();
502 let mut order = Vec::new();
503 let mut config_sources = IndexMap::new();
504
505 for (name, tree, url, filter) in sources {
506 let url_str = url.map(|u| u.to_string());
507 nodes.insert(
508 name.into(),
509 ResolvedNode {
510 source_name: name.into(),
511 source_id: if let Some(u) = url {
512 SourceId::git(crate::types::SourceUrl::from(u))
513 } else {
514 SourceId::Path {
515 canonical: tree.path().to_path_buf(),
516 }
517 },
518 resolved_ref: ResolvedRef {
519 source_name: name.into(),
520 version: None,
521 version_tag: None,
522 commit: None,
523 tree_path: tree.path().to_path_buf(),
524 },
525 manifest: None,
526 deps: vec![],
527 },
528 );
529 order.push(name.into());
530
531 let spec = if let Some(u) = url {
532 SourceSpec::Git(GitSpec {
533 url: crate::types::SourceUrl::from(u),
534 version: None,
535 })
536 } else {
537 SourceSpec::Path(tree.path().to_path_buf())
538 };
539
540 config_sources.insert(
541 name.into(),
542 EffectiveSource {
543 name: name.into(),
544 id: if let Some(u) = url {
545 SourceId::git(crate::types::SourceUrl::from(u))
546 } else {
547 SourceId::Path {
548 canonical: tree.path().to_path_buf(),
549 }
550 },
551 spec,
552 filter,
553 rename: RenameMap::new(),
554 is_overridden: false,
555 original_git: url_str.map(|u| GitSpec {
556 url: crate::types::SourceUrl::from(u),
557 version: None,
558 }),
559 },
560 );
561 }
562
563 let graph = ResolvedGraph {
564 nodes,
565 order,
566 id_index: std::collections::HashMap::new(),
567 };
568 let config = EffectiveConfig {
569 sources: config_sources,
570 settings: Settings::default(),
571 };
572 (graph, config)
573 }
574
575 #[test]
578 fn extract_github_https_url() {
579 let result = extract_owner_repo(Some("https://github.com/haowjy/meridian-base"), "base");
580 assert_eq!(result, "haowjy_meridian-base");
581 }
582
583 #[test]
584 fn extract_github_https_with_git_suffix() {
585 let result =
586 extract_owner_repo(Some("https://github.com/haowjy/meridian-base.git"), "base");
587 assert_eq!(result, "haowjy_meridian-base");
588 }
589
590 #[test]
591 fn extract_github_ssh_url() {
592 let result = extract_owner_repo(Some("git@github.com:haowjy/meridian-base.git"), "base");
593 assert_eq!(result, "haowjy_meridian-base");
594 }
595
596 #[test]
597 fn extract_bare_github_url() {
598 let result = extract_owner_repo(Some("github.com/someone/cool-agents"), "cool");
599 assert_eq!(result, "someone_cool-agents");
600 }
601
602 #[test]
603 fn extract_fallback_to_source_name() {
604 let result = extract_owner_repo(None, "my-source");
605 assert_eq!(result, "my-source");
606 }
607
608 #[test]
609 fn extract_from_short_url() {
610 let result = extract_owner_repo(Some("single-segment"), "fallback");
611 assert_eq!(result, "fallback");
612 }
613
614 #[test]
617 fn filter_all_returns_everything() {
618 let tree = make_source_tree(
619 &[("coder.md", "# coder"), ("reviewer.md", "# reviewer")],
620 &[("planning", "# planning")],
621 );
622 let discovered = discover::discover_source(tree.path(), None).unwrap();
623 let filtered = apply_filter(&discovered, &FilterMode::All, tree.path()).unwrap();
624 assert_eq!(filtered.len(), 3);
625 }
626
627 #[test]
628 fn filter_exclude_removes_items() {
629 let tree = make_source_tree(
630 &[("coder.md", "# coder"), ("reviewer.md", "# reviewer")],
631 &[],
632 );
633 let discovered = discover::discover_source(tree.path(), None).unwrap();
634 let filtered = apply_filter(
635 &discovered,
636 &FilterMode::Exclude(vec!["reviewer".into()]),
637 tree.path(),
638 )
639 .unwrap();
640 assert_eq!(filtered.len(), 1);
641 assert_eq!(filtered[0].id.name, "coder");
642 }
643
644 #[test]
645 fn filter_include_agents_only() {
646 let tree = make_source_tree(
647 &[("coder.md", "# coder"), ("reviewer.md", "# reviewer")],
648 &[("planning", "# planning")],
649 );
650 let discovered = discover::discover_source(tree.path(), None).unwrap();
651 let filtered = apply_filter(
652 &discovered,
653 &FilterMode::Include {
654 agents: vec!["coder".into()],
655 skills: vec![],
656 },
657 tree.path(),
658 )
659 .unwrap();
660 assert_eq!(filtered.len(), 1);
661 assert_eq!(filtered[0].id.name, "coder");
662 }
663
664 #[test]
665 fn filter_include_with_transitive_skill_deps() {
666 let tree = make_source_tree(
667 &[(
668 "coder.md",
669 "---\nskills:\n - planning\n---\n# Coder agent\n",
670 )],
671 &[
672 ("planning", "# Planning skill"),
673 ("review", "# Review skill"),
674 ],
675 );
676 let discovered = discover::discover_source(tree.path(), None).unwrap();
677 let filtered = apply_filter(
678 &discovered,
679 &FilterMode::Include {
680 agents: vec!["coder".into()],
681 skills: vec![],
682 },
683 tree.path(),
684 )
685 .unwrap();
686 assert_eq!(filtered.len(), 2);
688 let names: Vec<&str> = filtered.iter().map(|i| i.id.name.as_str()).collect();
689 assert!(names.contains(&"coder"));
690 assert!(names.contains(&"planning"));
691 }
692
693 #[test]
696 fn build_single_source_no_filter() {
697 let tree = make_source_tree(&[("coder.md", "# coder")], &[("planning", "# planning")]);
698 let (graph, config) = make_graph_and_config(vec![(
699 "base",
700 &tree,
701 Some("https://github.com/org/base"),
702 FilterMode::All,
703 )]);
704
705 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
706 assert!(renames.is_empty());
707 assert_eq!(target.items.len(), 2);
708 assert!(target.items.contains_key("agents/coder.md"));
709 assert!(target.items.contains_key("skills/planning"));
710 }
711
712 #[test]
713 fn build_with_path_rename_mapping() {
714 let tree = make_source_tree(&[("old-name.md", "# old")], &[]);
715
716 let (graph, mut config) = make_graph_and_config(vec![(
717 "base",
718 &tree,
719 Some("https://github.com/org/base"),
720 FilterMode::All,
721 )]);
722
723 config
725 .sources
726 .get_mut("base")
727 .unwrap()
728 .rename
729 .insert("agents/old-name.md".into(), "agents/new-name.md".into());
730
731 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
732 assert!(renames.is_empty());
733 assert_eq!(target.items.len(), 1);
734 assert!(target.items.contains_key("agents/new-name.md"));
735 assert_eq!(target.items["agents/new-name.md"].id.name, "new-name");
736 }
737
738 #[test]
741 fn collision_auto_renames_both() {
742 let tree1 = make_source_tree(&[("coder.md", "# coder from source 1")], &[]);
743 let tree2 = make_source_tree(&[("coder.md", "# coder from source 2")], &[]);
744
745 let (graph, config) = make_graph_and_config(vec![
746 (
747 "source-a",
748 &tree1,
749 Some("https://github.com/alice/agents"),
750 FilterMode::All,
751 ),
752 (
753 "source-b",
754 &tree2,
755 Some("https://github.com/bob/agents"),
756 FilterMode::All,
757 ),
758 ]);
759
760 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
761 assert_eq!(renames.len(), 2);
762 assert_eq!(target.items.len(), 2);
763
764 let names: Vec<&str> = target.items.values().map(|i| i.id.name.as_str()).collect();
766 assert!(names.contains(&"coder__alice_agents"));
767 assert!(names.contains(&"coder__bob_agents"));
768 }
769
770 #[test]
771 fn no_collision_no_renames() {
772 let tree1 = make_source_tree(&[("coder.md", "# coder")], &[]);
773 let tree2 = make_source_tree(&[("reviewer.md", "# reviewer")], &[]);
774
775 let (graph, config) = make_graph_and_config(vec![
776 (
777 "source-a",
778 &tree1,
779 Some("https://github.com/alice/agents"),
780 FilterMode::All,
781 ),
782 (
783 "source-b",
784 &tree2,
785 Some("https://github.com/bob/agents"),
786 FilterMode::All,
787 ),
788 ]);
789
790 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
791 assert!(renames.is_empty());
792 assert_eq!(target.items.len(), 2);
793 }
794
795 #[test]
798 fn rewrite_skill_refs_uses_exact_skill_matches() {
799 let dir = TempDir::new().unwrap();
800 let agent_path = dir.path().join("agents/coder.md");
801 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
802 fs::write(
803 &agent_path,
804 "---\nskills:\n- plan\n- planner\n---\n# Agent\n",
805 )
806 .unwrap();
807
808 let skill_path = dir.path().join("skills/plan__org_base");
809 fs::create_dir_all(&skill_path).unwrap();
810 fs::write(skill_path.join("SKILL.md"), "# Planning").unwrap();
811
812 let mut items = IndexMap::new();
813 items.insert(
814 "agents/coder.md".into(),
815 TargetItem {
816 id: ItemId {
817 kind: ItemKind::Agent,
818 name: "coder".into(),
819 },
820 source_name: "source-a".into(),
821 source_id: SourceId::Path {
822 canonical: agent_path.clone(),
823 },
824 source_path: agent_path.clone(),
825 dest_path: "agents/coder.md".into(),
826 source_hash: hash::hash_bytes(fs::read(&agent_path).unwrap().as_slice()).into(),
827 is_flat_skill: false,
828 rewritten_content: None,
829 },
830 );
831 items.insert(
832 "skills/plan__org_base".into(),
833 TargetItem {
834 id: ItemId {
835 kind: ItemKind::Skill,
836 name: "plan__org_base".into(),
837 },
838 source_name: "source-a".into(),
839 source_id: SourceId::Path {
840 canonical: skill_path.clone(),
841 },
842 source_path: skill_path.clone(),
843 dest_path: "skills/plan__org_base".into(),
844 source_hash: hash::compute_hash(&skill_path, ItemKind::Skill)
845 .unwrap()
846 .into(),
847 is_flat_skill: false,
848 rewritten_content: None,
849 },
850 );
851
852 let mut target = TargetState { items };
853 let renames = vec![RenameAction {
854 original_name: "plan".into(),
855 new_name: "plan__org_base".into(),
856 source_name: "source-a".into(),
857 }];
858 let graph = ResolvedGraph {
859 nodes: IndexMap::new(),
860 order: vec![],
861 id_index: std::collections::HashMap::new(),
862 };
863
864 rewrite_skill_refs(&mut target, &renames, &graph).unwrap();
865
866 let rewritten = target.items["agents/coder.md"]
867 .rewritten_content
868 .as_ref()
869 .unwrap();
870 let fm = crate::frontmatter::parse(rewritten).unwrap();
871 assert_eq!(fm.skills(), vec!["plan__org_base", "planner"]);
872 }
873
874 #[test]
875 fn rewrite_skill_refs_leaves_non_matching_agents_unchanged() {
876 let dir = TempDir::new().unwrap();
877 let agent_path = dir.path().join("agents/coder.md");
878 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
879 fs::write(&agent_path, "---\nskills: [review]\n---\n# Agent\n").unwrap();
880
881 let mut items = IndexMap::new();
882 items.insert(
883 "agents/coder.md".into(),
884 TargetItem {
885 id: ItemId {
886 kind: ItemKind::Agent,
887 name: "coder".into(),
888 },
889 source_name: "source-a".into(),
890 source_id: SourceId::Path {
891 canonical: agent_path.clone(),
892 },
893 source_path: agent_path.clone(),
894 dest_path: "agents/coder.md".into(),
895 source_hash: hash::hash_bytes(fs::read(&agent_path).unwrap().as_slice()).into(),
896 is_flat_skill: false,
897 rewritten_content: None,
898 },
899 );
900
901 let mut target = TargetState { items };
902 let renames = vec![RenameAction {
903 original_name: "plan".into(),
904 new_name: "plan__org_base".into(),
905 source_name: "source-a".into(),
906 }];
907 let graph = ResolvedGraph {
908 nodes: IndexMap::new(),
909 order: vec![],
910 id_index: std::collections::HashMap::new(),
911 };
912
913 rewrite_skill_refs(&mut target, &renames, &graph).unwrap();
914 assert!(target.items["agents/coder.md"].rewritten_content.is_none());
915 }
916
917 #[test]
918 fn rewrite_skill_refs_cross_package_uses_dep_graph() {
919 let dir = TempDir::new().unwrap();
923 let agent_path = dir.path().join("agents/coder.md");
924 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
925 fs::write(&agent_path, "---\nskills:\n- planning\n---\n# Agent\n").unwrap();
926
927 let skill_b_path = dir.path().join("skills/planning__org_b");
928 fs::create_dir_all(&skill_b_path).unwrap();
929 fs::write(skill_b_path.join("SKILL.md"), "# Planning from B").unwrap();
930
931 let skill_c_path = dir.path().join("skills/planning__org_c");
932 fs::create_dir_all(&skill_c_path).unwrap();
933 fs::write(skill_c_path.join("SKILL.md"), "# Planning from C").unwrap();
934
935 let mut items = IndexMap::new();
936 items.insert(
937 "agents/coder.md".into(),
938 TargetItem {
939 id: ItemId {
940 kind: ItemKind::Agent,
941 name: "coder".into(),
942 },
943 source_name: "source-a".into(),
944 source_id: SourceId::Path {
945 canonical: agent_path.clone(),
946 },
947 source_path: agent_path.clone(),
948 dest_path: "agents/coder.md".into(),
949 source_hash: hash::hash_bytes(fs::read(&agent_path).unwrap().as_slice()).into(),
950 is_flat_skill: false,
951 rewritten_content: None,
952 },
953 );
954 items.insert(
955 "skills/planning__org_b".into(),
956 TargetItem {
957 id: ItemId {
958 kind: ItemKind::Skill,
959 name: "planning__org_b".into(),
960 },
961 source_name: "source-b".into(),
962 source_id: SourceId::Path {
963 canonical: skill_b_path.clone(),
964 },
965 source_path: skill_b_path.clone(),
966 dest_path: "skills/planning__org_b".into(),
967 source_hash: hash::compute_hash(&skill_b_path, ItemKind::Skill)
968 .unwrap()
969 .into(),
970 is_flat_skill: false,
971 rewritten_content: None,
972 },
973 );
974 items.insert(
975 "skills/planning__org_c".into(),
976 TargetItem {
977 id: ItemId {
978 kind: ItemKind::Skill,
979 name: "planning__org_c".into(),
980 },
981 source_name: "source-c".into(),
982 source_id: SourceId::Path {
983 canonical: skill_c_path.clone(),
984 },
985 source_path: skill_c_path.clone(),
986 dest_path: "skills/planning__org_c".into(),
987 source_hash: hash::compute_hash(&skill_c_path, ItemKind::Skill)
988 .unwrap()
989 .into(),
990 is_flat_skill: false,
991 rewritten_content: None,
992 },
993 );
994
995 let mut target = TargetState { items };
996 let renames = vec![
997 RenameAction {
998 original_name: "planning".into(),
999 new_name: "planning__org_b".into(),
1000 source_name: "source-b".into(),
1001 },
1002 RenameAction {
1003 original_name: "planning".into(),
1004 new_name: "planning__org_c".into(),
1005 source_name: "source-c".into(),
1006 },
1007 ];
1008
1009 let mut nodes = IndexMap::new();
1011 nodes.insert(
1012 SourceName::from("source-a"),
1013 crate::resolve::ResolvedNode {
1014 source_name: "source-a".into(),
1015 source_id: SourceId::Path {
1016 canonical: dir.path().to_path_buf(),
1017 },
1018 resolved_ref: crate::source::ResolvedRef {
1019 source_name: "source-a".into(),
1020 version: None,
1021 version_tag: None,
1022 commit: None,
1023 tree_path: dir.path().to_path_buf(),
1024 },
1025 manifest: None,
1026 deps: vec!["source-b".into()],
1027 },
1028 );
1029 let graph = ResolvedGraph {
1030 nodes,
1031 order: vec!["source-a".into()],
1032 id_index: std::collections::HashMap::new(),
1033 };
1034
1035 rewrite_skill_refs(&mut target, &renames, &graph).unwrap();
1036
1037 let rewritten = target.items["agents/coder.md"]
1038 .rewritten_content
1039 .as_ref()
1040 .expect("agent should have been rewritten");
1041 let fm = crate::frontmatter::parse(rewritten).unwrap();
1042 assert_eq!(fm.skills(), vec!["planning__org_b"]);
1044 }
1045
1046 #[test]
1049 fn build_with_agents_filter_pulls_transitive_skills() {
1050 let tree = make_source_tree(
1051 &[("coder.md", "---\nskills:\n - planning\n---\n# Coder\n")],
1052 &[("planning", "# Planning"), ("unused-skill", "# Unused")],
1053 );
1054
1055 let (graph, config) = make_graph_and_config(vec![(
1056 "base",
1057 &tree,
1058 None,
1059 FilterMode::Include {
1060 agents: vec!["coder".into()],
1061 skills: vec![],
1062 },
1063 )]);
1064
1065 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
1066 assert!(renames.is_empty());
1067 assert_eq!(target.items.len(), 2); assert!(target.items.contains_key("agents/coder.md"));
1069 assert!(target.items.contains_key("skills/planning"));
1070 assert!(!target.items.contains_key("skills/unused-skill"));
1072 }
1073
1074 #[test]
1075 fn build_with_exclude_filter() {
1076 let tree = make_source_tree(&[("coder.md", "# coder"), ("deprecated.md", "# old")], &[]);
1077
1078 let (graph, config) = make_graph_and_config(vec![(
1079 "base",
1080 &tree,
1081 None,
1082 FilterMode::Exclude(vec!["deprecated".into()]),
1083 )]);
1084
1085 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
1086 assert!(renames.is_empty());
1087 assert_eq!(target.items.len(), 1);
1088 assert!(target.items.contains_key("agents/coder.md"));
1089 }
1090
1091 #[test]
1092 fn build_target_items_have_correct_hashes() {
1093 let content = "# agent content for hash test";
1094 let tree = make_source_tree(&[("test.md", content)], &[]);
1095
1096 let (graph, config) = make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
1097
1098 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
1099 assert!(renames.is_empty());
1100 let item = &target.items["agents/test.md"];
1101 let expected_hash = hash::hash_bytes(content.as_bytes());
1102 assert_eq!(item.source_hash, expected_hash);
1103 }
1104
1105 #[test]
1106 fn unmanaged_disk_path_collision_errors() {
1107 let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
1108 let (graph, config) = make_graph_and_config(vec![(
1109 "base",
1110 &tree,
1111 Some("https://github.com/org/base"),
1112 FilterMode::All,
1113 )]);
1114
1115 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
1116 assert!(renames.is_empty());
1117 let install_root = TempDir::new().unwrap();
1118
1119 let existing = install_root.path().join("agents").join("coder.md");
1121 fs::create_dir_all(existing.parent().unwrap()).unwrap();
1122 fs::write(&existing, "# user-authored").unwrap();
1123
1124 let err = check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target)
1125 .unwrap_err();
1126 let message = err.to_string();
1127 assert!(message.contains("refusing to overwrite unmanaged path"));
1128 }
1129
1130 #[test]
1131 fn unmanaged_collision_skipped_when_hash_matches() {
1132 let content = "# managed agent";
1133 let tree = make_source_tree(&[("coder.md", content)], &[]);
1134 let (graph, config) = make_graph_and_config(vec![(
1135 "base",
1136 &tree,
1137 Some("https://github.com/org/base"),
1138 FilterMode::All,
1139 )]);
1140
1141 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
1142 assert!(renames.is_empty());
1143 let install_root = TempDir::new().unwrap();
1144
1145 let existing = install_root.path().join("agents").join("coder.md");
1147 fs::create_dir_all(existing.parent().unwrap()).unwrap();
1148 fs::write(&existing, content).unwrap();
1149
1150 check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target).unwrap();
1152 }
1153
1154 #[test]
1155 fn unmanaged_collision_still_errors_on_different_content() {
1156 let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
1157 let (graph, config) = make_graph_and_config(vec![(
1158 "base",
1159 &tree,
1160 Some("https://github.com/org/base"),
1161 FilterMode::All,
1162 )]);
1163
1164 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
1165 assert!(renames.is_empty());
1166 let install_root = TempDir::new().unwrap();
1167
1168 let existing = install_root.path().join("agents").join("coder.md");
1170 fs::create_dir_all(existing.parent().unwrap()).unwrap();
1171 fs::write(&existing, "# different user content").unwrap();
1172
1173 let err = check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target)
1174 .unwrap_err();
1175 assert!(
1176 err.to_string()
1177 .contains("refusing to overwrite unmanaged path")
1178 );
1179 }
1180}