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.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    // 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/// Refuse to overwrite existing on-disk files/directories that are not managed.
267///
268/// If a target destination already exists but is not tracked in the lock file,
269/// treat it as user-authored content and fail sync.
270pub 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            // Check if disk content matches what we'd install — if so,
283            // this is a partial prior install (crash recovery), not an
284            // unmanaged user file. Safe to overwrite.
285            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
357/// Apply filter mode to discovered items.
358///
359/// For Include mode with agents: also resolves transitive skill dependencies
360/// by parsing agent frontmatter.
361fn 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                        // Match against full source path or just the name
376                        path_str == e.as_ref() || item.id.name == *e
377                    })
378                })
379                .cloned()
380                .collect())
381        }
382
383        FilterMode::Include { agents, skills } => {
384            // Start with explicitly requested items
385            let mut include_set: std::collections::HashSet<ItemName> =
386                std::collections::HashSet::new();
387
388            // Add explicitly requested agents and skills
389            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            // Resolve transitive skill deps from agent frontmatter
397            for agent_name in agents {
398                // Find the agent in discovered items
399                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
420/// Extract `{owner}_{repo}` from a source URL.
421///
422/// For git URLs like `github.com/haowjy/meridian-base`, extracts `haowjy_meridian-base`.
423/// For path sources, uses the source name directly.
424pub fn extract_owner_repo(url: Option<&str>, source_name: &str) -> String {
425    if let Some(url) = url {
426        // Try to extract from URL patterns:
427        // github.com/owner/repo, https://github.com/owner/repo.git, etc.
428        let cleaned = url.trim_end_matches('/').trim_end_matches(".git");
429
430        // Strip protocol
431        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        // Handle git@ SSH format: git@github.com:owner/repo
439        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        // Split by '/' and take last two parts as owner/repo
446        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    // Fallback: use source name
455    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    /// Helper: create a source tree with agents and skills
477    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    // === extract_owner_repo tests ===
576
577    #[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    // === Filter tests ===
615
616    #[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        // Should include coder agent + planning skill (transitive dep)
687        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    // === Target build tests ===
694
695    #[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        // Add rename mapping
724        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    // === Collision tests ===
739
740    #[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        // Both should have been renamed
765        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    // === Frontmatter rewriting tests ===
796
797    #[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        // Source A has an agent referencing skill "planning".
920        // Source B and C both provide "planning", causing collision rename.
921        // Source A depends on B (not C). The agent should get B's renamed version.
922        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        // Build a graph where source-a depends on source-b (not source-c)
1010        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        // Should pick source-b's version (the dependency), not source-c's
1043        assert_eq!(fm.skills(), vec!["planning__org_b"]);
1044    }
1045
1046    // === Source with agents filter + skill deps ===
1047
1048    #[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); // coder + planning
1068        assert!(target.items.contains_key("agents/coder.md"));
1069        assert!(target.items.contains_key("skills/planning"));
1070        // unused-skill should NOT be present
1071        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        // Existing user-authored file at the same destination.
1120        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        // Simulate partial prior install: file on disk with same content
1146        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        // Should succeed — disk content matches planned install (crash recovery)
1151        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        // User-authored file with different content
1169        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}