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.dependencies.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
266#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct UnmanagedCollision {
269 pub source_name: SourceName,
270 pub path: DestPath,
271}
272
273pub fn check_unmanaged_collisions(
279 install_target: &Path,
280 lock: &LockFile,
281 target: &TargetState,
282) -> Vec<UnmanagedCollision> {
283 let mut collisions = Vec::new();
284
285 for (dest_key, target_item) in &target.items {
286 if lock.items.contains_key(dest_key) {
287 continue;
288 }
289
290 let disk_path = install_target.join(&target_item.dest_path);
291 if disk_path.exists() {
292 if let Ok(disk_hash) = hash::compute_hash(&disk_path, target_item.id.kind)
296 && disk_hash == target_item.source_hash.as_str()
297 {
298 continue;
299 }
300
301 collisions.push(UnmanagedCollision {
302 source_name: target_item.source_name.clone(),
303 path: target_item.dest_path.clone(),
304 });
305 }
306 }
307
308 collisions
309}
310
311fn apply_item_rename(kind: ItemKind, item_name: &str, renames: &RenameMap) -> (ItemName, DestPath) {
312 let default_dest = default_dest_path(kind, item_name);
313 let default_key = default_dest.to_string_lossy().to_string();
314
315 let rename_value = renames.get(&default_key).or_else(|| renames.get(item_name));
316
317 let dest_path = match rename_value {
318 Some(value) => parse_rename_dest(kind, value.as_str()),
319 None => default_dest,
320 };
321 let dest_name = dest_name_from_path(kind, &dest_path);
322
323 (ItemName::from(dest_name), DestPath::from(dest_path))
324}
325
326fn default_dest_path(kind: ItemKind, name: &str) -> PathBuf {
327 match kind {
328 ItemKind::Agent => PathBuf::from("agents").join(format!("{name}.md")),
329 ItemKind::Skill => PathBuf::from("skills").join(name),
330 }
331}
332
333fn parse_rename_dest(kind: ItemKind, rename_value: &str) -> PathBuf {
334 let value = PathBuf::from(rename_value);
335 let has_prefix = value.starts_with("agents") || value.starts_with("skills");
336 let has_parent = value.parent().is_some_and(|p| p != Path::new(""));
337
338 if has_prefix || has_parent {
339 return value;
340 }
341
342 match kind {
343 ItemKind::Agent => {
344 if rename_value.ends_with(".md") {
345 PathBuf::from("agents").join(rename_value)
346 } else {
347 PathBuf::from("agents").join(format!("{rename_value}.md"))
348 }
349 }
350 ItemKind::Skill => PathBuf::from("skills").join(rename_value),
351 }
352}
353
354fn dest_name_from_path(kind: ItemKind, path: &Path) -> String {
355 match kind {
356 ItemKind::Agent => path
357 .file_stem()
358 .map(|s| s.to_string_lossy().to_string())
359 .unwrap_or_default(),
360 ItemKind::Skill => path
361 .file_name()
362 .map(|s| s.to_string_lossy().to_string())
363 .unwrap_or_default(),
364 }
365}
366
367fn apply_filter(
372 discovered: &[discover::DiscoveredItem],
373 filter: &FilterMode,
374 tree_path: &Path,
375) -> Result<Vec<discover::DiscoveredItem>, MarsError> {
376 match filter {
377 FilterMode::All => Ok(discovered.to_vec()),
378
379 FilterMode::Exclude(excluded) => {
380 Ok(discovered
381 .iter()
382 .filter(|item| {
383 let path_str = item.source_path.to_string_lossy();
384 !excluded.iter().any(|e| {
385 path_str == e.as_ref() || item.id.name == *e
387 })
388 })
389 .cloned()
390 .collect())
391 }
392
393 FilterMode::Include { agents, skills } => {
394 let mut include_set: std::collections::HashSet<ItemName> =
396 std::collections::HashSet::new();
397
398 for a in agents {
400 include_set.insert(a.clone());
401 }
402 for s in skills {
403 include_set.insert(s.clone());
404 }
405
406 resolve_agent_skill_deps(discovered, agents, tree_path, &mut include_set);
408
409 Ok(discovered
410 .iter()
411 .filter(|item| include_set.contains(&item.id.name))
412 .cloned()
413 .collect())
414 }
415
416 FilterMode::OnlySkills => Ok(discovered
417 .iter()
418 .filter(|item| item.id.kind == ItemKind::Skill)
419 .cloned()
420 .collect()),
421
422 FilterMode::OnlyAgents => {
423 let agents: Vec<_> = discovered
425 .iter()
426 .filter(|item| item.id.kind == ItemKind::Agent)
427 .cloned()
428 .collect();
429
430 let agent_names: Vec<ItemName> = agents.iter().map(|a| a.id.name.clone()).collect();
432 let mut skill_deps: std::collections::HashSet<ItemName> =
433 std::collections::HashSet::new();
434 resolve_agent_skill_deps(discovered, &agent_names, tree_path, &mut skill_deps);
435
436 let skills: Vec<_> = discovered
438 .iter()
439 .filter(|item| {
440 item.id.kind == ItemKind::Skill && skill_deps.contains(&item.id.name)
441 })
442 .cloned()
443 .collect();
444
445 let mut result = agents;
446 result.extend(skills);
447 Ok(result)
448 }
449 }
450}
451
452fn resolve_agent_skill_deps(
457 discovered: &[discover::DiscoveredItem],
458 agent_names: &[ItemName],
459 tree_path: &Path,
460 skill_deps: &mut std::collections::HashSet<ItemName>,
461) {
462 for agent_name in agent_names {
463 if let Some(agent_item) = discovered
464 .iter()
465 .find(|i| i.id.kind == ItemKind::Agent && i.id.name == *agent_name)
466 {
467 let agent_path = tree_path.join(&agent_item.source_path);
468 let deps = validate::parse_agent_skills(&agent_path).unwrap_or_default();
469 for skill in deps {
470 skill_deps.insert(ItemName::from(skill));
471 }
472 }
473 }
474}
475
476pub fn extract_owner_repo(url: Option<&str>, source_name: &str) -> String {
481 if let Some(url) = url {
482 let cleaned = url.trim_end_matches('/').trim_end_matches(".git");
485
486 let without_proto = cleaned
488 .strip_prefix("https://")
489 .or_else(|| cleaned.strip_prefix("http://"))
490 .or_else(|| cleaned.strip_prefix("ssh://"))
491 .or_else(|| cleaned.strip_prefix("git://"))
492 .unwrap_or(cleaned);
493
494 let normalized = if let Some(rest) = without_proto.strip_prefix("git@") {
496 rest.replacen(':', "/", 1)
497 } else {
498 without_proto.to_string()
499 };
500
501 let parts: Vec<&str> = normalized.split('/').collect();
503 if parts.len() >= 2 {
504 let owner = parts[parts.len() - 2];
505 let repo = parts[parts.len() - 1];
506 return format!("{owner}_{repo}");
507 }
508 }
509
510 source_name.to_string()
512}
513
514fn extract_owner_repo_from_id(source_id: &SourceId, source_name: &str) -> String {
515 match source_id {
516 SourceId::Git { url } => extract_owner_repo(Some(url.as_ref()), source_name),
517 SourceId::Path { .. } => extract_owner_repo(None, source_name),
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use crate::config::*;
525 use crate::lock::LockFile;
526 use crate::resolve::{ResolvedGraph, ResolvedNode};
527 use crate::source::ResolvedRef;
528 use indexmap::IndexMap;
529 use std::fs;
530 use tempfile::TempDir;
531
532 fn make_source_tree(agents: &[(&str, &str)], skills: &[(&str, &str)]) -> TempDir {
534 let dir = TempDir::new().unwrap();
535 if !agents.is_empty() {
536 let agents_dir = dir.path().join("agents");
537 fs::create_dir_all(&agents_dir).unwrap();
538 for (name, content) in agents {
539 fs::write(agents_dir.join(name), content).unwrap();
540 }
541 }
542 if !skills.is_empty() {
543 let skills_dir = dir.path().join("skills");
544 fs::create_dir_all(&skills_dir).unwrap();
545 for (name, content) in skills {
546 let skill_dir = skills_dir.join(name);
547 fs::create_dir_all(&skill_dir).unwrap();
548 fs::write(skill_dir.join("SKILL.md"), content).unwrap();
549 }
550 }
551 dir
552 }
553
554 fn make_graph_and_config(
555 sources: Vec<(&str, &TempDir, Option<&str>, FilterMode)>,
556 ) -> (ResolvedGraph, EffectiveConfig) {
557 let mut nodes = IndexMap::new();
558 let mut order = Vec::new();
559 let mut config_dependencies = IndexMap::new();
560
561 for (name, tree, url, filter) in sources {
562 let url_str = url.map(|u| u.to_string());
563 nodes.insert(
564 name.into(),
565 ResolvedNode {
566 source_name: name.into(),
567 source_id: if let Some(u) = url {
568 SourceId::git(crate::types::SourceUrl::from(u))
569 } else {
570 SourceId::Path {
571 canonical: tree.path().to_path_buf(),
572 }
573 },
574 resolved_ref: ResolvedRef {
575 source_name: name.into(),
576 version: None,
577 version_tag: None,
578 commit: None,
579 tree_path: tree.path().to_path_buf(),
580 },
581 manifest: None,
582 deps: vec![],
583 },
584 );
585 order.push(name.into());
586
587 let spec = if let Some(u) = url {
588 SourceSpec::Git(GitSpec {
589 url: crate::types::SourceUrl::from(u),
590 version: None,
591 })
592 } else {
593 SourceSpec::Path(tree.path().to_path_buf())
594 };
595
596 config_dependencies.insert(
597 name.into(),
598 EffectiveDependency {
599 name: name.into(),
600 id: if let Some(u) = url {
601 SourceId::git(crate::types::SourceUrl::from(u))
602 } else {
603 SourceId::Path {
604 canonical: tree.path().to_path_buf(),
605 }
606 },
607 spec,
608 filter,
609 rename: RenameMap::new(),
610 is_overridden: false,
611 original_git: url_str.map(|u| GitSpec {
612 url: crate::types::SourceUrl::from(u),
613 version: None,
614 }),
615 },
616 );
617 }
618
619 let graph = ResolvedGraph {
620 nodes,
621 order,
622 id_index: std::collections::HashMap::new(),
623 };
624 let config = EffectiveConfig {
625 dependencies: config_dependencies,
626 settings: Settings::default(),
627 };
628 (graph, config)
629 }
630
631 #[test]
634 fn extract_github_https_url() {
635 let result = extract_owner_repo(Some("https://github.com/haowjy/meridian-base"), "base");
636 assert_eq!(result, "haowjy_meridian-base");
637 }
638
639 #[test]
640 fn extract_github_https_with_git_suffix() {
641 let result =
642 extract_owner_repo(Some("https://github.com/haowjy/meridian-base.git"), "base");
643 assert_eq!(result, "haowjy_meridian-base");
644 }
645
646 #[test]
647 fn extract_github_ssh_url() {
648 let result = extract_owner_repo(Some("git@github.com:haowjy/meridian-base.git"), "base");
649 assert_eq!(result, "haowjy_meridian-base");
650 }
651
652 #[test]
653 fn extract_bare_github_url() {
654 let result = extract_owner_repo(Some("github.com/someone/cool-agents"), "cool");
655 assert_eq!(result, "someone_cool-agents");
656 }
657
658 #[test]
659 fn extract_fallback_to_source_name() {
660 let result = extract_owner_repo(None, "my-source");
661 assert_eq!(result, "my-source");
662 }
663
664 #[test]
665 fn extract_from_short_url() {
666 let result = extract_owner_repo(Some("single-segment"), "fallback");
667 assert_eq!(result, "fallback");
668 }
669
670 #[test]
673 fn filter_all_returns_everything() {
674 let tree = make_source_tree(
675 &[("coder.md", "# coder"), ("reviewer.md", "# reviewer")],
676 &[("planning", "# planning")],
677 );
678 let discovered = discover::discover_source(tree.path(), None).unwrap();
679 let filtered = apply_filter(&discovered, &FilterMode::All, tree.path()).unwrap();
680 assert_eq!(filtered.len(), 3);
681 }
682
683 #[test]
684 fn filter_exclude_removes_items() {
685 let tree = make_source_tree(
686 &[("coder.md", "# coder"), ("reviewer.md", "# reviewer")],
687 &[],
688 );
689 let discovered = discover::discover_source(tree.path(), None).unwrap();
690 let filtered = apply_filter(
691 &discovered,
692 &FilterMode::Exclude(vec!["reviewer".into()]),
693 tree.path(),
694 )
695 .unwrap();
696 assert_eq!(filtered.len(), 1);
697 assert_eq!(filtered[0].id.name, "coder");
698 }
699
700 #[test]
701 fn filter_include_agents_only() {
702 let tree = make_source_tree(
703 &[("coder.md", "# coder"), ("reviewer.md", "# reviewer")],
704 &[("planning", "# planning")],
705 );
706 let discovered = discover::discover_source(tree.path(), None).unwrap();
707 let filtered = apply_filter(
708 &discovered,
709 &FilterMode::Include {
710 agents: vec!["coder".into()],
711 skills: vec![],
712 },
713 tree.path(),
714 )
715 .unwrap();
716 assert_eq!(filtered.len(), 1);
717 assert_eq!(filtered[0].id.name, "coder");
718 }
719
720 #[test]
721 fn filter_include_with_transitive_skill_deps() {
722 let tree = make_source_tree(
723 &[(
724 "coder.md",
725 "---\nskills:\n - planning\n---\n# Coder agent\n",
726 )],
727 &[
728 ("planning", "# Planning skill"),
729 ("review", "# Review skill"),
730 ],
731 );
732 let discovered = discover::discover_source(tree.path(), None).unwrap();
733 let filtered = apply_filter(
734 &discovered,
735 &FilterMode::Include {
736 agents: vec!["coder".into()],
737 skills: vec![],
738 },
739 tree.path(),
740 )
741 .unwrap();
742 assert_eq!(filtered.len(), 2);
744 let names: Vec<&str> = filtered.iter().map(|i| i.id.name.as_str()).collect();
745 assert!(names.contains(&"coder"));
746 assert!(names.contains(&"planning"));
747 }
748
749 #[test]
752 fn build_single_source_no_filter() {
753 let tree = make_source_tree(&[("coder.md", "# coder")], &[("planning", "# planning")]);
754 let (graph, config) = make_graph_and_config(vec![(
755 "base",
756 &tree,
757 Some("https://github.com/org/base"),
758 FilterMode::All,
759 )]);
760
761 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
762 assert!(renames.is_empty());
763 assert_eq!(target.items.len(), 2);
764 assert!(target.items.contains_key("agents/coder.md"));
765 assert!(target.items.contains_key("skills/planning"));
766 }
767
768 #[test]
769 fn build_with_path_rename_mapping() {
770 let tree = make_source_tree(&[("old-name.md", "# old")], &[]);
771
772 let (graph, mut config) = make_graph_and_config(vec![(
773 "base",
774 &tree,
775 Some("https://github.com/org/base"),
776 FilterMode::All,
777 )]);
778
779 config
781 .dependencies
782 .get_mut("base")
783 .unwrap()
784 .rename
785 .insert("agents/old-name.md".into(), "agents/new-name.md".into());
786
787 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
788 assert!(renames.is_empty());
789 assert_eq!(target.items.len(), 1);
790 assert!(target.items.contains_key("agents/new-name.md"));
791 assert_eq!(target.items["agents/new-name.md"].id.name, "new-name");
792 }
793
794 #[test]
797 fn collision_auto_renames_both() {
798 let tree1 = make_source_tree(&[("coder.md", "# coder from source 1")], &[]);
799 let tree2 = make_source_tree(&[("coder.md", "# coder from source 2")], &[]);
800
801 let (graph, config) = make_graph_and_config(vec![
802 (
803 "source-a",
804 &tree1,
805 Some("https://github.com/alice/agents"),
806 FilterMode::All,
807 ),
808 (
809 "source-b",
810 &tree2,
811 Some("https://github.com/bob/agents"),
812 FilterMode::All,
813 ),
814 ]);
815
816 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
817 assert_eq!(renames.len(), 2);
818 assert_eq!(target.items.len(), 2);
819
820 let names: Vec<&str> = target.items.values().map(|i| i.id.name.as_str()).collect();
822 assert!(names.contains(&"coder__alice_agents"));
823 assert!(names.contains(&"coder__bob_agents"));
824 }
825
826 #[test]
827 fn no_collision_no_renames() {
828 let tree1 = make_source_tree(&[("coder.md", "# coder")], &[]);
829 let tree2 = make_source_tree(&[("reviewer.md", "# reviewer")], &[]);
830
831 let (graph, config) = make_graph_and_config(vec![
832 (
833 "source-a",
834 &tree1,
835 Some("https://github.com/alice/agents"),
836 FilterMode::All,
837 ),
838 (
839 "source-b",
840 &tree2,
841 Some("https://github.com/bob/agents"),
842 FilterMode::All,
843 ),
844 ]);
845
846 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
847 assert!(renames.is_empty());
848 assert_eq!(target.items.len(), 2);
849 }
850
851 #[test]
854 fn rewrite_skill_refs_uses_exact_skill_matches() {
855 let dir = TempDir::new().unwrap();
856 let agent_path = dir.path().join("agents/coder.md");
857 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
858 fs::write(
859 &agent_path,
860 "---\nskills:\n- plan\n- planner\n---\n# Agent\n",
861 )
862 .unwrap();
863
864 let skill_path = dir.path().join("skills/plan__org_base");
865 fs::create_dir_all(&skill_path).unwrap();
866 fs::write(skill_path.join("SKILL.md"), "# Planning").unwrap();
867
868 let mut items = IndexMap::new();
869 items.insert(
870 "agents/coder.md".into(),
871 TargetItem {
872 id: ItemId {
873 kind: ItemKind::Agent,
874 name: "coder".into(),
875 },
876 source_name: "source-a".into(),
877 source_id: SourceId::Path {
878 canonical: agent_path.clone(),
879 },
880 source_path: agent_path.clone(),
881 dest_path: "agents/coder.md".into(),
882 source_hash: hash::hash_bytes(fs::read(&agent_path).unwrap().as_slice()).into(),
883 is_flat_skill: false,
884 rewritten_content: None,
885 },
886 );
887 items.insert(
888 "skills/plan__org_base".into(),
889 TargetItem {
890 id: ItemId {
891 kind: ItemKind::Skill,
892 name: "plan__org_base".into(),
893 },
894 source_name: "source-a".into(),
895 source_id: SourceId::Path {
896 canonical: skill_path.clone(),
897 },
898 source_path: skill_path.clone(),
899 dest_path: "skills/plan__org_base".into(),
900 source_hash: hash::compute_hash(&skill_path, ItemKind::Skill)
901 .unwrap()
902 .into(),
903 is_flat_skill: false,
904 rewritten_content: None,
905 },
906 );
907
908 let mut target = TargetState { items };
909 let renames = vec![RenameAction {
910 original_name: "plan".into(),
911 new_name: "plan__org_base".into(),
912 source_name: "source-a".into(),
913 }];
914 let graph = ResolvedGraph {
915 nodes: IndexMap::new(),
916 order: vec![],
917 id_index: std::collections::HashMap::new(),
918 };
919
920 rewrite_skill_refs(&mut target, &renames, &graph).unwrap();
921
922 let rewritten = target.items["agents/coder.md"]
923 .rewritten_content
924 .as_ref()
925 .unwrap();
926 let fm = crate::frontmatter::parse(rewritten).unwrap();
927 assert_eq!(fm.skills(), vec!["plan__org_base", "planner"]);
928 }
929
930 #[test]
931 fn rewrite_skill_refs_leaves_non_matching_agents_unchanged() {
932 let dir = TempDir::new().unwrap();
933 let agent_path = dir.path().join("agents/coder.md");
934 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
935 fs::write(&agent_path, "---\nskills: [review]\n---\n# Agent\n").unwrap();
936
937 let mut items = IndexMap::new();
938 items.insert(
939 "agents/coder.md".into(),
940 TargetItem {
941 id: ItemId {
942 kind: ItemKind::Agent,
943 name: "coder".into(),
944 },
945 source_name: "source-a".into(),
946 source_id: SourceId::Path {
947 canonical: agent_path.clone(),
948 },
949 source_path: agent_path.clone(),
950 dest_path: "agents/coder.md".into(),
951 source_hash: hash::hash_bytes(fs::read(&agent_path).unwrap().as_slice()).into(),
952 is_flat_skill: false,
953 rewritten_content: None,
954 },
955 );
956
957 let mut target = TargetState { items };
958 let renames = vec![RenameAction {
959 original_name: "plan".into(),
960 new_name: "plan__org_base".into(),
961 source_name: "source-a".into(),
962 }];
963 let graph = ResolvedGraph {
964 nodes: IndexMap::new(),
965 order: vec![],
966 id_index: std::collections::HashMap::new(),
967 };
968
969 rewrite_skill_refs(&mut target, &renames, &graph).unwrap();
970 assert!(target.items["agents/coder.md"].rewritten_content.is_none());
971 }
972
973 #[test]
974 fn rewrite_skill_refs_cross_package_uses_dep_graph() {
975 let dir = TempDir::new().unwrap();
979 let agent_path = dir.path().join("agents/coder.md");
980 fs::create_dir_all(agent_path.parent().unwrap()).unwrap();
981 fs::write(&agent_path, "---\nskills:\n- planning\n---\n# Agent\n").unwrap();
982
983 let skill_b_path = dir.path().join("skills/planning__org_b");
984 fs::create_dir_all(&skill_b_path).unwrap();
985 fs::write(skill_b_path.join("SKILL.md"), "# Planning from B").unwrap();
986
987 let skill_c_path = dir.path().join("skills/planning__org_c");
988 fs::create_dir_all(&skill_c_path).unwrap();
989 fs::write(skill_c_path.join("SKILL.md"), "# Planning from C").unwrap();
990
991 let mut items = IndexMap::new();
992 items.insert(
993 "agents/coder.md".into(),
994 TargetItem {
995 id: ItemId {
996 kind: ItemKind::Agent,
997 name: "coder".into(),
998 },
999 source_name: "source-a".into(),
1000 source_id: SourceId::Path {
1001 canonical: agent_path.clone(),
1002 },
1003 source_path: agent_path.clone(),
1004 dest_path: "agents/coder.md".into(),
1005 source_hash: hash::hash_bytes(fs::read(&agent_path).unwrap().as_slice()).into(),
1006 is_flat_skill: false,
1007 rewritten_content: None,
1008 },
1009 );
1010 items.insert(
1011 "skills/planning__org_b".into(),
1012 TargetItem {
1013 id: ItemId {
1014 kind: ItemKind::Skill,
1015 name: "planning__org_b".into(),
1016 },
1017 source_name: "source-b".into(),
1018 source_id: SourceId::Path {
1019 canonical: skill_b_path.clone(),
1020 },
1021 source_path: skill_b_path.clone(),
1022 dest_path: "skills/planning__org_b".into(),
1023 source_hash: hash::compute_hash(&skill_b_path, ItemKind::Skill)
1024 .unwrap()
1025 .into(),
1026 is_flat_skill: false,
1027 rewritten_content: None,
1028 },
1029 );
1030 items.insert(
1031 "skills/planning__org_c".into(),
1032 TargetItem {
1033 id: ItemId {
1034 kind: ItemKind::Skill,
1035 name: "planning__org_c".into(),
1036 },
1037 source_name: "source-c".into(),
1038 source_id: SourceId::Path {
1039 canonical: skill_c_path.clone(),
1040 },
1041 source_path: skill_c_path.clone(),
1042 dest_path: "skills/planning__org_c".into(),
1043 source_hash: hash::compute_hash(&skill_c_path, ItemKind::Skill)
1044 .unwrap()
1045 .into(),
1046 is_flat_skill: false,
1047 rewritten_content: None,
1048 },
1049 );
1050
1051 let mut target = TargetState { items };
1052 let renames = vec![
1053 RenameAction {
1054 original_name: "planning".into(),
1055 new_name: "planning__org_b".into(),
1056 source_name: "source-b".into(),
1057 },
1058 RenameAction {
1059 original_name: "planning".into(),
1060 new_name: "planning__org_c".into(),
1061 source_name: "source-c".into(),
1062 },
1063 ];
1064
1065 let mut nodes = IndexMap::new();
1067 nodes.insert(
1068 SourceName::from("source-a"),
1069 crate::resolve::ResolvedNode {
1070 source_name: "source-a".into(),
1071 source_id: SourceId::Path {
1072 canonical: dir.path().to_path_buf(),
1073 },
1074 resolved_ref: crate::source::ResolvedRef {
1075 source_name: "source-a".into(),
1076 version: None,
1077 version_tag: None,
1078 commit: None,
1079 tree_path: dir.path().to_path_buf(),
1080 },
1081 manifest: None,
1082 deps: vec!["source-b".into()],
1083 },
1084 );
1085 let graph = ResolvedGraph {
1086 nodes,
1087 order: vec!["source-a".into()],
1088 id_index: std::collections::HashMap::new(),
1089 };
1090
1091 rewrite_skill_refs(&mut target, &renames, &graph).unwrap();
1092
1093 let rewritten = target.items["agents/coder.md"]
1094 .rewritten_content
1095 .as_ref()
1096 .expect("agent should have been rewritten");
1097 let fm = crate::frontmatter::parse(rewritten).unwrap();
1098 assert_eq!(fm.skills(), vec!["planning__org_b"]);
1100 }
1101
1102 #[test]
1105 fn build_with_agents_filter_pulls_transitive_skills() {
1106 let tree = make_source_tree(
1107 &[("coder.md", "---\nskills:\n - planning\n---\n# Coder\n")],
1108 &[("planning", "# Planning"), ("unused-skill", "# Unused")],
1109 );
1110
1111 let (graph, config) = make_graph_and_config(vec![(
1112 "base",
1113 &tree,
1114 None,
1115 FilterMode::Include {
1116 agents: vec!["coder".into()],
1117 skills: vec![],
1118 },
1119 )]);
1120
1121 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
1122 assert!(renames.is_empty());
1123 assert_eq!(target.items.len(), 2); assert!(target.items.contains_key("agents/coder.md"));
1125 assert!(target.items.contains_key("skills/planning"));
1126 assert!(!target.items.contains_key("skills/unused-skill"));
1128 }
1129
1130 #[test]
1131 fn build_with_exclude_filter() {
1132 let tree = make_source_tree(&[("coder.md", "# coder"), ("deprecated.md", "# old")], &[]);
1133
1134 let (graph, config) = make_graph_and_config(vec![(
1135 "base",
1136 &tree,
1137 None,
1138 FilterMode::Exclude(vec!["deprecated".into()]),
1139 )]);
1140
1141 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
1142 assert!(renames.is_empty());
1143 assert_eq!(target.items.len(), 1);
1144 assert!(target.items.contains_key("agents/coder.md"));
1145 }
1146
1147 #[test]
1148 fn build_target_items_have_correct_hashes() {
1149 let content = "# agent content for hash test";
1150 let tree = make_source_tree(&[("test.md", content)], &[]);
1151
1152 let (graph, config) = make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
1153
1154 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
1155 assert!(renames.is_empty());
1156 let item = &target.items["agents/test.md"];
1157 let expected_hash = hash::hash_bytes(content.as_bytes());
1158 assert_eq!(item.source_hash, expected_hash);
1159 }
1160
1161 #[test]
1162 fn unmanaged_disk_path_collision_reported() {
1163 let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
1164 let (graph, config) = make_graph_and_config(vec![(
1165 "base",
1166 &tree,
1167 Some("https://github.com/org/base"),
1168 FilterMode::All,
1169 )]);
1170
1171 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
1172 assert!(renames.is_empty());
1173 let install_root = TempDir::new().unwrap();
1174
1175 let existing = install_root.path().join("agents").join("coder.md");
1177 fs::create_dir_all(existing.parent().unwrap()).unwrap();
1178 fs::write(&existing, "# user-authored").unwrap();
1179
1180 let collisions =
1181 check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
1182 assert_eq!(collisions.len(), 1);
1183 assert_eq!(collisions[0].source_name.as_ref(), "base");
1184 assert_eq!(
1185 collisions[0].path.as_ref().to_string_lossy(),
1186 "agents/coder.md"
1187 );
1188 }
1189
1190 #[test]
1191 fn unmanaged_collision_skipped_when_hash_matches() {
1192 let content = "# managed agent";
1193 let tree = make_source_tree(&[("coder.md", content)], &[]);
1194 let (graph, config) = make_graph_and_config(vec![(
1195 "base",
1196 &tree,
1197 Some("https://github.com/org/base"),
1198 FilterMode::All,
1199 )]);
1200
1201 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
1202 assert!(renames.is_empty());
1203 let install_root = TempDir::new().unwrap();
1204
1205 let existing = install_root.path().join("agents").join("coder.md");
1207 fs::create_dir_all(existing.parent().unwrap()).unwrap();
1208 fs::write(&existing, content).unwrap();
1209
1210 let collisions =
1212 check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
1213 assert!(collisions.is_empty());
1214 }
1215
1216 #[test]
1217 fn unmanaged_collision_reported_on_different_content() {
1218 let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
1219 let (graph, config) = make_graph_and_config(vec![(
1220 "base",
1221 &tree,
1222 Some("https://github.com/org/base"),
1223 FilterMode::All,
1224 )]);
1225
1226 let (target, renames) = build_with_collisions(&graph, &config).unwrap();
1227 assert!(renames.is_empty());
1228 let install_root = TempDir::new().unwrap();
1229
1230 let existing = install_root.path().join("agents").join("coder.md");
1232 fs::create_dir_all(existing.parent().unwrap()).unwrap();
1233 fs::write(&existing, "# different user content").unwrap();
1234
1235 let collisions =
1236 check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
1237 assert_eq!(collisions.len(), 1);
1238 assert_eq!(collisions[0].source_name.as_ref(), "base");
1239 assert_eq!(
1240 collisions[0].path.as_ref().to_string_lossy(),
1241 "agents/coder.md"
1242 );
1243 }
1244}