Skip to main content

mars_agents/sync/
target.rs

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