Skip to main content

mars_agents/sync/
mod.rs

1pub mod apply;
2pub mod diff;
3pub mod plan;
4pub mod target;
5
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8
9use indexmap::IndexMap;
10
11use crate::config::{Config, EffectiveConfig, LocalConfig, OverrideEntry, Settings, SourceEntry};
12use crate::error::{ConfigError, MarsError};
13use crate::resolve::{ManifestReader, ResolveOptions, SourceFetcher, VersionLister};
14use crate::source::{self, AvailableVersion, GlobalCache, ResolvedRef};
15use crate::sync::apply::ApplyResult;
16pub use crate::sync::apply::SyncOptions;
17use crate::types::{CommitHash, ItemName, SourceName};
18use crate::validate::ValidationWarning;
19
20/// Report from a completed sync operation.
21#[derive(Debug)]
22pub struct SyncReport {
23    pub applied: ApplyResult,
24    pub pruned: Vec<apply::ActionOutcome>,
25    pub warnings: Vec<ValidationWarning>,
26    /// Whether this was a dry run (`--diff`). Affects output wording only.
27    pub dry_run: bool,
28}
29
30impl SyncReport {
31    /// Whether the sync produced any unresolved conflicts.
32    pub fn has_conflicts(&self) -> bool {
33        self.applied
34            .outcomes
35            .iter()
36            .any(|o| matches!(o.action, apply::ActionTaken::Conflicted))
37    }
38}
39
40/// What a CLI command requests from the sync pipeline.
41#[derive(Debug, Clone)]
42pub struct SyncRequest {
43    /// How to resolve versions.
44    pub resolution: ResolutionMode,
45    /// Config mutation to apply under flock.
46    pub mutation: Option<ConfigMutation>,
47    /// Behavior flags.
48    pub options: SyncOptions,
49}
50
51/// Resolution behavior for the resolver stage.
52#[derive(Debug, Clone)]
53pub enum ResolutionMode {
54    /// Normal sync behavior.
55    Normal,
56    /// Upgrade behavior (maximize versions), optionally scoped to specific sources.
57    Maximize { targets: HashSet<SourceName> },
58}
59
60/// Config mutation to apply atomically under flock.
61#[derive(Debug, Clone)]
62pub enum ConfigMutation {
63    /// Add or update a source in mars.toml.
64    UpsertSource {
65        name: SourceName,
66        entry: SourceEntry,
67    },
68    /// Remove a source from mars.toml.
69    RemoveSource { name: SourceName },
70    /// Add or update an override in mars.local.toml.
71    SetOverride {
72        source_name: SourceName,
73        local_path: PathBuf,
74    },
75    /// Remove an override from mars.local.toml.
76    ClearOverride { source_name: SourceName },
77    /// Set or update a rename mapping for one managed item.
78    SetRename {
79        source_name: SourceName,
80        from: String,
81        to: String,
82    },
83    /// Add a link target to settings.links (idempotent).
84    SetLink { target: String },
85    /// Remove a link target from settings.links.
86    ClearLink { target: String },
87}
88
89/// Link-specific config mutations. Separate type from ConfigMutation
90/// to enforce that only link operations use the lightweight (no-sync) mutation path.
91#[derive(Debug, Clone)]
92pub enum LinkMutation {
93    /// Add a link target to settings.links (idempotent).
94    Set { target: String },
95    /// Remove a link target from settings.links.
96    Clear { target: String },
97}
98
99/// Apply a link mutation under sync lock, without running the full sync pipeline.
100/// Only for settings.links changes — use sync::execute for source mutations.
101pub fn mutate_link_config(root: &Path, mutation: &LinkMutation) -> Result<(), MarsError> {
102    let lock_path = root.join(".mars").join("sync.lock");
103    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
104
105    let mut config = crate::config::load(root)?;
106    match mutation {
107        LinkMutation::Set { target } => {
108            if !config.settings.links.contains(target) {
109                config.settings.links.push(target.clone());
110            }
111        }
112        LinkMutation::Clear { target } => {
113            config.settings.links.retain(|l| l != target);
114        }
115    }
116    crate::config::save(root, &config)?;
117
118    Ok(())
119}
120
121/// Execute the unified sync pipeline.
122pub fn execute(root: &Path, request: &SyncRequest) -> Result<SyncReport, MarsError> {
123    validate_request(request)?;
124
125    std::fs::create_dir_all(root.join(".mars").join("cache"))?;
126
127    // Step 1: Acquire sync lock before any config reads/mutations.
128    let lock_path = root.join(".mars").join("sync.lock");
129    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
130
131    // Step 2: Load config under lock (auto-init when mutating and missing).
132    let mut config = match crate::config::load(root) {
133        Ok(config) => config,
134        Err(err) if is_config_not_found(&err) && request.mutation.is_some() => Config {
135            sources: IndexMap::new(),
136            settings: Settings::default(),
137        },
138        Err(err) => return Err(err),
139    };
140
141    // Step 3: Apply config mutation.
142    let has_mutation = request.mutation.is_some();
143    if let Some(mutation) = &request.mutation {
144        apply_mutation(&mut config, mutation)?;
145    }
146
147    // Step 4: Load/mutate local overrides under the same lock.
148    let mut local = crate::config::load_local(root)?;
149    if let Some(mutation) = &request.mutation {
150        apply_local_mutation(&mut local, mutation);
151    }
152
153    // Step 4b: Build effective config.
154    let effective = crate::config::merge_with_root(config.clone(), local.clone(), root)?;
155
156    // Step 5: Validate upgrade targets exist.
157    validate_targets(&request.resolution, &effective)?;
158
159    // Step 6: Load existing lock file.
160    let old_lock = crate::lock::load(root)?;
161
162    // Step 7: Resolve dependency graph.
163    let cache = GlobalCache::new()?;
164    let project_root = root.parent().unwrap_or(root);
165    let provider = RealSourceProvider {
166        cache: &cache,
167        project_root,
168    };
169    let resolve_options = to_resolve_options(&request.resolution, request.options.frozen);
170    let graph = crate::resolve::resolve(&effective, &provider, Some(&old_lock), &resolve_options)?;
171
172    // Step 8: Build target state.
173    let (mut target_state, renames) = target::build_with_collisions(&graph, &effective)?;
174
175    // Step 9: Handle collisions + rewrite frontmatter refs.
176    if !renames.is_empty() {
177        let rewrite_warnings = target::rewrite_skill_refs(&mut target_state, &renames, &graph)?;
178        for w in &rewrite_warnings {
179            eprintln!("{w}");
180        }
181    }
182
183    // Step 10: Validate skill references.
184    let warnings = validate_skill_refs(root, &target_state);
185
186    // Step 11: Prevent managed installs from overwriting unmanaged files.
187    target::check_unmanaged_collisions(root, &old_lock, &target_state)?;
188
189    // Step 12: Compute diff.
190    let sync_diff = diff::compute(root, &old_lock, &target_state, request.options.force)?;
191
192    // Step 13: Create plan.
193    let cache_bases_dir = root.join(".mars").join("cache").join("bases");
194    let sync_plan = plan::create(&sync_diff, &request.options, &cache_bases_dir);
195
196    // Step 14: Frozen gate.
197    if request.options.frozen {
198        let has_changes = sync_plan.actions.iter().any(|a| {
199            !matches!(
200                a,
201                plan::PlannedAction::Skip { .. } | plan::PlannedAction::KeepLocal { .. }
202            )
203        });
204        if has_changes {
205            return Err(MarsError::FrozenViolation {
206                message: "lock file would change but --frozen is set".into(),
207            });
208        }
209    }
210
211    // Step 15: Persist config/local only after validation gate and before apply.
212    if has_mutation && !request.options.dry_run {
213        match request.mutation {
214            Some(ConfigMutation::SetOverride { .. } | ConfigMutation::ClearOverride { .. }) => {
215                crate::config::save_local(root, &local)?;
216            }
217            Some(
218                ConfigMutation::UpsertSource { .. }
219                | ConfigMutation::RemoveSource { .. }
220                | ConfigMutation::SetRename { .. }
221                | ConfigMutation::SetLink { .. }
222                | ConfigMutation::ClearLink { .. },
223            ) => {
224                crate::config::save(root, &config)?;
225            }
226            None => {}
227        }
228    }
229
230    // Step 16: Apply plan.
231    let applied = apply::execute(root, &sync_plan, &request.options, &cache_bases_dir)?;
232    let pruned = Vec::new();
233
234    // Step 17: Write lock file.
235    if !request.options.dry_run {
236        let new_lock = crate::lock::build(&graph, &applied, &old_lock)?;
237        crate::lock::write(root, &new_lock)?;
238    }
239
240    Ok(SyncReport {
241        applied,
242        pruned,
243        warnings,
244        dry_run: request.options.dry_run,
245    })
246}
247
248fn validate_request(request: &SyncRequest) -> Result<(), MarsError> {
249    if request.options.frozen && matches!(request.resolution, ResolutionMode::Maximize { .. }) {
250        return Err(MarsError::InvalidRequest {
251            message:
252                "cannot use --frozen with upgrade (frozen locks versions; upgrade maximizes them)"
253                    .to_string(),
254        });
255    }
256
257    if request.options.frozen && request.mutation.is_some() {
258        return Err(MarsError::InvalidRequest {
259            message:
260                "cannot modify config in --frozen mode (config change would require lock update)"
261                    .to_string(),
262        });
263    }
264
265    Ok(())
266}
267
268fn is_config_not_found(error: &MarsError) -> bool {
269    matches!(error, MarsError::Config(ConfigError::NotFound { .. }))
270}
271
272fn apply_mutation(config: &mut Config, mutation: &ConfigMutation) -> Result<(), MarsError> {
273    match mutation {
274        ConfigMutation::UpsertSource { name, entry } => {
275            if let Some(existing) = config.sources.get_mut(name) {
276                // Merge: update source location fields, preserve user customizations
277                existing.url = entry.url.clone();
278                existing.path = entry.path.clone();
279                existing.version = entry.version.clone();
280                // Only overwrite filters if the new entry explicitly sets them
281                if entry.filter.agents.is_some() {
282                    existing.filter.agents = entry.filter.agents.clone();
283                }
284                if entry.filter.skills.is_some() {
285                    existing.filter.skills = entry.filter.skills.clone();
286                }
287                if entry.filter.exclude.is_some() {
288                    existing.filter.exclude = entry.filter.exclude.clone();
289                }
290                // Never overwrite rename rules from add — those are set via `mars rename`
291                // entry.filter.rename is always None from the add command
292            } else {
293                config.sources.insert(name.clone(), entry.clone());
294            }
295            Ok(())
296        }
297        ConfigMutation::RemoveSource { name } => {
298            if !config.sources.contains_key(name) {
299                return Err(MarsError::Source {
300                    source_name: name.to_string(),
301                    message: format!("source `{name}` not found in mars.toml"),
302                });
303            }
304            config.sources.shift_remove(name);
305            Ok(())
306        }
307        ConfigMutation::SetOverride { source_name, .. } => {
308            if !config.sources.contains_key(source_name) {
309                return Err(MarsError::Source {
310                    source_name: source_name.to_string(),
311                    message: format!("source `{source_name}` not found in mars.toml"),
312                });
313            }
314            Ok(())
315        }
316        ConfigMutation::SetRename {
317            source_name,
318            from,
319            to,
320        } => {
321            let source = config
322                .sources
323                .get_mut(source_name)
324                .ok_or_else(|| MarsError::Source {
325                    source_name: source_name.to_string(),
326                    message: format!("source `{source_name}` not found in mars.toml"),
327                })?;
328            let rename_map = source
329                .filter
330                .rename
331                .get_or_insert_with(crate::types::RenameMap::new);
332            rename_map.insert(ItemName::from(from.as_str()), ItemName::from(to.as_str()));
333            Ok(())
334        }
335        ConfigMutation::ClearOverride { .. } => Ok(()),
336        ConfigMutation::SetLink { target } => {
337            if !config.settings.links.contains(target) {
338                config.settings.links.push(target.clone());
339            }
340            Ok(())
341        }
342        ConfigMutation::ClearLink { target } => {
343            config.settings.links.retain(|l| l != target);
344            Ok(())
345        }
346    }
347}
348
349fn apply_local_mutation(local: &mut LocalConfig, mutation: &ConfigMutation) {
350    match mutation {
351        ConfigMutation::SetOverride {
352            source_name,
353            local_path,
354        } => {
355            local.overrides.insert(
356                source_name.clone(),
357                OverrideEntry {
358                    path: local_path.clone(),
359                },
360            );
361        }
362        ConfigMutation::ClearOverride { source_name } => {
363            local.overrides.shift_remove(source_name);
364        }
365        ConfigMutation::UpsertSource { .. }
366        | ConfigMutation::RemoveSource { .. }
367        | ConfigMutation::SetRename { .. }
368        | ConfigMutation::SetLink { .. }
369        | ConfigMutation::ClearLink { .. } => {}
370    }
371}
372
373fn validate_targets(
374    resolution: &ResolutionMode,
375    effective: &EffectiveConfig,
376) -> Result<(), MarsError> {
377    if let ResolutionMode::Maximize { targets } = resolution {
378        for name in targets {
379            if !effective.sources.contains_key(name) {
380                return Err(MarsError::Source {
381                    source_name: name.to_string(),
382                    message: format!("source `{name}` not found in mars.toml"),
383                });
384            }
385        }
386    }
387
388    Ok(())
389}
390
391fn to_resolve_options(mode: &ResolutionMode, frozen: bool) -> ResolveOptions {
392    match mode {
393        ResolutionMode::Normal => ResolveOptions {
394            frozen,
395            ..ResolveOptions::default()
396        },
397        ResolutionMode::Maximize { targets } => ResolveOptions {
398            maximize: true,
399            upgrade_targets: targets.clone(),
400            frozen,
401        },
402    }
403}
404
405/// Real source provider that delegates to the source module.
406///
407/// Implements the SourceProvider trait so the resolver can fetch sources
408/// and read manifests through a uniform interface.
409struct RealSourceProvider<'a> {
410    cache: &'a GlobalCache,
411    project_root: &'a Path,
412}
413
414impl VersionLister for RealSourceProvider<'_> {
415    fn list_versions(
416        &self,
417        url: &crate::types::SourceUrl,
418    ) -> Result<Vec<AvailableVersion>, MarsError> {
419        source::list_versions(url, self.cache)
420    }
421}
422
423impl SourceFetcher for RealSourceProvider<'_> {
424    fn fetch_git_version(
425        &self,
426        url: &crate::types::SourceUrl,
427        version: &AvailableVersion,
428        source_name: &str,
429        preferred_commit: Option<&str>,
430    ) -> Result<ResolvedRef, MarsError> {
431        let fetch_options = source::git::FetchOptions {
432            preferred_commit: preferred_commit.map(CommitHash::from),
433        };
434        source::git::fetch(
435            url.as_ref(),
436            Some(&version.tag),
437            source_name,
438            self.cache,
439            &fetch_options,
440        )
441    }
442
443    fn fetch_git_ref(
444        &self,
445        url: &crate::types::SourceUrl,
446        ref_name: &str,
447        source_name: &str,
448        preferred_commit: Option<&str>,
449    ) -> Result<ResolvedRef, MarsError> {
450        let fetch_options = source::git::FetchOptions {
451            preferred_commit: preferred_commit.map(CommitHash::from),
452        };
453        source::git::fetch(
454            url.as_ref(),
455            Some(ref_name),
456            source_name,
457            self.cache,
458            &fetch_options,
459        )
460    }
461
462    fn fetch_path(&self, path: &Path, source_name: &str) -> Result<ResolvedRef, MarsError> {
463        source::path::fetch_path(path, self.project_root, source_name)
464    }
465}
466
467impl ManifestReader for RealSourceProvider<'_> {
468    fn read_manifest(
469        &self,
470        source_tree: &Path,
471    ) -> Result<Option<crate::manifest::Manifest>, MarsError> {
472        crate::manifest::load(source_tree)
473    }
474}
475
476/// Validate skill references: check that agents' `skills:` frontmatter entries
477/// reference skills that exist in the target state.
478fn validate_skill_refs(
479    install_target: &Path,
480    target: &target::TargetState,
481) -> Vec<ValidationWarning> {
482    use crate::lock::ItemKind;
483
484    // Collect available skill names
485    let available_skills: HashSet<String> = target
486        .items
487        .values()
488        .filter(|item| item.id.kind == ItemKind::Skill)
489        .map(|item| item.id.name.to_string())
490        .collect();
491
492    // Collect agents with their paths
493    let agents: Vec<(String, PathBuf)> = target
494        .items
495        .values()
496        .filter(|item| item.id.kind == ItemKind::Agent)
497        .map(|item| {
498            let disk_path = install_target.join(&item.dest_path);
499            // If the file exists on disk, use that (may have local edits).
500            // Otherwise, use the source path.
501            let path = if disk_path.exists() {
502                disk_path
503            } else {
504                item.source_path.clone()
505            };
506            (item.id.name.to_string(), path)
507        })
508        .collect();
509
510    crate::validate::check_deps(&agents, &available_skills).unwrap_or_default()
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::config::*;
517    use crate::lock::{ItemKind, LockFile};
518    use crate::resolve::{ResolvedGraph, ResolvedNode};
519    use crate::source::ResolvedRef;
520    use indexmap::IndexMap;
521    use std::fs;
522    use tempfile::TempDir;
523
524    /// Helper to set up a complete sync context with temp dirs.
525    struct TestFixture {
526        root: TempDir,
527        source_trees: Vec<TempDir>,
528    }
529
530    impl TestFixture {
531        fn new() -> Self {
532            let root = TempDir::new().unwrap();
533            // Create .mars/cache directories
534            fs::create_dir_all(root.path().join(".mars/cache/bases")).unwrap();
535            TestFixture {
536                root,
537                source_trees: Vec::new(),
538            }
539        }
540
541        fn add_source(&mut self, agents: &[(&str, &str)], skills: &[(&str, &str)]) -> usize {
542            let dir = TempDir::new().unwrap();
543            if !agents.is_empty() {
544                let agents_dir = dir.path().join("agents");
545                fs::create_dir_all(&agents_dir).unwrap();
546                for (name, content) in agents {
547                    fs::write(agents_dir.join(name), content).unwrap();
548                }
549            }
550            if !skills.is_empty() {
551                let skills_dir = dir.path().join("skills");
552                fs::create_dir_all(&skills_dir).unwrap();
553                for (name, content) in skills {
554                    let skill_dir = skills_dir.join(name);
555                    fs::create_dir_all(&skill_dir).unwrap();
556                    fs::write(skill_dir.join("SKILL.md"), content).unwrap();
557                }
558            }
559            self.source_trees.push(dir);
560            self.source_trees.len() - 1
561        }
562
563        fn root(&self) -> &Path {
564            self.root.path()
565        }
566
567        fn tree_path(&self, idx: usize) -> PathBuf {
568            self.source_trees[idx].path().to_path_buf()
569        }
570    }
571
572    fn make_graph_config(
573        fixture: &TestFixture,
574        sources: Vec<(&str, usize, FilterMode)>,
575    ) -> (ResolvedGraph, EffectiveConfig) {
576        let mut nodes = IndexMap::new();
577        let mut order = Vec::new();
578        let mut config_sources = IndexMap::new();
579
580        for (name, tree_idx, filter) in sources {
581            let tree_path = fixture.tree_path(tree_idx);
582            nodes.insert(
583                name.into(),
584                ResolvedNode {
585                    source_name: name.into(),
586                    source_id: crate::types::SourceId::Path {
587                        canonical: tree_path.clone(),
588                    },
589                    resolved_ref: ResolvedRef {
590                        source_name: name.into(),
591                        version: None,
592                        version_tag: None,
593                        commit: None,
594                        tree_path: tree_path.clone(),
595                    },
596                    manifest: None,
597                    deps: vec![],
598                },
599            );
600            order.push(name.into());
601
602            config_sources.insert(
603                name.into(),
604                EffectiveSource {
605                    name: name.into(),
606                    id: crate::types::SourceId::Path {
607                        canonical: tree_path.clone(),
608                    },
609                    spec: SourceSpec::Path(tree_path),
610                    filter,
611                    rename: crate::types::RenameMap::new(),
612                    is_overridden: false,
613                    original_git: None,
614                },
615            );
616        }
617
618        (
619            ResolvedGraph {
620                nodes,
621                order,
622                id_index: std::collections::HashMap::new(),
623            },
624            EffectiveConfig {
625                sources: config_sources,
626                settings: Settings::default(),
627            },
628        )
629    }
630
631    fn path_source_entry(path: &Path) -> SourceEntry {
632        SourceEntry {
633            url: None,
634            path: Some(path.to_path_buf()),
635            version: None,
636            filter: FilterConfig::default(),
637        }
638    }
639
640    #[test]
641    fn validate_request_rejects_frozen_with_maximize() {
642        let request = SyncRequest {
643            resolution: ResolutionMode::Maximize {
644                targets: HashSet::new(),
645            },
646            mutation: None,
647            options: SyncOptions {
648                force: false,
649                dry_run: false,
650                frozen: true,
651            },
652        };
653
654        let err = validate_request(&request).unwrap_err();
655        assert!(matches!(err, MarsError::InvalidRequest { .. }));
656        assert!(err.to_string().contains("--frozen"));
657    }
658
659    #[test]
660    fn validate_request_rejects_frozen_with_mutation() {
661        let request = SyncRequest {
662            resolution: ResolutionMode::Normal,
663            mutation: Some(ConfigMutation::RemoveSource {
664                name: "base".into(),
665            }),
666            options: SyncOptions {
667                force: false,
668                dry_run: false,
669                frozen: true,
670            },
671        };
672
673        let err = validate_request(&request).unwrap_err();
674        assert!(matches!(err, MarsError::InvalidRequest { .. }));
675        assert!(err.to_string().contains("cannot modify config"));
676    }
677
678    #[test]
679    fn execute_auto_inits_config_for_mutation() {
680        let root = TempDir::new().unwrap();
681        let source = TempDir::new().unwrap();
682        fs::create_dir_all(source.path().join("agents")).unwrap();
683        fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
684
685        let request = SyncRequest {
686            resolution: ResolutionMode::Normal,
687            mutation: Some(ConfigMutation::UpsertSource {
688                name: "base".into(),
689                entry: path_source_entry(source.path()),
690            }),
691            options: SyncOptions::default(),
692        };
693
694        let report = execute(root.path(), &request).unwrap();
695        assert!(!report.applied.outcomes.is_empty());
696        assert!(root.path().join("mars.toml").exists());
697
698        let saved = crate::config::load(root.path()).unwrap();
699        assert!(saved.sources.contains_key("base"));
700    }
701
702    #[test]
703    fn execute_dry_run_with_mutation_does_not_write_config() {
704        let root = TempDir::new().unwrap();
705        crate::config::save(
706            root.path(),
707            &Config {
708                sources: IndexMap::new(),
709                settings: Settings::default(),
710            },
711        )
712        .unwrap();
713
714        let source = TempDir::new().unwrap();
715        fs::create_dir_all(source.path().join("agents")).unwrap();
716        fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
717
718        let request = SyncRequest {
719            resolution: ResolutionMode::Normal,
720            mutation: Some(ConfigMutation::UpsertSource {
721                name: "base".into(),
722                entry: path_source_entry(source.path()),
723            }),
724            options: SyncOptions {
725                force: false,
726                dry_run: true,
727                frozen: false,
728            },
729        };
730
731        let report = execute(root.path(), &request).unwrap();
732        assert!(!report.applied.outcomes.is_empty());
733
734        let saved = crate::config::load(root.path()).unwrap();
735        assert!(!saved.sources.contains_key("base"));
736        assert!(!root.path().join("agents/coder.md").exists());
737        assert!(!root.path().join("mars.lock").exists());
738    }
739
740    // === Integration tests for the pipeline stages ===
741
742    #[test]
743    fn full_pipeline_fresh_sync() {
744        let mut fixture = TestFixture::new();
745        let src_idx = fixture.add_source(
746            &[("coder.md", "# Coder agent")],
747            &[("planning", "# Planning skill")],
748        );
749
750        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
751
752        // Build target
753        let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
754        assert!(renames.is_empty());
755        assert_eq!(target.items.len(), 2);
756
757        // Compute diff against empty lock
758        let lock = LockFile::empty();
759        let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
760
761        // All items should be Add
762        assert_eq!(sync_diff.items.len(), 2);
763        for entry in &sync_diff.items {
764            assert!(matches!(entry, diff::DiffEntry::Add { .. }));
765        }
766
767        // Create plan
768        let cache_dir = fixture.root().join(".mars/cache/bases");
769        let options = SyncOptions {
770            force: false,
771            dry_run: false,
772            frozen: false,
773        };
774        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
775        assert_eq!(sync_plan.actions.len(), 2);
776        for action in &sync_plan.actions {
777            assert!(matches!(action, plan::PlannedAction::Install { .. }));
778        }
779
780        // Execute plan
781        let result = apply::execute(fixture.root(), &sync_plan, &options, &cache_dir).unwrap();
782        assert_eq!(result.outcomes.len(), 2);
783
784        // Verify files were created
785        assert!(fixture.root().join("agents/coder.md").exists());
786        assert!(fixture.root().join("skills/planning/SKILL.md").exists());
787
788        // Build lock
789        let new_lock = crate::lock::build(&graph, &result, &lock).unwrap();
790        assert_eq!(new_lock.items.len(), 2);
791        assert!(new_lock.items.contains_key("agents/coder.md"));
792        assert!(new_lock.items.contains_key("skills/planning"));
793    }
794
795    #[test]
796    fn re_sync_no_changes() {
797        let mut fixture = TestFixture::new();
798        let content = "# Coder agent";
799        let src_idx = fixture.add_source(&[("coder.md", content)], &[]);
800
801        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
802
803        // First sync
804        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
805        let lock = LockFile::empty();
806        let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
807        let cache_dir = fixture.root().join(".mars/cache/bases");
808        let options = SyncOptions {
809            force: false,
810            dry_run: false,
811            frozen: false,
812        };
813        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
814        let result = apply::execute(fixture.root(), &sync_plan, &options, &cache_dir).unwrap();
815        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
816
817        // Second sync with same content
818        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
819        let sync_diff2 = diff::compute(fixture.root(), &first_lock, &target2, false).unwrap();
820
821        // All items should be Unchanged
822        for entry in &sync_diff2.items {
823            assert!(
824                matches!(entry, diff::DiffEntry::Unchanged { .. }),
825                "expected Unchanged, got {entry:?}"
826            );
827        }
828
829        let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
830        for action in &sync_plan2.actions {
831            assert!(matches!(action, plan::PlannedAction::Skip { .. }));
832        }
833    }
834
835    #[test]
836    fn source_update_detects_changes() {
837        let mut fixture = TestFixture::new();
838        let src_idx = fixture.add_source(&[("coder.md", "# Version 1")], &[]);
839
840        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
841
842        // First sync
843        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
844        let lock = LockFile::empty();
845        let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
846        let cache_dir = fixture.root().join(".mars/cache/bases");
847        let options = SyncOptions {
848            force: false,
849            dry_run: false,
850            frozen: false,
851        };
852        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
853        let result = apply::execute(fixture.root(), &sync_plan, &options, &cache_dir).unwrap();
854        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
855
856        // Update source content
857        let agents_dir = fixture.tree_path(src_idx).join("agents");
858        fs::write(agents_dir.join("coder.md"), "# Version 2").unwrap();
859
860        // Rebuild target with updated content
861        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
862        let sync_diff2 = diff::compute(fixture.root(), &first_lock, &target2, false).unwrap();
863
864        // Should detect an Update
865        assert_eq!(sync_diff2.items.len(), 1);
866        assert!(matches!(
867            &sync_diff2.items[0],
868            diff::DiffEntry::Update { .. }
869        ));
870    }
871
872    #[test]
873    fn local_modification_preserved() {
874        let mut fixture = TestFixture::new();
875        let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
876
877        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
878
879        // First sync
880        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
881        let lock = LockFile::empty();
882        let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
883        let cache_dir = fixture.root().join(".mars/cache/bases");
884        let options = SyncOptions {
885            force: false,
886            dry_run: false,
887            frozen: false,
888        };
889        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
890        let result = apply::execute(fixture.root(), &sync_plan, &options, &cache_dir).unwrap();
891        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
892
893        // Locally modify the installed file
894        fs::write(fixture.root().join("agents/coder.md"), "# Locally modified").unwrap();
895
896        // Re-sync (source unchanged)
897        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
898        let sync_diff2 = diff::compute(fixture.root(), &first_lock, &target2, false).unwrap();
899
900        // Should detect LocalModified
901        assert_eq!(sync_diff2.items.len(), 1);
902        assert!(matches!(
903            &sync_diff2.items[0],
904            diff::DiffEntry::LocalModified { .. }
905        ));
906
907        // Plan should KeepLocal
908        let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
909        assert!(matches!(
910            &sync_plan2.actions[0],
911            plan::PlannedAction::KeepLocal { .. }
912        ));
913    }
914
915    #[test]
916    fn force_overwrites_local_modifications() {
917        let mut fixture = TestFixture::new();
918        let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
919
920        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
921
922        // First sync
923        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
924        let lock = LockFile::empty();
925        let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
926        let cache_dir = fixture.root().join(".mars/cache/bases");
927        let options = SyncOptions {
928            force: false,
929            dry_run: false,
930            frozen: false,
931        };
932        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
933        let result = apply::execute(fixture.root(), &sync_plan, &options, &cache_dir).unwrap();
934        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
935
936        // Locally modify the installed file
937        fs::write(fixture.root().join("agents/coder.md"), "# Locally modified").unwrap();
938
939        // Update source too (triggers conflict)
940        let agents_dir = fixture.tree_path(src_idx).join("agents");
941        fs::write(agents_dir.join("coder.md"), "# Upstream update").unwrap();
942
943        // Re-sync with --force
944        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
945        let sync_diff2 = diff::compute(fixture.root(), &first_lock, &target2, false).unwrap();
946
947        let force_options = SyncOptions {
948            force: true,
949            dry_run: false,
950            frozen: false,
951        };
952        let sync_plan2 = plan::create(&sync_diff2, &force_options, &cache_dir);
953        assert!(matches!(
954            &sync_plan2.actions[0],
955            plan::PlannedAction::Overwrite { .. }
956        ));
957
958        let result2 =
959            apply::execute(fixture.root(), &sync_plan2, &force_options, &cache_dir).unwrap();
960        assert!(matches!(
961            result2.outcomes[0].action,
962            apply::ActionTaken::Updated
963        ));
964
965        // File should have upstream content
966        let content = fs::read_to_string(fixture.root().join("agents/coder.md")).unwrap();
967        assert_eq!(content, "# Upstream update");
968    }
969
970    #[test]
971    fn orphan_removed_when_source_drops_item() {
972        let mut fixture = TestFixture::new();
973        let src_idx = fixture.add_source(
974            &[("coder.md", "# Coder"), ("reviewer.md", "# Reviewer")],
975            &[],
976        );
977
978        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
979
980        // First sync — install both
981        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
982        let lock = LockFile::empty();
983        let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
984        let cache_dir = fixture.root().join(".mars/cache/bases");
985        let options = SyncOptions {
986            force: false,
987            dry_run: false,
988            frozen: false,
989        };
990        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
991        let result = apply::execute(fixture.root(), &sync_plan, &options, &cache_dir).unwrap();
992        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
993
994        assert!(fixture.root().join("agents/coder.md").exists());
995        assert!(fixture.root().join("agents/reviewer.md").exists());
996
997        // Remove reviewer from source
998        fs::remove_file(fixture.tree_path(src_idx).join("agents/reviewer.md")).unwrap();
999
1000        // Re-sync
1001        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1002        let sync_diff2 = diff::compute(fixture.root(), &first_lock, &target2, false).unwrap();
1003
1004        // Should have one Unchanged and one Orphan
1005        let orphan_count = sync_diff2
1006            .items
1007            .iter()
1008            .filter(|e| matches!(e, diff::DiffEntry::Orphan { .. }))
1009            .count();
1010        assert_eq!(orphan_count, 1);
1011
1012        let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1013        let result2 = apply::execute(fixture.root(), &sync_plan2, &options, &cache_dir).unwrap();
1014
1015        // Reviewer should be removed
1016        assert!(!fixture.root().join("agents/reviewer.md").exists());
1017        // Coder should still be there
1018        assert!(fixture.root().join("agents/coder.md").exists());
1019
1020        // Check remove outcome
1021        let removed = result2
1022            .outcomes
1023            .iter()
1024            .any(|o| matches!(o.action, apply::ActionTaken::Removed));
1025        assert!(removed);
1026    }
1027
1028    #[test]
1029    fn dry_run_produces_plan_without_changes() {
1030        let mut fixture = TestFixture::new();
1031        let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1032
1033        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1034
1035        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1036        let lock = LockFile::empty();
1037        let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
1038
1039        let cache_dir = fixture.root().join(".mars/cache/bases");
1040        let dry_options = SyncOptions {
1041            force: false,
1042            dry_run: true,
1043            frozen: false,
1044        };
1045
1046        let sync_plan = plan::create(&sync_diff, &dry_options, &cache_dir);
1047        assert!(!sync_plan.actions.is_empty());
1048
1049        // Execute in dry-run mode
1050        let result = apply::execute(fixture.root(), &sync_plan, &dry_options, &cache_dir).unwrap();
1051        assert!(!result.outcomes.is_empty());
1052
1053        // No files should have been created
1054        assert!(!fixture.root().join("agents/coder.md").exists());
1055    }
1056
1057    #[test]
1058    fn lock_written_after_apply() {
1059        let mut fixture = TestFixture::new();
1060        let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1061
1062        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1063
1064        // Full pipeline minus actual sync() (which needs real config files)
1065        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1066        let lock = LockFile::empty();
1067        let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
1068        let cache_dir = fixture.root().join(".mars/cache/bases");
1069        let options = SyncOptions {
1070            force: false,
1071            dry_run: false,
1072            frozen: false,
1073        };
1074        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1075        let result = apply::execute(fixture.root(), &sync_plan, &options, &cache_dir).unwrap();
1076
1077        let new_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1078        crate::lock::write(fixture.root(), &new_lock).unwrap();
1079
1080        // Verify lock file exists and is valid
1081        let reloaded = crate::lock::load(fixture.root()).unwrap();
1082        assert_eq!(reloaded.items.len(), 1);
1083        assert!(reloaded.items.contains_key("agents/coder.md"));
1084
1085        let item = &reloaded.items["agents/coder.md"];
1086        assert_eq!(item.kind, ItemKind::Agent);
1087        assert!(!item.source_checksum.is_empty());
1088        assert!(!item.installed_checksum.is_empty());
1089    }
1090
1091    #[test]
1092    fn two_sources_no_collision() {
1093        let mut fixture = TestFixture::new();
1094        let src_a = fixture.add_source(&[("coder.md", "# Coder from A")], &[]);
1095        let src_b = fixture.add_source(&[("reviewer.md", "# Reviewer from B")], &[]);
1096
1097        let (graph, config) = make_graph_config(
1098            &fixture,
1099            vec![
1100                ("source-a", src_a, FilterMode::All),
1101                ("source-b", src_b, FilterMode::All),
1102            ],
1103        );
1104
1105        let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1106        assert!(renames.is_empty());
1107        assert_eq!(target.items.len(), 2);
1108
1109        let lock = LockFile::empty();
1110        let sync_diff = diff::compute(fixture.root(), &lock, &target, false).unwrap();
1111        let cache_dir = fixture.root().join(".mars/cache/bases");
1112        let options = SyncOptions {
1113            force: false,
1114            dry_run: false,
1115            frozen: false,
1116        };
1117        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1118        let result = apply::execute(fixture.root(), &sync_plan, &options, &cache_dir).unwrap();
1119
1120        assert!(fixture.root().join("agents/coder.md").exists());
1121        assert!(fixture.root().join("agents/reviewer.md").exists());
1122        assert_eq!(result.outcomes.len(), 2);
1123    }
1124}