Skip to main content

mars_agents/sync/
target.rs

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/// What `.agents/` should look like after sync.
17///
18/// Built from the resolved graph with intent-based filtering applied.
19#[derive(Debug, Clone)]
20pub struct TargetState {
21    /// Keyed by dest_path (relative to .agents/).
22    pub items: IndexMap<DestPath, TargetItem>,
23}
24
25/// A single item in the desired target state.
26#[derive(Debug, Clone)]
27pub struct TargetItem {
28    pub id: ItemId,
29    pub source_name: SourceName,
30    pub source_id: SourceId,
31    /// Path to content in fetched source tree.
32    pub source_path: PathBuf,
33    /// Relative path under `.agents/` (reflects rename if any).
34    pub dest_path: DestPath,
35    /// SHA-256 of source content.
36    pub source_hash: ContentHash,
37    /// True when this item comes from root-level `SKILL.md` flat skill discovery.
38    pub is_flat_skill: bool,
39    /// Optional in-memory content override after frontmatter rewrites.
40    pub rewritten_content: Option<String>,
41}
42
43/// Rename action produced by collision detection.
44#[derive(Debug, Clone)]
45pub struct RenameAction {
46    pub original_name: ItemName,
47    pub new_name: ItemName,
48    pub source_name: SourceName,
49}
50
51/// Build target state with collision detection integrated.
52///
53/// This is the main entry point — it builds the target, detects collisions,
54/// applies auto-renames, and returns both the target state and rename actions.
55pub fn build_with_collisions(
56    graph: &ResolvedGraph,
57    config: &EffectiveConfig,
58) -> Result<(TargetState, Vec<RenameAction>), MarsError> {
59    // Phase 1: Collect all items without dedup
60    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    // Phase 2: Detect collisions on dest_path
117    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    // Phase 3: Apply auto-rename for collisions
126    for indices in dest_counts.values() {
127        if indices.len() <= 1 {
128            continue;
129        }
130
131        // Collision detected — rename all colliding items
132        for &idx in indices {
133            let item = &all_items[idx];
134            let original_name = item.id.name.clone();
135
136            // Extract owner_repo suffix from source URL or source name
137            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            // Apply rename in-place
153            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    // Phase 4: Build final TargetState from (possibly renamed) items
160    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
169/// Rewrite frontmatter skill references for renamed transitive deps.
170///
171/// When a collision forces a rename AND affected agents have frontmatter
172/// `skills:` references to the renamed skill, mars rewrites those references
173/// to point at the correct renamed version.
174pub 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    // Build rename map for skills only:
186    // original skill name -> [(renamed skill name, source name)].
187    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    // For each agent in target, check if it references any renamed skills
206    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/// Existing on-disk destination that is not lock-managed.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct UnmanagedCollision {
269    pub source_name: SourceName,
270    pub path: DestPath,
271}
272
273/// Detect target installs that would overwrite unmanaged on-disk content.
274///
275/// If a target destination already exists but is not tracked in the lock file,
276/// treat it as user-authored content and report it as a collision so callers can
277/// skip installation while leaving existing files untouched.
278pub 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            // Check if disk content matches what we'd install — if so,
293            // this is a partial prior install (crash recovery), not an
294            // unmanaged user file. Safe to overwrite.
295            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
367/// Apply filter mode to discovered items.
368///
369/// For Include mode with agents: also resolves transitive skill dependencies
370/// by parsing agent frontmatter.
371fn 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                        // Match against full source path or just the name
386                        path_str == e.as_ref() || item.id.name == *e
387                    })
388                })
389                .cloned()
390                .collect())
391        }
392
393        FilterMode::Include { agents, skills } => {
394            // Start with explicitly requested items
395            let mut include_set: std::collections::HashSet<ItemName> =
396                std::collections::HashSet::new();
397
398            // Add explicitly requested agents and skills
399            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 transitive skill deps from agent frontmatter
407            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            // Collect all agents
424            let agents: Vec<_> = discovered
425                .iter()
426                .filter(|item| item.id.kind == ItemKind::Agent)
427                .cloned()
428                .collect();
429
430            // Resolve transitive skill deps from all agent frontmatter
431            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            // Include agents + their transitive skill deps only
437            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
452/// Resolve transitive skill dependencies from agent frontmatter.
453///
454/// For each agent name, finds the matching discovered item and parses its
455/// frontmatter to extract skill dependencies, inserting them into the provided set.
456fn 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
476/// Extract `{owner}_{repo}` from a source URL.
477///
478/// For git URLs like `github.com/haowjy/meridian-base`, extracts `haowjy_meridian-base`.
479/// For path sources, uses the source name directly.
480pub fn extract_owner_repo(url: Option<&str>, source_name: &str) -> String {
481    if let Some(url) = url {
482        // Try to extract from URL patterns:
483        // github.com/owner/repo, https://github.com/owner/repo.git, etc.
484        let cleaned = url.trim_end_matches('/').trim_end_matches(".git");
485
486        // Strip protocol
487        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        // Handle git@ SSH format: git@github.com:owner/repo
495        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        // Split by '/' and take last two parts as owner/repo
502        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    // Fallback: use source name
511    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    /// Helper: create a source tree with agents and skills
533    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    // === extract_owner_repo tests ===
632
633    #[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    // === Filter tests ===
671
672    #[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        // Should include coder agent + planning skill (transitive dep)
743        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    // === Target build tests ===
750
751    #[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        // Add rename mapping
780        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    // === Collision tests ===
795
796    #[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        // Both should have been renamed
821        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    // === Frontmatter rewriting tests ===
852
853    #[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        // Source A has an agent referencing skill "planning".
976        // Source B and C both provide "planning", causing collision rename.
977        // Source A depends on B (not C). The agent should get B's renamed version.
978        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        // Build a graph where source-a depends on source-b (not source-c)
1066        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        // Should pick source-b's version (the dependency), not source-c's
1099        assert_eq!(fm.skills(), vec!["planning__org_b"]);
1100    }
1101
1102    // === Source with agents filter + skill deps ===
1103
1104    #[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); // coder + planning
1124        assert!(target.items.contains_key("agents/coder.md"));
1125        assert!(target.items.contains_key("skills/planning"));
1126        // unused-skill should NOT be present
1127        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        // Existing user-authored file at the same destination.
1176        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        // Simulate partial prior install: file on disk with same content
1206        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        // Should skip collision — disk content matches planned install (crash recovery)
1211        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        // User-authored file with different content
1231        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}