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::hash;
10use crate::lock::{ItemId, ItemKind, LockFile};
11use crate::resolve::ResolvedGraph;
12use crate::sync::filter::apply_filter;
13use crate::types::{
14    ContentHash, DestPath, ItemName, Materialization, RenameMap, SourceId, SourceName, SourceOrigin,
15};
16
17/// What `.agents/` should look like after sync.
18///
19/// Built from the resolved graph with intent-based filtering applied.
20#[derive(Debug, Clone)]
21pub struct TargetState {
22    /// Keyed by dest_path (relative to .agents/).
23    pub items: IndexMap<DestPath, TargetItem>,
24}
25
26/// A single item in the desired target state.
27#[derive(Debug, Clone)]
28pub struct TargetItem {
29    pub id: ItemId,
30    pub source_name: SourceName,
31    pub origin: SourceOrigin,
32    pub materialization: Materialization,
33    pub source_id: SourceId,
34    /// Path to content in fetched source tree.
35    pub source_path: PathBuf,
36    /// Relative path under `.agents/` (reflects rename if any).
37    pub dest_path: DestPath,
38    /// SHA-256 of source content.
39    pub source_hash: ContentHash,
40    /// True when this item comes from root-level `SKILL.md` flat skill discovery.
41    pub is_flat_skill: bool,
42    /// Optional in-memory content override after frontmatter rewrites.
43    pub rewritten_content: Option<String>,
44}
45
46/// Rename action produced by collision detection.
47#[derive(Debug, Clone)]
48pub struct RenameAction {
49    pub original_name: ItemName,
50    pub new_name: ItemName,
51    pub source_name: SourceName,
52}
53
54/// Build target state with collision detection integrated.
55///
56/// This is the main entry point — it builds the target, detects collisions,
57/// applies auto-renames, and returns both the target state and rename actions.
58pub fn build_with_collisions(
59    graph: &ResolvedGraph,
60    config: &EffectiveConfig,
61) -> Result<(TargetState, Vec<RenameAction>), MarsError> {
62    // Phase 1: Collect all items without dedup
63    let mut all_items: Vec<TargetItem> = Vec::new();
64
65    for source_name in &graph.order {
66        let node = &graph.nodes[source_name];
67        let source_config = config.dependencies.get(source_name);
68
69        let discovered =
70            discover::discover_source(&node.resolved_ref.tree_path, Some(source_name.as_str()))?;
71
72        let source_id = source_config
73            .map(|s| s.id.clone())
74            .unwrap_or_else(|| node.source_id.clone());
75
76        let filter = source_config
77            .map(|s| &s.filter)
78            .cloned()
79            .unwrap_or(FilterMode::All);
80
81        let renames = source_config
82            .map(|s| &s.rename)
83            .cloned()
84            .unwrap_or_default();
85
86        let filtered = apply_filter(&discovered, &filter, &node.resolved_ref.tree_path)?;
87
88        for item in filtered {
89            let is_flat_skill =
90                item.id.kind == ItemKind::Skill && item.source_path == Path::new(".");
91            let source_content_path = node.resolved_ref.tree_path.join(&item.source_path);
92            let source_hash = if is_flat_skill {
93                ContentHash::from(hash::compute_skill_hash_filtered(
94                    &source_content_path,
95                    crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
96                )?)
97            } else {
98                ContentHash::from(hash::compute_hash(&source_content_path, item.id.kind)?)
99            };
100
101            let (dest_name, dest_path) = apply_item_rename(item.id.kind, &item.id.name, &renames);
102
103            all_items.push(TargetItem {
104                id: ItemId {
105                    kind: item.id.kind,
106                    name: dest_name,
107                },
108                source_name: source_name.clone(),
109                origin: SourceOrigin::Dependency(source_name.clone()),
110                materialization: Materialization::Copy,
111                source_id: source_id.clone(),
112                source_path: source_content_path,
113                dest_path,
114                source_hash,
115                is_flat_skill,
116                rewritten_content: None,
117            });
118        }
119    }
120
121    // Phase 2: Detect collisions on dest_path
122    let mut dest_counts: HashMap<DestPath, Vec<usize>> = HashMap::new();
123    for (idx, item) in all_items.iter().enumerate() {
124        let key = item.dest_path.clone();
125        dest_counts.entry(key).or_default().push(idx);
126    }
127
128    let mut rename_actions = Vec::new();
129
130    // Phase 3: Apply auto-rename for collisions
131    for indices in dest_counts.values() {
132        if indices.len() <= 1 {
133            continue;
134        }
135
136        // Collision detected — rename all colliding items
137        for &idx in indices {
138            let item = &all_items[idx];
139            let original_name = item.id.name.clone();
140
141            // Extract owner_repo suffix from source URL or source name
142            let suffix = extract_owner_repo_from_id(&item.source_id, &item.source_name);
143            let new_name = format!("{original_name}__{suffix}");
144            let new_item_name = ItemName::from(new_name.clone());
145
146            let new_dest_path = DestPath::from(match item.id.kind {
147                ItemKind::Agent => PathBuf::from("agents").join(format!("{new_name}.md")),
148                ItemKind::Skill => PathBuf::from("skills").join(&new_name),
149            });
150
151            rename_actions.push(RenameAction {
152                original_name: original_name.clone(),
153                new_name: new_item_name.clone(),
154                source_name: item.source_name.clone(),
155            });
156
157            // Apply rename in-place
158            let item_mut = &mut all_items[idx];
159            item_mut.id.name = new_item_name;
160            item_mut.dest_path = new_dest_path;
161        }
162    }
163
164    // Phase 4: Build final TargetState from (possibly renamed) items
165    let mut items = IndexMap::new();
166    for item in all_items {
167        let key = item.dest_path.clone();
168        items.insert(key, item);
169    }
170
171    Ok((TargetState { items }, rename_actions))
172}
173
174// Re-export for API compatibility — rewrite_skill_refs moved to sync::rewrite.
175pub use crate::sync::rewrite::rewrite_skill_refs;
176
177/// Existing on-disk destination that is not lock-managed.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct UnmanagedCollision {
180    pub source_name: SourceName,
181    pub path: DestPath,
182}
183
184/// Detect target installs that would overwrite unmanaged on-disk content.
185///
186/// If a target destination already exists but is not tracked in the lock file,
187/// treat it as user-authored content and report it as a collision so callers can
188/// skip installation while leaving existing files untouched.
189pub fn check_unmanaged_collisions(
190    install_target: &Path,
191    lock: &LockFile,
192    target: &TargetState,
193) -> Vec<UnmanagedCollision> {
194    let mut collisions = Vec::new();
195
196    for (dest_key, target_item) in &target.items {
197        if lock.items.contains_key(dest_key) {
198            continue;
199        }
200
201        let disk_path = install_target.join(&target_item.dest_path);
202        if disk_path.exists() {
203            // Check if disk content matches what we'd install — if so,
204            // this is a partial prior install (crash recovery), not an
205            // unmanaged user file. Safe to overwrite.
206            if let Ok(disk_hash) = hash::compute_hash(&disk_path, target_item.id.kind)
207                && disk_hash == target_item.source_hash.as_str()
208            {
209                continue;
210            }
211
212            collisions.push(UnmanagedCollision {
213                source_name: target_item.source_name.clone(),
214                path: target_item.dest_path.clone(),
215            });
216        }
217    }
218
219    collisions
220}
221
222fn apply_item_rename(kind: ItemKind, item_name: &str, renames: &RenameMap) -> (ItemName, DestPath) {
223    let default_dest = default_dest_path(kind, item_name);
224    let default_key = default_dest.to_string_lossy().to_string();
225
226    let rename_value = renames.get(&default_key).or_else(|| renames.get(item_name));
227
228    let dest_path = match rename_value {
229        Some(value) => parse_rename_dest(kind, value.as_str()),
230        None => default_dest,
231    };
232    let dest_name = dest_name_from_path(kind, &dest_path);
233
234    (ItemName::from(dest_name), DestPath::from(dest_path))
235}
236
237fn default_dest_path(kind: ItemKind, name: &str) -> PathBuf {
238    match kind {
239        ItemKind::Agent => PathBuf::from("agents").join(format!("{name}.md")),
240        ItemKind::Skill => PathBuf::from("skills").join(name),
241    }
242}
243
244fn parse_rename_dest(kind: ItemKind, rename_value: &str) -> PathBuf {
245    let value = PathBuf::from(rename_value);
246    let has_prefix = value.starts_with("agents") || value.starts_with("skills");
247    let has_parent = value.parent().is_some_and(|p| p != Path::new(""));
248
249    if has_prefix || has_parent {
250        return value;
251    }
252
253    match kind {
254        ItemKind::Agent => {
255            if rename_value.ends_with(".md") {
256                PathBuf::from("agents").join(rename_value)
257            } else {
258                PathBuf::from("agents").join(format!("{rename_value}.md"))
259            }
260        }
261        ItemKind::Skill => PathBuf::from("skills").join(rename_value),
262    }
263}
264
265fn dest_name_from_path(kind: ItemKind, path: &Path) -> String {
266    match kind {
267        ItemKind::Agent => path
268            .file_stem()
269            .map(|s| s.to_string_lossy().to_string())
270            .unwrap_or_default(),
271        ItemKind::Skill => path
272            .file_name()
273            .map(|s| s.to_string_lossy().to_string())
274            .unwrap_or_default(),
275    }
276}
277
278/// Extract `{owner}_{repo}` from a source URL.
279///
280/// For git URLs like `github.com/haowjy/meridian-base`, extracts `haowjy_meridian-base`.
281/// For path sources, uses the source name directly.
282pub fn extract_owner_repo(url: Option<&str>, source_name: &str) -> String {
283    if let Some(url) = url {
284        // Try to extract from URL patterns:
285        // github.com/owner/repo, https://github.com/owner/repo.git, etc.
286        let cleaned = url.trim_end_matches('/').trim_end_matches(".git");
287
288        // Strip protocol
289        let without_proto = cleaned
290            .strip_prefix("https://")
291            .or_else(|| cleaned.strip_prefix("http://"))
292            .or_else(|| cleaned.strip_prefix("ssh://"))
293            .or_else(|| cleaned.strip_prefix("git://"))
294            .unwrap_or(cleaned);
295
296        // Handle git@ SSH format: git@github.com:owner/repo
297        let normalized = if let Some(rest) = without_proto.strip_prefix("git@") {
298            rest.replacen(':', "/", 1)
299        } else {
300            without_proto.to_string()
301        };
302
303        // Split by '/' and take last two parts as owner/repo
304        let parts: Vec<&str> = normalized.split('/').collect();
305        if parts.len() >= 2 {
306            let owner = parts[parts.len() - 2];
307            let repo = parts[parts.len() - 1];
308            return format!("{owner}_{repo}");
309        }
310    }
311
312    // Fallback: use source name
313    source_name.to_string()
314}
315
316fn extract_owner_repo_from_id(source_id: &SourceId, source_name: &str) -> String {
317    match source_id {
318        SourceId::Git { url } => extract_owner_repo(Some(url.as_ref()), source_name),
319        SourceId::Path { .. } => extract_owner_repo(None, source_name),
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::config::*;
327    use crate::lock::LockFile;
328    use crate::resolve::{ResolvedGraph, ResolvedNode};
329    use crate::source::ResolvedRef;
330    use indexmap::IndexMap;
331    use std::fs;
332    use tempfile::TempDir;
333
334    /// Helper: create a source tree with agents and skills
335    fn make_source_tree(agents: &[(&str, &str)], skills: &[(&str, &str)]) -> TempDir {
336        let dir = TempDir::new().unwrap();
337        if !agents.is_empty() {
338            let agents_dir = dir.path().join("agents");
339            fs::create_dir_all(&agents_dir).unwrap();
340            for (name, content) in agents {
341                fs::write(agents_dir.join(name), content).unwrap();
342            }
343        }
344        if !skills.is_empty() {
345            let skills_dir = dir.path().join("skills");
346            fs::create_dir_all(&skills_dir).unwrap();
347            for (name, content) in skills {
348                let skill_dir = skills_dir.join(name);
349                fs::create_dir_all(&skill_dir).unwrap();
350                fs::write(skill_dir.join("SKILL.md"), content).unwrap();
351            }
352        }
353        dir
354    }
355
356    fn make_graph_and_config(
357        sources: Vec<(&str, &TempDir, Option<&str>, FilterMode)>,
358    ) -> (ResolvedGraph, EffectiveConfig) {
359        let mut nodes = IndexMap::new();
360        let mut order = Vec::new();
361        let mut config_dependencies = IndexMap::new();
362
363        for (name, tree, url, filter) in sources {
364            let url_str = url.map(|u| u.to_string());
365            nodes.insert(
366                name.into(),
367                ResolvedNode {
368                    source_name: name.into(),
369                    source_id: if let Some(u) = url {
370                        SourceId::git(crate::types::SourceUrl::from(u))
371                    } else {
372                        SourceId::Path {
373                            canonical: tree.path().to_path_buf(),
374                        }
375                    },
376                    resolved_ref: ResolvedRef {
377                        source_name: name.into(),
378                        version: None,
379                        version_tag: None,
380                        commit: None,
381                        tree_path: tree.path().to_path_buf(),
382                    },
383                    manifest: None,
384                    deps: vec![],
385                },
386            );
387            order.push(name.into());
388
389            let spec = if let Some(u) = url {
390                SourceSpec::Git(GitSpec {
391                    url: crate::types::SourceUrl::from(u),
392                    version: None,
393                })
394            } else {
395                SourceSpec::Path(tree.path().to_path_buf())
396            };
397
398            config_dependencies.insert(
399                name.into(),
400                EffectiveDependency {
401                    name: name.into(),
402                    id: if let Some(u) = url {
403                        SourceId::git(crate::types::SourceUrl::from(u))
404                    } else {
405                        SourceId::Path {
406                            canonical: tree.path().to_path_buf(),
407                        }
408                    },
409                    spec,
410                    filter,
411                    rename: RenameMap::new(),
412                    is_overridden: false,
413                    original_git: url_str.map(|u| GitSpec {
414                        url: crate::types::SourceUrl::from(u),
415                        version: None,
416                    }),
417                },
418            );
419        }
420
421        let graph = ResolvedGraph {
422            nodes,
423            order,
424            id_index: std::collections::HashMap::new(),
425        };
426        let config = EffectiveConfig {
427            dependencies: config_dependencies,
428            settings: Settings::default(),
429        };
430        (graph, config)
431    }
432
433    // === extract_owner_repo tests ===
434
435    #[test]
436    fn extract_github_https_url() {
437        let result = extract_owner_repo(Some("https://github.com/haowjy/meridian-base"), "base");
438        assert_eq!(result, "haowjy_meridian-base");
439    }
440
441    #[test]
442    fn extract_github_https_with_git_suffix() {
443        let result =
444            extract_owner_repo(Some("https://github.com/haowjy/meridian-base.git"), "base");
445        assert_eq!(result, "haowjy_meridian-base");
446    }
447
448    #[test]
449    fn extract_github_ssh_url() {
450        let result = extract_owner_repo(Some("git@github.com:haowjy/meridian-base.git"), "base");
451        assert_eq!(result, "haowjy_meridian-base");
452    }
453
454    #[test]
455    fn extract_bare_github_url() {
456        let result = extract_owner_repo(Some("github.com/someone/cool-agents"), "cool");
457        assert_eq!(result, "someone_cool-agents");
458    }
459
460    #[test]
461    fn extract_fallback_to_source_name() {
462        let result = extract_owner_repo(None, "my-source");
463        assert_eq!(result, "my-source");
464    }
465
466    #[test]
467    fn extract_from_short_url() {
468        let result = extract_owner_repo(Some("single-segment"), "fallback");
469        assert_eq!(result, "fallback");
470    }
471
472    // === Target build tests ===
473
474    #[test]
475    fn build_single_source_no_filter() {
476        let tree = make_source_tree(&[("coder.md", "# coder")], &[("planning", "# planning")]);
477        let (graph, config) = make_graph_and_config(vec![(
478            "base",
479            &tree,
480            Some("https://github.com/org/base"),
481            FilterMode::All,
482        )]);
483
484        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
485        assert!(renames.is_empty());
486        assert_eq!(target.items.len(), 2);
487        assert!(target.items.contains_key("agents/coder.md"));
488        assert!(target.items.contains_key("skills/planning"));
489    }
490
491    #[test]
492    fn build_with_path_rename_mapping() {
493        let tree = make_source_tree(&[("old-name.md", "# old")], &[]);
494
495        let (graph, mut config) = make_graph_and_config(vec![(
496            "base",
497            &tree,
498            Some("https://github.com/org/base"),
499            FilterMode::All,
500        )]);
501
502        // Add rename mapping
503        config
504            .dependencies
505            .get_mut("base")
506            .unwrap()
507            .rename
508            .insert("agents/old-name.md".into(), "agents/new-name.md".into());
509
510        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
511        assert!(renames.is_empty());
512        assert_eq!(target.items.len(), 1);
513        assert!(target.items.contains_key("agents/new-name.md"));
514        assert_eq!(target.items["agents/new-name.md"].id.name, "new-name");
515    }
516
517    // === Collision tests ===
518
519    #[test]
520    fn collision_auto_renames_both() {
521        let tree1 = make_source_tree(&[("coder.md", "# coder from source 1")], &[]);
522        let tree2 = make_source_tree(&[("coder.md", "# coder from source 2")], &[]);
523
524        let (graph, config) = make_graph_and_config(vec![
525            (
526                "source-a",
527                &tree1,
528                Some("https://github.com/alice/agents"),
529                FilterMode::All,
530            ),
531            (
532                "source-b",
533                &tree2,
534                Some("https://github.com/bob/agents"),
535                FilterMode::All,
536            ),
537        ]);
538
539        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
540        assert_eq!(renames.len(), 2);
541        assert_eq!(target.items.len(), 2);
542
543        // Both should have been renamed
544        let names: Vec<&str> = target.items.values().map(|i| i.id.name.as_str()).collect();
545        assert!(names.contains(&"coder__alice_agents"));
546        assert!(names.contains(&"coder__bob_agents"));
547    }
548
549    #[test]
550    fn no_collision_no_renames() {
551        let tree1 = make_source_tree(&[("coder.md", "# coder")], &[]);
552        let tree2 = make_source_tree(&[("reviewer.md", "# reviewer")], &[]);
553
554        let (graph, config) = make_graph_and_config(vec![
555            (
556                "source-a",
557                &tree1,
558                Some("https://github.com/alice/agents"),
559                FilterMode::All,
560            ),
561            (
562                "source-b",
563                &tree2,
564                Some("https://github.com/bob/agents"),
565                FilterMode::All,
566            ),
567        ]);
568
569        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
570        assert!(renames.is_empty());
571        assert_eq!(target.items.len(), 2);
572    }
573
574    // === Source with agents filter + skill deps ===
575
576    #[test]
577    fn build_with_agents_filter_pulls_transitive_skills() {
578        let tree = make_source_tree(
579            &[("coder.md", "---\nskills:\n  - planning\n---\n# Coder\n")],
580            &[("planning", "# Planning"), ("unused-skill", "# Unused")],
581        );
582
583        let (graph, config) = make_graph_and_config(vec![(
584            "base",
585            &tree,
586            None,
587            FilterMode::Include {
588                agents: vec!["coder".into()],
589                skills: vec![],
590            },
591        )]);
592
593        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
594        assert!(renames.is_empty());
595        assert_eq!(target.items.len(), 2); // coder + planning
596        assert!(target.items.contains_key("agents/coder.md"));
597        assert!(target.items.contains_key("skills/planning"));
598        // unused-skill should NOT be present
599        assert!(!target.items.contains_key("skills/unused-skill"));
600    }
601
602    #[test]
603    fn build_with_exclude_filter() {
604        let tree = make_source_tree(&[("coder.md", "# coder"), ("deprecated.md", "# old")], &[]);
605
606        let (graph, config) = make_graph_and_config(vec![(
607            "base",
608            &tree,
609            None,
610            FilterMode::Exclude(vec!["deprecated".into()]),
611        )]);
612
613        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
614        assert!(renames.is_empty());
615        assert_eq!(target.items.len(), 1);
616        assert!(target.items.contains_key("agents/coder.md"));
617    }
618
619    #[test]
620    fn build_target_items_have_correct_hashes() {
621        let content = "# agent content for hash test";
622        let tree = make_source_tree(&[("test.md", content)], &[]);
623
624        let (graph, config) = make_graph_and_config(vec![("base", &tree, None, FilterMode::All)]);
625
626        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
627        assert!(renames.is_empty());
628        let item = &target.items["agents/test.md"];
629        let expected_hash = hash::hash_bytes(content.as_bytes());
630        assert_eq!(item.source_hash, expected_hash);
631    }
632
633    #[test]
634    fn unmanaged_disk_path_collision_reported() {
635        let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
636        let (graph, config) = make_graph_and_config(vec![(
637            "base",
638            &tree,
639            Some("https://github.com/org/base"),
640            FilterMode::All,
641        )]);
642
643        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
644        assert!(renames.is_empty());
645        let install_root = TempDir::new().unwrap();
646
647        // Existing user-authored file at the same destination.
648        let existing = install_root.path().join("agents").join("coder.md");
649        fs::create_dir_all(existing.parent().unwrap()).unwrap();
650        fs::write(&existing, "# user-authored").unwrap();
651
652        let collisions =
653            check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
654        assert_eq!(collisions.len(), 1);
655        assert_eq!(collisions[0].source_name.as_ref(), "base");
656        assert_eq!(
657            collisions[0].path.as_ref().to_string_lossy(),
658            "agents/coder.md"
659        );
660    }
661
662    #[test]
663    fn unmanaged_collision_skipped_when_hash_matches() {
664        let content = "# managed agent";
665        let tree = make_source_tree(&[("coder.md", content)], &[]);
666        let (graph, config) = make_graph_and_config(vec![(
667            "base",
668            &tree,
669            Some("https://github.com/org/base"),
670            FilterMode::All,
671        )]);
672
673        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
674        assert!(renames.is_empty());
675        let install_root = TempDir::new().unwrap();
676
677        // Simulate partial prior install: file on disk with same content
678        let existing = install_root.path().join("agents").join("coder.md");
679        fs::create_dir_all(existing.parent().unwrap()).unwrap();
680        fs::write(&existing, content).unwrap();
681
682        // Should skip collision — disk content matches planned install (crash recovery)
683        let collisions =
684            check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
685        assert!(collisions.is_empty());
686    }
687
688    #[test]
689    fn unmanaged_collision_reported_on_different_content() {
690        let tree = make_source_tree(&[("coder.md", "# managed")], &[]);
691        let (graph, config) = make_graph_and_config(vec![(
692            "base",
693            &tree,
694            Some("https://github.com/org/base"),
695            FilterMode::All,
696        )]);
697
698        let (target, renames) = build_with_collisions(&graph, &config).unwrap();
699        assert!(renames.is_empty());
700        let install_root = TempDir::new().unwrap();
701
702        // User-authored file with different content
703        let existing = install_root.path().join("agents").join("coder.md");
704        fs::create_dir_all(existing.parent().unwrap()).unwrap();
705        fs::write(&existing, "# different user content").unwrap();
706
707        let collisions =
708            check_unmanaged_collisions(install_root.path(), &LockFile::empty(), &target);
709        assert_eq!(collisions.len(), 1);
710        assert_eq!(collisions[0].source_name.as_ref(), "base");
711        assert_eq!(
712            collisions[0].path.as_ref().to_string_lossy(),
713            "agents/coder.md"
714        );
715    }
716}