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 crate::config::{
10    Config, DependencyEntry, EffectiveConfig, FilterConfig, LocalConfig, Manifest, OverrideEntry,
11    Settings,
12};
13use crate::error::{ConfigError, MarsError};
14use crate::hash;
15use crate::resolve::{ManifestReader, ResolveOptions, SourceFetcher, VersionLister};
16use crate::source::{self, AvailableVersion, GlobalCache, ResolvedRef};
17use crate::sync::apply::ApplyResult;
18pub use crate::sync::apply::SyncOptions;
19use crate::types::{CommitHash, ContentHash, ItemName, MarsContext, SourceName};
20use crate::validate::ValidationWarning;
21
22/// Report from a completed sync operation.
23#[derive(Debug)]
24pub struct SyncReport {
25    pub applied: ApplyResult,
26    pub pruned: Vec<apply::ActionOutcome>,
27    pub warnings: Vec<ValidationWarning>,
28    pub dependency_changes: Vec<DependencyUpsertChange>,
29    /// Whether this was a dry run (`--diff`). Affects output wording only.
30    pub dry_run: bool,
31}
32
33impl SyncReport {
34    /// Whether the sync produced any unresolved conflicts.
35    pub fn has_conflicts(&self) -> bool {
36        self.applied
37            .outcomes
38            .iter()
39            .any(|o| matches!(o.action, apply::ActionTaken::Conflicted))
40    }
41}
42
43/// What a CLI command requests from the sync pipeline.
44#[derive(Debug, Clone)]
45pub struct SyncRequest {
46    /// How to resolve versions.
47    pub resolution: ResolutionMode,
48    /// Config mutation to apply under flock.
49    pub mutation: Option<ConfigMutation>,
50    /// Behavior flags.
51    pub options: SyncOptions,
52}
53
54/// Resolution behavior for the resolver stage.
55#[derive(Debug, Clone)]
56pub enum ResolutionMode {
57    /// Normal sync behavior.
58    Normal,
59    /// Upgrade behavior (maximize versions), optionally scoped to specific sources.
60    Maximize { targets: HashSet<SourceName> },
61}
62
63/// Config mutation to apply atomically under flock.
64#[derive(Debug, Clone)]
65pub enum ConfigMutation {
66    /// Add or update a dependency in mars.toml.
67    UpsertDependency {
68        name: SourceName,
69        entry: DependencyEntry,
70    },
71    /// Add or update multiple dependencies in mars.toml atomically under one sync lock.
72    BatchUpsert(Vec<(SourceName, DependencyEntry)>),
73    /// Remove a dependency from mars.toml.
74    RemoveDependency { name: SourceName },
75    /// Add or update an override in mars.local.toml.
76    SetOverride {
77        source_name: SourceName,
78        local_path: PathBuf,
79    },
80    /// Remove an override from mars.local.toml.
81    ClearOverride { source_name: SourceName },
82    /// Set or update a rename mapping for one managed item.
83    SetRename {
84        source_name: SourceName,
85        from: String,
86        to: String,
87    },
88}
89
90/// Metadata captured when `UpsertDependency` mutates an existing/new dependency.
91#[derive(Debug, Clone)]
92pub struct DependencyUpsertChange {
93    pub name: SourceName,
94    pub already_exists: bool,
95    pub old_filter: Option<FilterConfig>,
96    pub new_filter: FilterConfig,
97}
98
99/// Link-specific config mutations. Separate type from ConfigMutation
100/// to enforce that only link operations use the lightweight (no-sync) mutation path.
101#[derive(Debug, Clone)]
102pub enum LinkMutation {
103    /// Add a link target to settings.links (idempotent).
104    Set { target: String },
105    /// Remove a link target from settings.links.
106    Clear { target: String },
107}
108
109/// Apply a link mutation under sync lock, without running the full sync pipeline.
110/// Only for settings.links changes — use sync::execute for source mutations.
111pub fn mutate_link_config(ctx: &MarsContext, mutation: &LinkMutation) -> Result<(), MarsError> {
112    let mars_dir = ctx.project_root.join(".mars");
113    std::fs::create_dir_all(&mars_dir)?;
114    let lock_path = mars_dir.join("sync.lock");
115    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
116
117    let mut config = crate::config::load(&ctx.project_root)?;
118    match mutation {
119        LinkMutation::Set { target } => {
120            if !config.settings.links.contains(target) {
121                config.settings.links.push(target.clone());
122            }
123        }
124        LinkMutation::Clear { target } => {
125            config.settings.links.retain(|l| l != target);
126        }
127    }
128    crate::config::save(&ctx.project_root, &config)?;
129
130    Ok(())
131}
132
133/// Execute the unified sync pipeline.
134pub fn execute(ctx: &MarsContext, request: &SyncRequest) -> Result<SyncReport, MarsError> {
135    let project_root = &ctx.project_root;
136    let managed_root = &ctx.managed_root;
137    let mars_dir = project_root.join(".mars");
138
139    validate_request(request)?;
140
141    std::fs::create_dir_all(mars_dir.join("cache"))?;
142
143    // Step 1: Acquire sync lock before any config reads/mutations.
144    let lock_path = mars_dir.join("sync.lock");
145    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
146
147    // Step 2: Load config under lock (auto-init when mutating and missing).
148    let mut config = match crate::config::load(project_root) {
149        Ok(config) => config,
150        Err(err) if is_config_not_found(&err) && request.mutation.is_some() => Config {
151            settings: Settings::default(),
152            ..Config::default()
153        },
154        Err(err) => return Err(err),
155    };
156
157    // Step 3: Apply config mutation.
158    let has_mutation = request.mutation.is_some();
159    let dependency_changes = if let Some(mutation) = &request.mutation {
160        apply_mutation(&mut config, mutation)?
161    } else {
162        Vec::new()
163    };
164
165    // Step 4: Load/mutate local overrides under the same lock.
166    let mut local = crate::config::load_local(project_root)?;
167    if let Some(mutation) = &request.mutation {
168        apply_local_mutation(&mut local, mutation);
169    }
170
171    // Step 4b: Build effective config.
172    let effective = crate::config::merge_with_root(config.clone(), local.clone(), project_root)?;
173
174    // Step 5: Validate upgrade targets exist.
175    validate_targets(&request.resolution, &effective)?;
176
177    // Step 6: Load existing lock file.
178    let old_lock = crate::lock::load(project_root)?;
179
180    // Step 7: Resolve dependency graph.
181    let cache = GlobalCache::new()?;
182    let provider = RealSourceProvider {
183        cache: &cache,
184        project_root,
185    };
186    let resolve_options = to_resolve_options(&request.resolution, request.options.frozen);
187    let graph = crate::resolve::resolve(&effective, &provider, Some(&old_lock), &resolve_options)?;
188
189    // Step 8: Build target state.
190    let (mut target_state, renames) = target::build_with_collisions(&graph, &effective)?;
191
192    // Step 9: Handle collisions + rewrite frontmatter refs.
193    if !renames.is_empty() {
194        let rewrite_warnings = target::rewrite_skill_refs(&mut target_state, &renames, &graph)?;
195        for w in &rewrite_warnings {
196            eprintln!("{w}");
197        }
198    }
199
200    // Step 10: Validate skill references.
201    let warnings = validate_skill_refs(managed_root, &target_state);
202
203    // Step 11: Prevent managed installs from overwriting unmanaged files.
204    let unmanaged_collisions =
205        target::check_unmanaged_collisions(managed_root, &old_lock, &target_state);
206    for collision in &unmanaged_collisions {
207        eprintln!(
208            "warning: source `{}` collides with unmanaged path `{}` — leaving existing content untouched",
209            collision.source_name, collision.path
210        );
211        target_state.items.shift_remove(&collision.path);
212    }
213
214    // Step 12: Compute diff.
215    let sync_diff = diff::compute(
216        managed_root,
217        &old_lock,
218        &target_state,
219        request.options.force,
220    )?;
221
222    // Step 13: Create plan.
223    let cache_bases_dir = mars_dir.join("cache").join("bases");
224    let mut sync_plan = plan::create(&sync_diff, &request.options, &cache_bases_dir);
225    let mut skipped_self_dests: HashSet<crate::types::DestPath> = HashSet::new();
226
227    // Step 13b: Inject local package symlinks into plan.
228    if config.package.is_some() {
229        let self_items = discover_local_items(project_root)?;
230
231        // Collision check: local items shadow external items
232        for item in &self_items {
233            if target_state.items.contains_key(&item.dest_rel) {
234                let existing = &target_state.items[&item.dest_rel];
235                eprintln!(
236                    "warning: local {} `{}` shadows dependency `{}` {} `{}`",
237                    item.kind, item.name, existing.source_name, existing.id.kind, existing.id.name
238                );
239                // Remove external item from plan (it will be replaced by symlink)
240                let dest_rel = item.dest_rel.clone();
241                sync_plan
242                    .actions
243                    .retain(|a| !action_matches_dest(a, &dest_rel));
244                target_state.items.shift_remove(&item.dest_rel);
245            }
246        }
247
248        // Inject symlink actions for items that need updating
249        for item in &self_items {
250            let dest = managed_root.join(item.dest_rel.as_path());
251            if !old_lock.items.contains_key(&item.dest_rel) && dest.symlink_metadata().is_ok() {
252                eprintln!(
253                    "warning: local {} `{}` collides with unmanaged path `{}` — leaving existing content untouched",
254                    item.kind, item.name, item.dest_rel
255                );
256                skipped_self_dests.insert(item.dest_rel.clone());
257                continue;
258            }
259            let needs_update = match dest.symlink_metadata() {
260                Ok(meta) if meta.file_type().is_symlink() => {
261                    let current_target = std::fs::read_link(&dest).ok();
262                    let from_dir = dest.parent().unwrap();
263                    let expected = pathdiff::diff_paths(&item.source_path, from_dir)
264                        .unwrap_or_else(|| item.source_path.clone());
265                    current_target.as_deref() != Some(expected.as_path())
266                }
267                Ok(_) => true,  // exists but not a symlink — replace
268                Err(_) => true, // doesn't exist — create
269            };
270            if needs_update {
271                sync_plan.actions.push(plan::PlannedAction::Symlink {
272                    source_abs: item.source_path.clone(),
273                    dest_rel: item.dest_rel.clone(),
274                    kind: item.kind,
275                    name: item.name.clone(),
276                });
277            }
278        }
279
280        // Prune old _self entries from lock that are no longer present
281        let self_dest_set: std::collections::HashSet<_> =
282            self_items.iter().map(|i| &i.dest_rel).collect();
283        for (dest_path, locked_item) in &old_lock.items {
284            if locked_item.source.as_ref() == "_self" && !self_dest_set.contains(dest_path) {
285                sync_plan.actions.push(plan::PlannedAction::Remove {
286                    locked: locked_item.clone(),
287                });
288            }
289        }
290    } else {
291        // No [package] — prune any stale _self entries from lock
292        for (_, locked_item) in &old_lock.items {
293            if locked_item.source.as_ref() == "_self" {
294                sync_plan.actions.push(plan::PlannedAction::Remove {
295                    locked: locked_item.clone(),
296                });
297            }
298        }
299    }
300
301    // Step 13c: Remove any orphan-removal actions targeting _self items.
302    // The diff engine (step 12) doesn't know about _self items, so it marks
303    // old _self lock entries as orphans. We handle _self lifecycle explicitly
304    // above (inject symlinks + explicit prune), so strip the diff engine's
305    // Remove actions for _self items to prevent double-removal.
306    sync_plan.actions.retain(|action| {
307        if let plan::PlannedAction::Remove { locked } = action {
308            locked.source.as_ref() != "_self"
309        } else {
310            true
311        }
312    });
313
314    // Step 14: Frozen gate.
315    if request.options.frozen {
316        let has_changes = sync_plan.actions.iter().any(|a| {
317            !matches!(
318                a,
319                plan::PlannedAction::Skip { .. } | plan::PlannedAction::KeepLocal { .. }
320            )
321        });
322        if has_changes {
323            return Err(MarsError::FrozenViolation {
324                message: "lock file would change but --frozen is set".into(),
325            });
326        }
327    }
328
329    // Step 15: Persist config/local only after validation gate and before apply.
330    if has_mutation && !request.options.dry_run {
331        match &request.mutation {
332            Some(ConfigMutation::SetOverride { .. } | ConfigMutation::ClearOverride { .. }) => {
333                crate::config::save_local(project_root, &local)?;
334            }
335            Some(
336                ConfigMutation::UpsertDependency { .. }
337                | ConfigMutation::BatchUpsert(..)
338                | ConfigMutation::RemoveDependency { .. }
339                | ConfigMutation::SetRename { .. },
340            ) => {
341                crate::config::save(project_root, &config)?;
342            }
343            None => {}
344        }
345    }
346
347    // Step 16: Apply plan.
348    let applied = apply::execute(managed_root, &sync_plan, &request.options, &cache_bases_dir)?;
349    let pruned = Vec::new();
350
351    // Step 17: Write lock file.
352    if !request.options.dry_run {
353        let self_lock_items = if config.package.is_some() {
354            let self_items = discover_local_items(project_root)?;
355            let filtered: Vec<_> = self_items
356                .into_iter()
357                .filter(|item| !skipped_self_dests.contains(&item.dest_rel))
358                .collect();
359            build_self_lock_items(&filtered)?
360        } else {
361            Vec::new()
362        };
363        let self_items_for_lock =
364            (!self_lock_items.is_empty()).then_some(self_lock_items.as_slice());
365        let new_lock = crate::lock::build(&graph, &applied, &old_lock, self_items_for_lock)?;
366        crate::lock::write(project_root, &new_lock)?;
367    }
368
369    Ok(SyncReport {
370        applied,
371        pruned,
372        warnings,
373        dependency_changes,
374        dry_run: request.options.dry_run,
375    })
376}
377
378fn validate_request(request: &SyncRequest) -> Result<(), MarsError> {
379    if request.options.frozen && matches!(request.resolution, ResolutionMode::Maximize { .. }) {
380        return Err(MarsError::InvalidRequest {
381            message:
382                "cannot use --frozen with upgrade (frozen locks versions; upgrade maximizes them)"
383                    .to_string(),
384        });
385    }
386
387    if request.options.frozen && request.mutation.is_some() {
388        return Err(MarsError::InvalidRequest {
389            message:
390                "cannot modify config in --frozen mode (config change would require lock update)"
391                    .to_string(),
392        });
393    }
394
395    Ok(())
396}
397
398fn is_config_not_found(error: &MarsError) -> bool {
399    matches!(error, MarsError::Config(ConfigError::NotFound { .. }))
400}
401
402/// Apply a config mutation to the in-memory config.
403///
404/// Public so that CLI commands can batch mutations before triggering sync.
405pub fn apply_config_mutation(
406    config: &mut Config,
407    mutation: &ConfigMutation,
408) -> Result<(), MarsError> {
409    apply_mutation(config, mutation).map(|_| ())
410}
411
412fn apply_mutation(
413    config: &mut Config,
414    mutation: &ConfigMutation,
415) -> Result<Vec<DependencyUpsertChange>, MarsError> {
416    match mutation {
417        ConfigMutation::UpsertDependency { name, entry } => {
418            Ok(vec![apply_dependency_upsert(config, name, entry)])
419        }
420        ConfigMutation::BatchUpsert(entries) => {
421            let mut changes = Vec::with_capacity(entries.len());
422            for (name, entry) in entries {
423                changes.push(apply_dependency_upsert(config, name, entry));
424            }
425            Ok(changes)
426        }
427        ConfigMutation::RemoveDependency { name } => {
428            if !config.dependencies.contains_key(name) {
429                return Err(MarsError::Source {
430                    source_name: name.to_string(),
431                    message: format!("dependency `{name}` not found in mars.toml"),
432                });
433            }
434            config.dependencies.shift_remove(name);
435            Ok(Vec::new())
436        }
437        ConfigMutation::SetOverride { source_name, .. } => {
438            if !config.dependencies.contains_key(source_name) {
439                return Err(MarsError::Source {
440                    source_name: source_name.to_string(),
441                    message: format!("dependency `{source_name}` not found in mars.toml"),
442                });
443            }
444            Ok(Vec::new())
445        }
446        ConfigMutation::SetRename {
447            source_name,
448            from,
449            to,
450        } => {
451            let dep =
452                config
453                    .dependencies
454                    .get_mut(source_name)
455                    .ok_or_else(|| MarsError::Source {
456                        source_name: source_name.to_string(),
457                        message: format!("dependency `{source_name}` not found in mars.toml"),
458                    })?;
459            let rename_map = dep
460                .filter
461                .rename
462                .get_or_insert_with(crate::types::RenameMap::new);
463            rename_map.insert(ItemName::from(from.as_str()), ItemName::from(to.as_str()));
464            Ok(Vec::new())
465        }
466        ConfigMutation::ClearOverride { .. } => Ok(Vec::new()),
467    }
468}
469
470fn apply_local_mutation(local: &mut LocalConfig, mutation: &ConfigMutation) {
471    match mutation {
472        ConfigMutation::SetOverride {
473            source_name,
474            local_path,
475        } => {
476            local.overrides.insert(
477                source_name.clone(),
478                OverrideEntry {
479                    path: local_path.clone(),
480                },
481            );
482        }
483        ConfigMutation::ClearOverride { source_name } => {
484            local.overrides.shift_remove(source_name);
485        }
486        ConfigMutation::UpsertDependency { .. }
487        | ConfigMutation::BatchUpsert(..)
488        | ConfigMutation::RemoveDependency { .. }
489        | ConfigMutation::SetRename { .. } => {}
490    }
491}
492
493fn apply_dependency_upsert(
494    config: &mut Config,
495    name: &SourceName,
496    entry: &DependencyEntry,
497) -> DependencyUpsertChange {
498    if let Some(existing) = config.dependencies.get_mut(name) {
499        let old_filter = existing.filter.clone();
500
501        // Merge: update location fields, preserve user customizations
502        existing.url = entry.url.clone();
503        existing.path = entry.path.clone();
504        existing.version = entry.version.clone();
505        // Atomic filter replacement: when any filter field is set on the
506        // incoming entry, replace the entire filter config (minus rename).
507        // This prevents mixed-mode states like agents + only_skills.
508        // When no filter flags are provided (e.g., version bump), preserve existing.
509        if entry.filter.has_any_filter() {
510            let rename = existing.filter.rename.take();
511            existing.filter = entry.filter.clone();
512            // Preserve rename — those are set via `mars rename`, not `mars add`
513            existing.filter.rename = rename;
514        }
515        // Never overwrite rename rules from add — those are set via `mars rename`
516
517        DependencyUpsertChange {
518            name: name.clone(),
519            already_exists: true,
520            old_filter: Some(old_filter),
521            new_filter: existing.filter.clone(),
522        }
523    } else {
524        config.dependencies.insert(name.clone(), entry.clone());
525        DependencyUpsertChange {
526            name: name.clone(),
527            already_exists: false,
528            old_filter: None,
529            new_filter: entry.filter.clone(),
530        }
531    }
532}
533
534fn validate_targets(
535    resolution: &ResolutionMode,
536    effective: &EffectiveConfig,
537) -> Result<(), MarsError> {
538    if let ResolutionMode::Maximize { targets } = resolution {
539        for name in targets {
540            if !effective.dependencies.contains_key(name) {
541                return Err(MarsError::Source {
542                    source_name: name.to_string(),
543                    message: format!("dependency `{name}` not found in mars.toml"),
544                });
545            }
546        }
547    }
548
549    Ok(())
550}
551
552fn to_resolve_options(mode: &ResolutionMode, frozen: bool) -> ResolveOptions {
553    match mode {
554        ResolutionMode::Normal => ResolveOptions {
555            frozen,
556            ..ResolveOptions::default()
557        },
558        ResolutionMode::Maximize { targets } => ResolveOptions {
559            maximize: true,
560            upgrade_targets: targets.clone(),
561            frozen,
562        },
563    }
564}
565
566/// Real source provider that delegates to the source module.
567///
568/// Implements the SourceProvider trait so the resolver can fetch sources
569/// and read manifests through a uniform interface.
570struct RealSourceProvider<'a> {
571    cache: &'a GlobalCache,
572    project_root: &'a Path,
573}
574
575impl VersionLister for RealSourceProvider<'_> {
576    fn list_versions(
577        &self,
578        url: &crate::types::SourceUrl,
579    ) -> Result<Vec<AvailableVersion>, MarsError> {
580        source::list_versions(url, self.cache)
581    }
582}
583
584impl SourceFetcher for RealSourceProvider<'_> {
585    fn fetch_git_version(
586        &self,
587        url: &crate::types::SourceUrl,
588        version: &AvailableVersion,
589        source_name: &str,
590        preferred_commit: Option<&str>,
591    ) -> Result<ResolvedRef, MarsError> {
592        let fetch_options = source::git::FetchOptions {
593            preferred_commit: preferred_commit.map(CommitHash::from),
594        };
595        source::git::fetch(
596            url.as_ref(),
597            Some(&version.tag),
598            source_name,
599            self.cache,
600            &fetch_options,
601        )
602    }
603
604    fn fetch_git_ref(
605        &self,
606        url: &crate::types::SourceUrl,
607        ref_name: &str,
608        source_name: &str,
609        preferred_commit: Option<&str>,
610    ) -> Result<ResolvedRef, MarsError> {
611        let fetch_options = source::git::FetchOptions {
612            preferred_commit: preferred_commit.map(CommitHash::from),
613        };
614        source::git::fetch(
615            url.as_ref(),
616            Some(ref_name),
617            source_name,
618            self.cache,
619            &fetch_options,
620        )
621    }
622
623    fn fetch_path(&self, path: &Path, source_name: &str) -> Result<ResolvedRef, MarsError> {
624        source::path::fetch_path(path, self.project_root, source_name)
625    }
626}
627
628impl ManifestReader for RealSourceProvider<'_> {
629    fn read_manifest(&self, source_tree: &Path) -> Result<Option<Manifest>, MarsError> {
630        crate::config::load_manifest(source_tree)
631    }
632}
633
634/// Validate skill references: check that agents' `skills:` frontmatter entries
635/// reference skills that exist in the target state.
636fn validate_skill_refs(
637    install_target: &Path,
638    target: &target::TargetState,
639) -> Vec<ValidationWarning> {
640    use crate::lock::ItemKind;
641
642    // Collect available skill names
643    let available_skills: HashSet<String> = target
644        .items
645        .values()
646        .filter(|item| item.id.kind == ItemKind::Skill)
647        .map(|item| item.id.name.to_string())
648        .collect();
649
650    // Collect agents with their paths
651    let agents: Vec<(String, PathBuf)> = target
652        .items
653        .values()
654        .filter(|item| item.id.kind == ItemKind::Agent)
655        .map(|item| {
656            let disk_path = install_target.join(&item.dest_path);
657            // If the file exists on disk, use that (may have local edits).
658            // Otherwise, use the source path.
659            let path = if disk_path.exists() {
660                disk_path
661            } else {
662                item.source_path.clone()
663            };
664            (item.id.name.to_string(), path)
665        })
666        .collect();
667
668    crate::validate::check_deps(&agents, &available_skills).unwrap_or_default()
669}
670
671/// A local package item discovered under the project root.
672struct LocalItem {
673    kind: crate::lock::ItemKind,
674    name: ItemName,
675    /// Absolute path to source — for agents, the .md file; for skills, the directory.
676    source_path: PathBuf,
677    /// Relative destination under managed root.
678    dest_rel: crate::types::DestPath,
679}
680
681/// Discover local package items (agents and skills) at the project root.
682///
683/// Called when `[package]` is present in `mars.toml`. Scans:
684/// - `project_root/agents/*.md` → agent items
685/// - `project_root/skills/*/` (directories containing SKILL.md) → skill items
686fn discover_local_items(project_root: &Path) -> Result<Vec<LocalItem>, MarsError> {
687    use crate::lock::ItemKind;
688    let mut items = Vec::new();
689
690    // Discover agents
691    let agents_dir = project_root.join("agents");
692    if agents_dir.is_dir() {
693        for entry in std::fs::read_dir(&agents_dir)? {
694            let entry = entry?;
695            let path = entry.path();
696            if path.extension().and_then(|e| e.to_str()) == Some("md") && path.is_file() {
697                let name = path
698                    .file_stem()
699                    .unwrap_or_default()
700                    .to_string_lossy()
701                    .to_string();
702                items.push(LocalItem {
703                    kind: ItemKind::Agent,
704                    name: ItemName::from(name.as_str()),
705                    source_path: path.canonicalize().unwrap_or(path.clone()),
706                    dest_rel: format!("agents/{}.md", name).into(),
707                });
708            }
709        }
710    }
711
712    // Discover skills
713    let skills_dir = project_root.join("skills");
714    if skills_dir.is_dir() {
715        for entry in std::fs::read_dir(&skills_dir)? {
716            let entry = entry?;
717            let path = entry.path();
718            if path.is_dir() && path.join("SKILL.md").exists() {
719                let name = path
720                    .file_name()
721                    .unwrap_or_default()
722                    .to_string_lossy()
723                    .to_string();
724                items.push(LocalItem {
725                    kind: ItemKind::Skill,
726                    name: ItemName::from(name.as_str()),
727                    source_path: path.canonicalize().unwrap_or(path.clone()),
728                    dest_rel: format!("skills/{}", name).into(),
729                });
730            }
731        }
732    }
733
734    Ok(items)
735}
736
737fn build_self_lock_items(items: &[LocalItem]) -> Result<Vec<crate::lock::SelfLockItem>, MarsError> {
738    let mut lock_items = Vec::with_capacity(items.len());
739    for item in items {
740        let source_checksum = ContentHash::from(hash::compute_hash(&item.source_path, item.kind)?);
741        lock_items.push(crate::lock::SelfLockItem {
742            dest_path: item.dest_rel.clone(),
743            kind: item.kind,
744            source_checksum,
745        });
746    }
747    Ok(lock_items)
748}
749
750/// Check if a planned action targets a specific destination path.
751fn action_matches_dest(action: &plan::PlannedAction, dest: &crate::types::DestPath) -> bool {
752    match action {
753        plan::PlannedAction::Install { target } | plan::PlannedAction::Overwrite { target } => {
754            &target.dest_path == dest
755        }
756        plan::PlannedAction::Skip { dest_path, .. }
757        | plan::PlannedAction::KeepLocal { dest_path, .. } => dest_path == dest,
758        plan::PlannedAction::Merge { target, .. } => &target.dest_path == dest,
759        plan::PlannedAction::Remove { locked } => &locked.dest_path == dest,
760        plan::PlannedAction::Symlink { dest_rel, .. } => dest_rel == dest,
761    }
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767    use crate::config::*;
768    use crate::lock::{ItemKind, LockFile};
769    use crate::resolve::{ResolvedGraph, ResolvedNode};
770    use crate::source::ResolvedRef;
771    use indexmap::IndexMap;
772    use std::fs;
773    use tempfile::TempDir;
774
775    /// Helper to set up a complete sync context with temp dirs.
776    struct TestFixture {
777        project_root: TempDir,
778        managed_root: PathBuf,
779        source_trees: Vec<TempDir>,
780    }
781
782    impl TestFixture {
783        fn new() -> Self {
784            let project_root = TempDir::new().unwrap();
785            let managed_root = project_root.path().join(".agents");
786            // Create .mars/cache directories
787            fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
788            TestFixture {
789                project_root,
790                managed_root,
791                source_trees: Vec::new(),
792            }
793        }
794
795        fn add_source(&mut self, agents: &[(&str, &str)], skills: &[(&str, &str)]) -> usize {
796            let dir = TempDir::new().unwrap();
797            if !agents.is_empty() {
798                let agents_dir = dir.path().join("agents");
799                fs::create_dir_all(&agents_dir).unwrap();
800                for (name, content) in agents {
801                    fs::write(agents_dir.join(name), content).unwrap();
802                }
803            }
804            if !skills.is_empty() {
805                let skills_dir = dir.path().join("skills");
806                fs::create_dir_all(&skills_dir).unwrap();
807                for (name, content) in skills {
808                    let skill_dir = skills_dir.join(name);
809                    fs::create_dir_all(&skill_dir).unwrap();
810                    fs::write(skill_dir.join("SKILL.md"), content).unwrap();
811                }
812            }
813            self.source_trees.push(dir);
814            self.source_trees.len() - 1
815        }
816
817        fn project_root(&self) -> &Path {
818            self.project_root.path()
819        }
820
821        fn managed_root(&self) -> &Path {
822            &self.managed_root
823        }
824
825        fn tree_path(&self, idx: usize) -> PathBuf {
826            self.source_trees[idx].path().to_path_buf()
827        }
828    }
829
830    fn make_graph_config(
831        fixture: &TestFixture,
832        sources: Vec<(&str, usize, FilterMode)>,
833    ) -> (ResolvedGraph, EffectiveConfig) {
834        let mut nodes = IndexMap::new();
835        let mut order = Vec::new();
836        let mut config_dependencies = IndexMap::new();
837
838        for (name, tree_idx, filter) in sources {
839            let tree_path = fixture.tree_path(tree_idx);
840            nodes.insert(
841                name.into(),
842                ResolvedNode {
843                    source_name: name.into(),
844                    source_id: crate::types::SourceId::Path {
845                        canonical: tree_path.clone(),
846                    },
847                    resolved_ref: ResolvedRef {
848                        source_name: name.into(),
849                        version: None,
850                        version_tag: None,
851                        commit: None,
852                        tree_path: tree_path.clone(),
853                    },
854                    manifest: None,
855                    deps: vec![],
856                },
857            );
858            order.push(name.into());
859
860            config_dependencies.insert(
861                name.into(),
862                EffectiveDependency {
863                    name: name.into(),
864                    id: crate::types::SourceId::Path {
865                        canonical: tree_path.clone(),
866                    },
867                    spec: SourceSpec::Path(tree_path),
868                    filter,
869                    rename: crate::types::RenameMap::new(),
870                    is_overridden: false,
871                    original_git: None,
872                },
873            );
874        }
875
876        (
877            ResolvedGraph {
878                nodes,
879                order,
880                id_index: std::collections::HashMap::new(),
881            },
882            EffectiveConfig {
883                dependencies: config_dependencies,
884                settings: Settings::default(),
885            },
886        )
887    }
888
889    fn path_dependency_entry(path: &Path) -> DependencyEntry {
890        DependencyEntry {
891            url: None,
892            path: Some(path.to_path_buf()),
893            version: None,
894            filter: FilterConfig::default(),
895        }
896    }
897
898    #[test]
899    fn validate_request_rejects_frozen_with_maximize() {
900        let request = SyncRequest {
901            resolution: ResolutionMode::Maximize {
902                targets: HashSet::new(),
903            },
904            mutation: None,
905            options: SyncOptions {
906                force: false,
907                dry_run: false,
908                frozen: true,
909            },
910        };
911
912        let err = validate_request(&request).unwrap_err();
913        assert!(matches!(err, MarsError::InvalidRequest { .. }));
914        assert!(err.to_string().contains("--frozen"));
915    }
916
917    #[test]
918    fn validate_request_rejects_frozen_with_mutation() {
919        let request = SyncRequest {
920            resolution: ResolutionMode::Normal,
921            mutation: Some(ConfigMutation::RemoveDependency {
922                name: "base".into(),
923            }),
924            options: SyncOptions {
925                force: false,
926                dry_run: false,
927                frozen: true,
928            },
929        };
930
931        let err = validate_request(&request).unwrap_err();
932        assert!(matches!(err, MarsError::InvalidRequest { .. }));
933        assert!(err.to_string().contains("cannot modify config"));
934    }
935
936    #[test]
937    fn execute_auto_inits_config_for_mutation() {
938        let project_root = TempDir::new().unwrap();
939        let managed_root = project_root.path().join(".agents");
940        fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
941        let source = TempDir::new().unwrap();
942        fs::create_dir_all(source.path().join("agents")).unwrap();
943        fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
944
945        let request = SyncRequest {
946            resolution: ResolutionMode::Normal,
947            mutation: Some(ConfigMutation::UpsertDependency {
948                name: "base".into(),
949                entry: path_dependency_entry(source.path()),
950            }),
951            options: SyncOptions::default(),
952        };
953
954        let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
955        let report = execute(&ctx, &request).unwrap();
956        assert!(!report.applied.outcomes.is_empty());
957        assert!(project_root.path().join("mars.toml").exists());
958
959        let saved = crate::config::load(project_root.path()).unwrap();
960        assert!(saved.dependencies.contains_key("base"));
961    }
962
963    #[test]
964    fn execute_dry_run_with_mutation_does_not_write_config() {
965        let project_root = TempDir::new().unwrap();
966        let managed_root = project_root.path().join(".agents");
967        fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
968        crate::config::save(
969            project_root.path(),
970            &Config {
971                dependencies: IndexMap::new(),
972                settings: Settings::default(),
973                ..Config::default()
974            },
975        )
976        .unwrap();
977
978        let source = TempDir::new().unwrap();
979        fs::create_dir_all(source.path().join("agents")).unwrap();
980        fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
981
982        let request = SyncRequest {
983            resolution: ResolutionMode::Normal,
984            mutation: Some(ConfigMutation::UpsertDependency {
985                name: "base".into(),
986                entry: path_dependency_entry(source.path()),
987            }),
988            options: SyncOptions {
989                force: false,
990                dry_run: true,
991                frozen: false,
992            },
993        };
994
995        let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
996        let report = execute(&ctx, &request).unwrap();
997        assert!(!report.applied.outcomes.is_empty());
998
999        let saved = crate::config::load(project_root.path()).unwrap();
1000        assert!(!saved.dependencies.contains_key("base"));
1001        assert!(!managed_root.join("agents/coder.md").exists());
1002        assert!(!project_root.path().join("mars.lock").exists());
1003    }
1004
1005    // === Integration tests for the pipeline stages ===
1006
1007    #[test]
1008    fn full_pipeline_fresh_sync() {
1009        let mut fixture = TestFixture::new();
1010        let src_idx = fixture.add_source(
1011            &[("coder.md", "# Coder agent")],
1012            &[("planning", "# Planning skill")],
1013        );
1014
1015        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1016
1017        // Build target
1018        let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1019        assert!(renames.is_empty());
1020        assert_eq!(target.items.len(), 2);
1021
1022        // Compute diff against empty lock
1023        let lock = LockFile::empty();
1024        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1025
1026        // All items should be Add
1027        assert_eq!(sync_diff.items.len(), 2);
1028        for entry in &sync_diff.items {
1029            assert!(matches!(entry, diff::DiffEntry::Add { .. }));
1030        }
1031
1032        // Create plan
1033        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1034        let options = SyncOptions {
1035            force: false,
1036            dry_run: false,
1037            frozen: false,
1038        };
1039        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1040        assert_eq!(sync_plan.actions.len(), 2);
1041        for action in &sync_plan.actions {
1042            assert!(matches!(action, plan::PlannedAction::Install { .. }));
1043        }
1044
1045        // Execute plan
1046        let result =
1047            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1048        assert_eq!(result.outcomes.len(), 2);
1049
1050        // Verify files were created
1051        assert!(fixture.managed_root().join("agents/coder.md").exists());
1052        assert!(
1053            fixture
1054                .managed_root()
1055                .join("skills/planning/SKILL.md")
1056                .exists()
1057        );
1058
1059        // Build lock
1060        let new_lock = crate::lock::build(&graph, &result, &lock, None).unwrap();
1061        assert_eq!(new_lock.items.len(), 2);
1062        assert!(new_lock.items.contains_key("agents/coder.md"));
1063        assert!(new_lock.items.contains_key("skills/planning"));
1064    }
1065
1066    #[test]
1067    fn re_sync_no_changes() {
1068        let mut fixture = TestFixture::new();
1069        let content = "# Coder agent";
1070        let src_idx = fixture.add_source(&[("coder.md", content)], &[]);
1071
1072        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1073
1074        // First sync
1075        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1076        let lock = LockFile::empty();
1077        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1078        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1079        let options = SyncOptions {
1080            force: false,
1081            dry_run: false,
1082            frozen: false,
1083        };
1084        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1085        let result =
1086            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1087        let first_lock = crate::lock::build(&graph, &result, &lock, None).unwrap();
1088
1089        // Second sync with same content
1090        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1091        let sync_diff2 =
1092            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1093
1094        // All items should be Unchanged
1095        for entry in &sync_diff2.items {
1096            assert!(
1097                matches!(entry, diff::DiffEntry::Unchanged { .. }),
1098                "expected Unchanged, got {entry:?}"
1099            );
1100        }
1101
1102        let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1103        for action in &sync_plan2.actions {
1104            assert!(matches!(action, plan::PlannedAction::Skip { .. }));
1105        }
1106    }
1107
1108    #[test]
1109    fn source_update_detects_changes() {
1110        let mut fixture = TestFixture::new();
1111        let src_idx = fixture.add_source(&[("coder.md", "# Version 1")], &[]);
1112
1113        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1114
1115        // First sync
1116        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1117        let lock = LockFile::empty();
1118        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1119        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1120        let options = SyncOptions {
1121            force: false,
1122            dry_run: false,
1123            frozen: false,
1124        };
1125        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1126        let result =
1127            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1128        let first_lock = crate::lock::build(&graph, &result, &lock, None).unwrap();
1129
1130        // Update source content
1131        let agents_dir = fixture.tree_path(src_idx).join("agents");
1132        fs::write(agents_dir.join("coder.md"), "# Version 2").unwrap();
1133
1134        // Rebuild target with updated content
1135        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1136        let sync_diff2 =
1137            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1138
1139        // Should detect an Update
1140        assert_eq!(sync_diff2.items.len(), 1);
1141        assert!(matches!(
1142            &sync_diff2.items[0],
1143            diff::DiffEntry::Update { .. }
1144        ));
1145    }
1146
1147    #[test]
1148    fn local_modification_preserved() {
1149        let mut fixture = TestFixture::new();
1150        let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1151
1152        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1153
1154        // First sync
1155        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1156        let lock = LockFile::empty();
1157        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1158        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1159        let options = SyncOptions {
1160            force: false,
1161            dry_run: false,
1162            frozen: false,
1163        };
1164        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1165        let result =
1166            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1167        let first_lock = crate::lock::build(&graph, &result, &lock, None).unwrap();
1168
1169        // Locally modify the installed file
1170        fs::write(
1171            fixture.managed_root().join("agents/coder.md"),
1172            "# Locally modified",
1173        )
1174        .unwrap();
1175
1176        // Re-sync (source unchanged)
1177        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1178        let sync_diff2 =
1179            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1180
1181        // Should detect LocalModified
1182        assert_eq!(sync_diff2.items.len(), 1);
1183        assert!(matches!(
1184            &sync_diff2.items[0],
1185            diff::DiffEntry::LocalModified { .. }
1186        ));
1187
1188        // Plan should KeepLocal
1189        let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1190        assert!(matches!(
1191            &sync_plan2.actions[0],
1192            plan::PlannedAction::KeepLocal { .. }
1193        ));
1194    }
1195
1196    #[test]
1197    fn force_overwrites_local_modifications() {
1198        let mut fixture = TestFixture::new();
1199        let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1200
1201        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1202
1203        // First sync
1204        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1205        let lock = LockFile::empty();
1206        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1207        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1208        let options = SyncOptions {
1209            force: false,
1210            dry_run: false,
1211            frozen: false,
1212        };
1213        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1214        let result =
1215            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1216        let first_lock = crate::lock::build(&graph, &result, &lock, None).unwrap();
1217
1218        // Locally modify the installed file
1219        fs::write(
1220            fixture.managed_root().join("agents/coder.md"),
1221            "# Locally modified",
1222        )
1223        .unwrap();
1224
1225        // Update source too (triggers conflict)
1226        let agents_dir = fixture.tree_path(src_idx).join("agents");
1227        fs::write(agents_dir.join("coder.md"), "# Upstream update").unwrap();
1228
1229        // Re-sync with --force
1230        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1231        let sync_diff2 =
1232            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1233
1234        let force_options = SyncOptions {
1235            force: true,
1236            dry_run: false,
1237            frozen: false,
1238        };
1239        let sync_plan2 = plan::create(&sync_diff2, &force_options, &cache_dir);
1240        assert!(matches!(
1241            &sync_plan2.actions[0],
1242            plan::PlannedAction::Overwrite { .. }
1243        ));
1244
1245        let result2 = apply::execute(
1246            fixture.managed_root(),
1247            &sync_plan2,
1248            &force_options,
1249            &cache_dir,
1250        )
1251        .unwrap();
1252        assert!(matches!(
1253            result2.outcomes[0].action,
1254            apply::ActionTaken::Updated
1255        ));
1256
1257        // File should have upstream content
1258        let content = fs::read_to_string(fixture.managed_root().join("agents/coder.md")).unwrap();
1259        assert_eq!(content, "# Upstream update");
1260    }
1261
1262    #[test]
1263    fn orphan_removed_when_source_drops_item() {
1264        let mut fixture = TestFixture::new();
1265        let src_idx = fixture.add_source(
1266            &[("coder.md", "# Coder"), ("reviewer.md", "# Reviewer")],
1267            &[],
1268        );
1269
1270        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1271
1272        // First sync — install both
1273        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1274        let lock = LockFile::empty();
1275        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1276        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1277        let options = SyncOptions {
1278            force: false,
1279            dry_run: false,
1280            frozen: false,
1281        };
1282        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1283        let result =
1284            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1285        let first_lock = crate::lock::build(&graph, &result, &lock, None).unwrap();
1286
1287        assert!(fixture.managed_root().join("agents/coder.md").exists());
1288        assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1289
1290        // Remove reviewer from source
1291        fs::remove_file(fixture.tree_path(src_idx).join("agents/reviewer.md")).unwrap();
1292
1293        // Re-sync
1294        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1295        let sync_diff2 =
1296            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1297
1298        // Should have one Unchanged and one Orphan
1299        let orphan_count = sync_diff2
1300            .items
1301            .iter()
1302            .filter(|e| matches!(e, diff::DiffEntry::Orphan { .. }))
1303            .count();
1304        assert_eq!(orphan_count, 1);
1305
1306        let sync_plan2 = plan::create(&sync_diff2, &options, &cache_dir);
1307        let result2 =
1308            apply::execute(fixture.managed_root(), &sync_plan2, &options, &cache_dir).unwrap();
1309
1310        // Reviewer should be removed
1311        assert!(!fixture.managed_root().join("agents/reviewer.md").exists());
1312        // Coder should still be there
1313        assert!(fixture.managed_root().join("agents/coder.md").exists());
1314
1315        // Check remove outcome
1316        let removed = result2
1317            .outcomes
1318            .iter()
1319            .any(|o| matches!(o.action, apply::ActionTaken::Removed));
1320        assert!(removed);
1321    }
1322
1323    #[test]
1324    fn dry_run_produces_plan_without_changes() {
1325        let mut fixture = TestFixture::new();
1326        let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1327
1328        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1329
1330        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1331        let lock = LockFile::empty();
1332        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1333
1334        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1335        let dry_options = SyncOptions {
1336            force: false,
1337            dry_run: true,
1338            frozen: false,
1339        };
1340
1341        let sync_plan = plan::create(&sync_diff, &dry_options, &cache_dir);
1342        assert!(!sync_plan.actions.is_empty());
1343
1344        // Execute in dry-run mode
1345        let result =
1346            apply::execute(fixture.managed_root(), &sync_plan, &dry_options, &cache_dir).unwrap();
1347        assert!(!result.outcomes.is_empty());
1348
1349        // No files should have been created
1350        assert!(!fixture.managed_root().join("agents/coder.md").exists());
1351    }
1352
1353    #[test]
1354    fn lock_written_after_apply() {
1355        let mut fixture = TestFixture::new();
1356        let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1357
1358        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1359
1360        // Full pipeline minus actual sync() (which needs real config files)
1361        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1362        let lock = LockFile::empty();
1363        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1364        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1365        let options = SyncOptions {
1366            force: false,
1367            dry_run: false,
1368            frozen: false,
1369        };
1370        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1371        let result =
1372            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1373
1374        let new_lock = crate::lock::build(&graph, &result, &lock, None).unwrap();
1375        crate::lock::write(fixture.project_root(), &new_lock).unwrap();
1376
1377        // Verify lock file exists and is valid
1378        let reloaded = crate::lock::load(fixture.project_root()).unwrap();
1379        assert_eq!(reloaded.items.len(), 1);
1380        assert!(reloaded.items.contains_key("agents/coder.md"));
1381
1382        let item = &reloaded.items["agents/coder.md"];
1383        assert_eq!(item.kind, ItemKind::Agent);
1384        assert!(!item.source_checksum.is_empty());
1385        assert!(!item.installed_checksum.is_empty());
1386    }
1387
1388    #[test]
1389    fn two_sources_no_collision() {
1390        let mut fixture = TestFixture::new();
1391        let src_a = fixture.add_source(&[("coder.md", "# Coder from A")], &[]);
1392        let src_b = fixture.add_source(&[("reviewer.md", "# Reviewer from B")], &[]);
1393
1394        let (graph, config) = make_graph_config(
1395            &fixture,
1396            vec![
1397                ("source-a", src_a, FilterMode::All),
1398                ("source-b", src_b, FilterMode::All),
1399            ],
1400        );
1401
1402        let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1403        assert!(renames.is_empty());
1404        assert_eq!(target.items.len(), 2);
1405
1406        let lock = LockFile::empty();
1407        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1408        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1409        let options = SyncOptions {
1410            force: false,
1411            dry_run: false,
1412            frozen: false,
1413        };
1414        let sync_plan = plan::create(&sync_diff, &options, &cache_dir);
1415        let result =
1416            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1417
1418        assert!(fixture.managed_root().join("agents/coder.md").exists());
1419        assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1420        assert_eq!(result.outcomes.len(), 2);
1421    }
1422
1423    // === Tests for atomic filter replacement in apply_mutation ===
1424
1425    #[test]
1426    fn apply_mutation_atomic_filter_replacement() {
1427        let mut config = Config::default();
1428        // First add with agents filter
1429        let entry1 = DependencyEntry {
1430            url: Some("https://github.com/org/base.git".into()),
1431            path: None,
1432            version: Some("v1".into()),
1433            filter: FilterConfig {
1434                agents: Some(vec!["reviewer".into()]),
1435                ..FilterConfig::default()
1436            },
1437        };
1438        apply_mutation(
1439            &mut config,
1440            &ConfigMutation::UpsertDependency {
1441                name: "base".into(),
1442                entry: entry1,
1443            },
1444        )
1445        .unwrap();
1446        assert!(config.dependencies["base"].filter.agents.is_some());
1447
1448        // Re-add with only_skills — should atomically replace, clearing agents
1449        let entry2 = DependencyEntry {
1450            url: Some("https://github.com/org/base.git".into()),
1451            path: None,
1452            version: Some("v1".into()),
1453            filter: FilterConfig {
1454                only_skills: true,
1455                ..FilterConfig::default()
1456            },
1457        };
1458        apply_mutation(
1459            &mut config,
1460            &ConfigMutation::UpsertDependency {
1461                name: "base".into(),
1462                entry: entry2,
1463            },
1464        )
1465        .unwrap();
1466
1467        let dep = &config.dependencies["base"];
1468        assert!(dep.filter.only_skills);
1469        assert!(
1470            dep.filter.agents.is_none(),
1471            "agents should be cleared by atomic replacement"
1472        );
1473    }
1474
1475    #[test]
1476    fn apply_mutation_preserves_filters_on_version_bump() {
1477        let mut config = Config::default();
1478        // Add with agents filter
1479        let entry1 = DependencyEntry {
1480            url: Some("https://github.com/org/base.git".into()),
1481            path: None,
1482            version: Some("v1".into()),
1483            filter: FilterConfig {
1484                agents: Some(vec!["coder".into()]),
1485                ..FilterConfig::default()
1486            },
1487        };
1488        apply_mutation(
1489            &mut config,
1490            &ConfigMutation::UpsertDependency {
1491                name: "base".into(),
1492                entry: entry1,
1493            },
1494        )
1495        .unwrap();
1496
1497        // Re-add with no filter (version bump only)
1498        let entry2 = DependencyEntry {
1499            url: Some("https://github.com/org/base.git".into()),
1500            path: None,
1501            version: Some("v2".into()),
1502            filter: FilterConfig::default(),
1503        };
1504        apply_mutation(
1505            &mut config,
1506            &ConfigMutation::UpsertDependency {
1507                name: "base".into(),
1508                entry: entry2,
1509            },
1510        )
1511        .unwrap();
1512
1513        let dep = &config.dependencies["base"];
1514        assert_eq!(dep.version.as_deref(), Some("v2"));
1515        assert_eq!(
1516            dep.filter.agents.as_deref(),
1517            Some(&["coder".into()][..]),
1518            "agents filter should be preserved on version bump"
1519        );
1520    }
1521
1522    #[test]
1523    fn apply_mutation_preserves_rename_on_filter_change() {
1524        let mut config = Config::default();
1525        let mut rename_map = crate::types::RenameMap::new();
1526        rename_map.insert("old".into(), "new".into());
1527
1528        let entry1 = DependencyEntry {
1529            url: Some("https://github.com/org/base.git".into()),
1530            path: None,
1531            version: None,
1532            filter: FilterConfig {
1533                agents: Some(vec!["coder".into()]),
1534                rename: Some(rename_map),
1535                ..FilterConfig::default()
1536            },
1537        };
1538        apply_mutation(
1539            &mut config,
1540            &ConfigMutation::UpsertDependency {
1541                name: "base".into(),
1542                entry: entry1,
1543            },
1544        )
1545        .unwrap();
1546
1547        // Re-add with different filter — rename should be preserved
1548        let entry2 = DependencyEntry {
1549            url: Some("https://github.com/org/base.git".into()),
1550            path: None,
1551            version: None,
1552            filter: FilterConfig {
1553                only_skills: true,
1554                ..FilterConfig::default()
1555            },
1556        };
1557        apply_mutation(
1558            &mut config,
1559            &ConfigMutation::UpsertDependency {
1560                name: "base".into(),
1561                entry: entry2,
1562            },
1563        )
1564        .unwrap();
1565
1566        let dep = &config.dependencies["base"];
1567        assert!(dep.filter.only_skills);
1568        assert!(dep.filter.agents.is_none());
1569        assert!(
1570            dep.filter.rename.is_some(),
1571            "rename should be preserved across filter changes"
1572        );
1573        assert_eq!(
1574            dep.filter.rename.as_ref().unwrap().get("old").unwrap(),
1575            "new"
1576        );
1577    }
1578
1579    #[test]
1580    fn apply_mutation_batch_upsert_applies_all_entries() {
1581        let mut config = Config::default();
1582        let batch = vec![
1583            (
1584                "base".into(),
1585                DependencyEntry {
1586                    url: Some("https://github.com/org/base.git".into()),
1587                    path: None,
1588                    version: Some("v1".into()),
1589                    filter: FilterConfig::default(),
1590                },
1591            ),
1592            (
1593                "workflow".into(),
1594                DependencyEntry {
1595                    url: Some("https://github.com/org/workflow.git".into()),
1596                    path: None,
1597                    version: Some("v2".into()),
1598                    filter: FilterConfig::default(),
1599                },
1600            ),
1601        ];
1602
1603        let changes = apply_mutation(&mut config, &ConfigMutation::BatchUpsert(batch)).unwrap();
1604        assert_eq!(changes.len(), 2);
1605        assert!(config.dependencies.contains_key("base"));
1606        assert!(config.dependencies.contains_key("workflow"));
1607    }
1608
1609    #[test]
1610    fn apply_mutation_returns_old_and_new_filters_for_readd() {
1611        let mut config = Config::default();
1612        let entry1 = DependencyEntry {
1613            url: Some("https://github.com/org/base.git".into()),
1614            path: None,
1615            version: Some("v1".into()),
1616            filter: FilterConfig {
1617                agents: Some(vec!["reviewer".into()]),
1618                ..FilterConfig::default()
1619            },
1620        };
1621        apply_mutation(
1622            &mut config,
1623            &ConfigMutation::UpsertDependency {
1624                name: "base".into(),
1625                entry: entry1,
1626            },
1627        )
1628        .unwrap();
1629
1630        let entry2 = DependencyEntry {
1631            url: Some("https://github.com/org/base.git".into()),
1632            path: None,
1633            version: Some("v2".into()),
1634            filter: FilterConfig {
1635                only_skills: true,
1636                ..FilterConfig::default()
1637            },
1638        };
1639        let changes = apply_mutation(
1640            &mut config,
1641            &ConfigMutation::UpsertDependency {
1642                name: "base".into(),
1643                entry: entry2,
1644            },
1645        )
1646        .unwrap();
1647
1648        assert_eq!(changes.len(), 1);
1649        let change = &changes[0];
1650        assert!(change.already_exists);
1651        assert_eq!(change.name, "base");
1652        assert_eq!(
1653            change.old_filter.as_ref().and_then(|f| f.agents.as_deref()),
1654            Some(&["reviewer".into()][..])
1655        );
1656        assert!(change.new_filter.only_skills);
1657        assert!(change.new_filter.agents.is_none());
1658    }
1659
1660    // === Tests for OnlySkills / OnlyAgents filter in pipeline ===
1661
1662    #[test]
1663    fn pipeline_only_skills_filter() {
1664        let mut fixture = TestFixture::new();
1665        let src_idx = fixture.add_source(
1666            &[("coder.md", "# Coder agent")],
1667            &[("planning", "# Planning skill")],
1668        );
1669
1670        let (graph, config) =
1671            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlySkills)]);
1672
1673        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1674        // Should only have the skill, not the agent
1675        assert_eq!(target.items.len(), 1);
1676        assert!(target.items.contains_key("skills/planning"));
1677    }
1678
1679    #[test]
1680    fn pipeline_only_agents_filter() {
1681        let mut fixture = TestFixture::new();
1682        // Agent with a skill dependency in frontmatter
1683        let agent_content = "---\nskills:\n  - planning\n---\n# Coder agent";
1684        let src_idx = fixture.add_source(
1685            &[("coder.md", agent_content)],
1686            &[
1687                ("planning", "# Planning skill"),
1688                ("standalone", "# Standalone skill"),
1689            ],
1690        );
1691
1692        let (graph, config) =
1693            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
1694
1695        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1696        // Should have the agent + its transitive skill dep, but NOT standalone
1697        assert_eq!(target.items.len(), 2);
1698        assert!(target.items.contains_key("agents/coder.md"));
1699        assert!(target.items.contains_key("skills/planning"));
1700        assert!(!target.items.contains_key("skills/standalone"));
1701    }
1702
1703    #[test]
1704    fn pipeline_only_agents_no_agents_source() {
1705        let mut fixture = TestFixture::new();
1706        let src_idx = fixture.add_source(&[], &[("planning", "# Planning skill")]);
1707
1708        let (graph, config) =
1709            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
1710
1711        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1712        // No agents means nothing gets installed
1713        assert_eq!(target.items.len(), 0);
1714    }
1715}