Skip to main content

mars_agents/sync/
mod.rs

1pub mod apply;
2pub mod diff;
3pub mod filter;
4pub mod mutation;
5pub mod plan;
6pub mod provider;
7pub mod rewrite;
8pub mod target;
9pub mod types;
10mod upgrades;
11mod validate;
12
13use std::collections::BTreeMap;
14use std::collections::HashSet;
15use std::path::Path;
16
17use crate::config::{Config, EffectiveConfig, LocalConfig, Settings};
18use crate::diagnostic::{Diagnostic, DiagnosticCollector, LossinessMode};
19use crate::error::MarsError;
20use crate::fs::FileLock;
21use crate::hash;
22use crate::lock::{CANONICAL_TARGET_ROOT, ItemId, ItemKind};
23use crate::lock::{LockFile, LockIndex};
24use crate::resolve::{ResolveOptions, ResolvedGraph};
25use crate::source::GlobalCache;
26use crate::sync::apply::ApplyResult;
27pub use crate::sync::apply::SyncOptions;
28use crate::sync::target::{TargetItem, TargetState};
29use crate::types::managed_cmd;
30use crate::types::{ContentHash, DestPath, MarsContext, SourceName, SourceOrigin};
31use crate::validate::ValidationWarning;
32
33pub use crate::sync::mutation::{ConfigMutation, DependencyUpsertChange};
34
35/// Report from a completed sync operation.
36#[derive(Debug)]
37pub struct SyncReport {
38    pub applied: ApplyResult,
39    pub diagnostics: Vec<Diagnostic>,
40    pub dependency_changes: Vec<DependencyUpsertChange>,
41    pub upgrades_available: usize,
42    /// Per-target sync outcomes from the target sync phase.
43    pub target_outcomes: Vec<crate::target_sync::TargetSyncOutcome>,
44    /// Whether this was a dry run (`--diff`). Affects output wording only.
45    pub dry_run: bool,
46    /// Native harness agent outputs emitted this run that are new or content-changed
47    /// vs the previous lock, as `(target_root, dest_path)`. Surfaced so native
48    /// emission is not silent in the summary.
49    pub native_emitted: Vec<(String, String)>,
50    /// Native harness agent outputs removed this run, as `(target_root, dest_path)`.
51    /// Surfaced so SuppressAll / selective prunes are not reported as "up to date".
52    pub native_removed: Vec<(String, String)>,
53    /// Present when a recovery command persisted intent but stopped before
54    /// materialization because at least one hook surface was unreadable.
55    pub recovery_halt: Option<RecoveryHalt>,
56}
57
58/// A source package preventing a recovery command from entering the compiler.
59#[derive(Debug, Clone, serde::Serialize)]
60pub struct RecoveryBlocker {
61    pub package: String,
62    pub version: String,
63    pub hook_names: Vec<String>,
64    pub guidance: String,
65    pub suggested_command: String,
66}
67
68/// Successful intent persistence that still requires recovery work.
69#[derive(Debug, Clone, serde::Serialize)]
70pub struct RecoveryHalt {
71    pub persisted: Vec<String>,
72    pub blockers: Vec<RecoveryBlocker>,
73    pub next_step: String,
74}
75
76/// What a CLI command requests from the sync pipeline.
77#[derive(Debug, Clone)]
78pub struct SyncRequest {
79    /// How to resolve versions.
80    pub resolution: ResolutionMode,
81    /// Config mutation to apply under flock.
82    pub mutation: Option<ConfigMutation>,
83    /// Behavior flags.
84    pub options: SyncOptions,
85    /// Whether a recovery command may persist intent and halt when resolution
86    /// finds a hook surface the compiler cannot read.
87    pub recovery: RecoveryPolicy,
88    /// Whether lossiness warnings are included in the returned report.
89    /// `Surface` for `mars sync` / `mars upgrade`; `Hidden` for validate/export/add/repair.
90    pub lossiness_mode: LossinessMode,
91}
92
93/// Schema handling policy for content encountered during sync resolution.
94///
95/// Strict is the safe default: only commands whose purpose is to recover a
96/// locked-out graph may opt into deferring materialization.
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
98pub enum RecoveryPolicy {
99    #[default]
100    Strict,
101    DeferOnUnreadable,
102    /// Defer on unreadable sources and rebuild a corrupt lock in memory.
103    ///
104    /// The corrupt file remains untouched unless the full pipeline reaches
105    /// finalization and replaces it with a rebuilt lock.
106    Repair,
107}
108
109impl RecoveryPolicy {
110    fn defers_on_unreadable(self) -> bool {
111        matches!(self, Self::DeferOnUnreadable | Self::Repair)
112    }
113}
114
115/// Resolution behavior for the resolver stage.
116#[derive(Debug, Clone)]
117pub enum ResolutionMode {
118    /// Normal sync behavior.
119    Normal,
120    /// Upgrade behavior (maximize versions), optionally scoped to specific
121    /// sources and optionally bumping direct constraints.
122    Maximize {
123        targets: HashSet<SourceName>,
124        bump: bool,
125    },
126}
127
128// ---------------------------------------------------------------------------
129// Pipeline phase structs — typed handoffs between pipeline stages.
130// Phase functions consume prior state by value (move semantics, no cloning).
131// ---------------------------------------------------------------------------
132
133/// Phase 1: Load and validate configuration under sync lock.
134pub(crate) struct LoadedConfig {
135    pub config: Config,
136    pub local: LocalConfig,
137    pub effective: EffectiveConfig,
138    pub old_lock: LockFile,
139    pub dependency_changes: Vec<DependencyUpsertChange>,
140    /// Intentional keepalive — holds the sync file lock for the duration of the pipeline. Dropping this field releases the lock.
141    #[allow(dead_code)]
142    pub sync_lock: FileLock,
143}
144
145/// Phase 2: Resolved dependency graph.
146pub(crate) struct ResolvedState {
147    pub loaded: LoadedConfig,
148    pub graph: ResolvedGraph,
149    pub upgrades_available: usize,
150}
151
152/// Phase 3: Desired target state after discovery + filtering.
153pub(crate) struct TargetedState {
154    pub resolved: ResolvedState,
155    pub target: TargetState,
156    pub warnings: Vec<ValidationWarning>,
157}
158
159/// Phase 4: Diff + plan ready for execution.
160pub(crate) struct PlannedState {
161    pub targeted: TargetedState,
162    pub plan: plan::SyncPlan,
163}
164
165/// Phase 5: Applied results.
166pub(crate) struct AppliedState {
167    pub planned: PlannedState,
168    pub applied: ApplyResult,
169}
170
171/// Phase 6: Target sync results.
172pub(crate) struct SyncedState {
173    pub applied: AppliedState,
174    pub target_outcomes: Vec<crate::target_sync::TargetSyncOutcome>,
175    pub config_entries: BTreeMap<String, BTreeMap<String, crate::lock::ConfigEntryRecord>>,
176    pub config_entry_outputs: Vec<crate::lock::CompiledNativeOutput>,
177    pub removed_config_entry_outputs: Vec<(String, String)>,
178    pub compiled_native_outputs: Vec<crate::lock::CompiledNativeOutput>,
179    pub removed_native_outputs: Vec<crate::compiler::RemovedNativeOutput>,
180}
181
182/// Execute the unified sync pipeline.
183///
184/// Orchestrates phase functions, each consuming the prior phase's output struct.
185pub fn execute(ctx: &MarsContext, request: &SyncRequest) -> Result<SyncReport, MarsError> {
186    validate_request(request)?;
187    let mut diag = DiagnosticCollector::with_lossiness_mode(request.lossiness_mode);
188    let ir = crate::reader::read(ctx, request, &mut diag)?;
189    let unreadable_hook_surfaces = &ir.resolved.graph.unreadable_hook_surfaces;
190    if request.recovery.defers_on_unreadable() && !unreadable_hook_surfaces.is_empty() {
191        persist_pending_config_mutation(ctx, &ir.resolved.loaded, request)?;
192        let recovery_halt = build_recovery_halt(&ir.resolved, unreadable_hook_surfaces, request);
193        return Ok(SyncReport {
194            applied: ApplyResult {
195                outcomes: Vec::new(),
196            },
197            diagnostics: diag.drain(),
198            dependency_changes: ir.resolved.loaded.dependency_changes,
199            upgrades_available: ir.resolved.upgrades_available,
200            target_outcomes: Vec::new(),
201            dry_run: request.options.dry_run,
202            native_emitted: Vec::new(),
203            native_removed: Vec::new(),
204            recovery_halt: Some(recovery_halt),
205        });
206    }
207    crate::compiler::compile(ctx, ir, request, &mut diag)
208}
209
210fn build_recovery_halt(
211    resolved: &ResolvedState,
212    unreadable_hook_surfaces: &BTreeMap<SourceName, std::collections::BTreeSet<String>>,
213    request: &SyncRequest,
214) -> RecoveryHalt {
215    let persisted = persisted_intent_descriptions(resolved, request);
216    let blockers = unreadable_hook_surfaces
217        .iter()
218        .map(|(source_name, hook_names)| {
219            let node = resolved
220                .graph
221                .nodes
222                .get(source_name)
223                .expect("unreadable surface belongs to a resolved node");
224            let version = node
225                .resolved_ref
226                .version
227                .as_ref()
228                .map(ToString::to_string)
229                .or_else(|| node.resolved_ref.version_tag.clone())
230                .or_else(|| {
231                    node.manifest
232                        .as_ref()
233                        .map(|manifest| manifest.package.version.clone())
234                })
235                .or_else(|| node.resolved_ref.commit.as_ref().map(ToString::to_string))
236                .unwrap_or_else(|| "path".to_string());
237            let parents: Vec<_> = resolved
238                .graph
239                .nodes
240                .values()
241                .filter(|candidate| candidate.deps.contains(source_name))
242                .map(|candidate| candidate.source_name.to_string())
243                .collect();
244            let (guidance, suggested_command) = match &request.mutation {
245                Some(ConfigMutation::RemoveDependency { name })
246                    if name == source_name && !parents.is_empty() =>
247                {
248                    (
249                        format!(
250                            "removed direct dependency `{name}`, but `{source_name}` is still required by {} and remains legacy; override it",
251                            parents.join(", ")
252                        ),
253                        format!("mars override {source_name} --path <path>"),
254                    )
255                }
256                None if matches!(request.resolution, ResolutionMode::Maximize { .. }) => (
257                    format!(
258                        "newest available `{source_name}@{version}` still uses the removed hook schema; override or remove it"
259                    ),
260                    format!(
261                        "mars override {source_name} --path <path> or mars remove {source_name}"
262                    ),
263                ),
264                None if request.options.force => (
265                    format!(
266                        "cannot repair while `{source_name}@{version}` uses the removed hook schema; upgrade, override, or remove it"
267                    ),
268                    format!("mars upgrade {source_name}"),
269                ),
270                _ => (
271                    format!(
272                        "`{source_name}@{version}` still uses the removed hook schema; upgrade, override, or remove it"
273                    ),
274                    format!("mars upgrade {source_name}"),
275                ),
276            };
277            RecoveryBlocker {
278                package: source_name.to_string(),
279                version,
280                hook_names: hook_names.iter().cloned().collect(),
281                guidance,
282                suggested_command,
283            }
284        })
285        .collect();
286    RecoveryHalt {
287        persisted,
288        blockers,
289        next_step: format!("then run `{}`", managed_cmd("mars sync")),
290    }
291}
292
293// ---------------------------------------------------------------------------
294// Phase functions
295// ---------------------------------------------------------------------------
296
297/// Phase 1: Acquire sync lock, load config, apply mutations, merge effective config,
298/// and load the existing lock file.
299pub(crate) fn load_config(
300    ctx: &MarsContext,
301    request: &SyncRequest,
302    diag: &mut DiagnosticCollector,
303) -> Result<LoadedConfig, MarsError> {
304    let project_root = &ctx.project_root;
305    let mars_dir = project_root.join(".mars");
306
307    std::fs::create_dir_all(mars_dir.join("cache"))?;
308
309    // Acquire sync lock before any config reads/mutations.
310    let lock_path = mars_dir.join("sync.lock");
311    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
312
313    // Load config under lock (auto-init when mutating and missing).
314    let mut config = match crate::config::load(project_root) {
315        Ok(config) => config,
316        Err(err) if mutation::is_config_not_found(&err) && request.mutation.is_some() => Config {
317            settings: Settings::default(),
318            ..Config::default()
319        },
320        Err(err) => return Err(err),
321    };
322
323    // Apply config mutation.
324    let dependency_changes = if let Some(m) = &request.mutation {
325        mutation::apply_mutation(&mut config, m)?
326    } else {
327        Vec::new()
328    };
329
330    // Load/mutate local overrides under the same lock.
331    let mut local = crate::config::load_local(project_root)?;
332    if let Some(m) = &request.mutation {
333        mutation::apply_local_mutation(&mut local, m);
334    }
335
336    // Build effective config.
337    let (effective, config_diagnostics) =
338        crate::config::merge_with_root(config.clone(), local.clone(), project_root)?;
339    diag.extend(config_diagnostics);
340
341    // Load existing lock file, routing load diagnostics through sync diagnostics.
342    let (old_lock, lock_diagnostics) = match crate::lock::load_with_diagnostics(project_root) {
343        Ok(loaded) => loaded,
344        Err(MarsError::Lock(crate::error::LockError::Corrupt { message }))
345            if request.recovery == RecoveryPolicy::Repair =>
346        {
347            diag.warn(
348                "corrupt-lock-rebuild",
349                format!("{message}; lock is corrupt, rebuilding from mars.toml + dependencies"),
350            );
351            (LockFile::empty(), Vec::new())
352        }
353        Err(err) => return Err(err),
354    };
355    diag.extend(lock_diagnostics);
356
357    Ok(LoadedConfig {
358        config,
359        local,
360        effective,
361        old_lock,
362        dependency_changes,
363        sync_lock: _sync_lock,
364    })
365}
366
367/// Phase 2: Validate upgrade targets, resolve the dependency graph.
368pub(crate) fn resolve_graph(
369    ctx: &MarsContext,
370    mut loaded: LoadedConfig,
371    request: &SyncRequest,
372    diag: &mut DiagnosticCollector,
373) -> Result<ResolvedState, MarsError> {
374    validate_targets(&request.resolution, &loaded.effective)?;
375
376    let cache = GlobalCache::new()?;
377    let source_provider = provider::RealSourceProvider::new(&cache, &ctx.project_root);
378    let source_overrides = loaded
379        .local
380        .overrides
381        .iter()
382        .map(|(name, entry)| {
383            let path = if entry.path.is_absolute() {
384                entry.path.clone()
385            } else {
386                ctx.project_root.join(&entry.path)
387            };
388            (name.clone(), path)
389        })
390        .collect();
391    let resolve_options = to_resolve_options(&request.resolution, request.options.frozen)
392        .with_staging_root(ctx.project_root.join(".mars/staging"))
393        .with_source_overrides(source_overrides);
394    let graph = crate::resolve::resolve(
395        &loaded.effective,
396        &source_provider,
397        Some(&loaded.old_lock),
398        &resolve_options,
399        diag,
400    )?;
401    if let Some(ConfigMutation::SetOverride { source_name, .. }) = &request.mutation
402        && !graph.nodes.contains_key(source_name)
403    {
404        return Err(MarsError::Source {
405            source_name: source_name.to_string(),
406            message: format!("dependency `{source_name}` not found in the resolved project graph"),
407        });
408    }
409    for override_name in loaded.local.overrides.keys() {
410        if !graph.nodes.contains_key(override_name) {
411            diag.warn(
412                "override-missing-dep",
413                format!(
414                    "override `{override_name}` references a dependency not in the resolved project graph"
415                ),
416            );
417        }
418    }
419    let upgrades_available = if request.options.frozen || !request.options.check_upgrades {
420        0
421    } else {
422        upgrades::count_compatible_upgrades(&graph, &source_provider, diag)
423    };
424
425    let bump_entries = planned_bump_entries(&loaded.config, &graph, &request.resolution);
426    if !bump_entries.is_empty() {
427        let bump_changes = mutation::apply_mutation(
428            &mut loaded.config,
429            &ConfigMutation::BatchUpsert(bump_entries),
430        )?;
431        loaded.dependency_changes.extend(bump_changes);
432    }
433
434    // Merge model config from dependency tree (for diagnostics side effects).
435    let _ = crate::models::merged_model_aliases(
436        &graph,
437        &loaded.effective,
438        &loaded.config,
439        &loaded.local,
440        diag,
441    );
442
443    Ok(ResolvedState {
444        loaded,
445        graph,
446        upgrades_available,
447    })
448}
449
450/// Phase 3: Build target state, handle collisions, rewrite frontmatter refs, validate.
451///
452/// `local_items` are pre-discovered by the reader stage; no discovery is
453/// performed here so that dest-path assignment remains the only compiler
454/// concern for local content.
455pub(crate) fn build_target(
456    ctx: &MarsContext,
457    resolved: ResolvedState,
458    local_items: Vec<crate::local_source::LocalDiscoveredItem>,
459    request: &SyncRequest,
460    diag: &mut DiagnosticCollector,
461) -> Result<TargetedState, MarsError> {
462    // Use .mars/ as the canonical content root for diff/collision checks.
463    let mars_dir = ctx.project_root.join(".mars");
464    let managed_root = &mars_dir;
465
466    // Build target state from resolved graph.
467    let (mut target_state, renames, collision_renames) =
468        target::build_with_collisions_and_diag(&resolved.graph, &resolved.loaded.effective, diag)?;
469
470    let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
471    let old_lock_index = LockIndex::new(&resolved.loaded.old_lock);
472
473    for item in local_items {
474        // Hook config and materialization are both discovered from project-root
475        // `hooks/`; treating `.mars-src` hook directories as ordinary canonical
476        // items would bypass per-target identity.
477        if item.discovered.id.kind == ItemKind::Hook {
478            continue;
479        }
480        let staging_root = ctx.project_root.join(".mars/staging");
481        let item_key = format!("{}:{}", item.discovered.id.kind, item.discovered.id.name);
482        let staged_path = crate::staging::stage_local_item(
483            &item.disk_path(),
484            item.discovered.id.kind,
485            crate::dialect::Dialect::resolve_local(None, &item.root),
486            &resolved.loaded.effective.skills,
487            &staging_root,
488            &item_key,
489            (item.discovered.id.kind == ItemKind::Skill).then(|| item.discovered.id.name.as_str()),
490            diag,
491        )?;
492        let source_path = staged_path;
493        let is_flat_skill = item.discovered.id.kind == ItemKind::Skill
494            && item.discovered.source_path == Path::new(".");
495        let source_hash = if is_flat_skill {
496            ContentHash::from(hash::compute_skill_hash_filtered(
497                &source_path,
498                crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
499            )?)
500        } else {
501            ContentHash::from(hash::compute_hash(&source_path, item.discovered.id.kind)?)
502        };
503        if item.discovered.id.kind == ItemKind::Agent
504            && let Err(message) =
505                crate::target::validate_agent_filename(item.discovered.id.name.as_str())
506        {
507            diag.error_with_category(
508                "invalid-agent-filename",
509                format!("{message}; skipping local agent"),
510                crate::diagnostic::DiagnosticCategory::Validation,
511            );
512            continue;
513        }
514        let dest_path =
515            default_dest_path(item.discovered.id.kind, item.discovered.id.name.as_str());
516
517        if let Some(existing) = target_state.items.shift_remove(&dest_path)
518            && existing.source_hash != source_hash
519        {
520            diag.warn(
521                "local-shadow",
522                format!(
523                    "local {} `{}` shadows dependency `{}` {} `{}`",
524                    item.discovered.id.kind,
525                    item.discovered.id.name,
526                    existing.source_name,
527                    existing.id.kind,
528                    existing.id.name
529                ),
530            );
531        }
532
533        let disk_path = dest_path.resolve(managed_root);
534        if !old_lock_index.contains_installed_output(CANONICAL_TARGET_ROOT, &dest_path)
535            && disk_path.symlink_metadata().is_ok()
536        {
537            diag.warn(
538                "unmanaged-collision",
539                format!(
540                    "local {} `{}` collides with unmanaged path `{}` — leaving existing content untouched",
541                    item.discovered.id.kind, item.discovered.id.name, dest_path
542                ),
543            );
544            continue;
545        }
546
547        target_state.items.insert(
548            dest_path.clone(),
549            TargetItem {
550                id: ItemId {
551                    kind: item.discovered.id.kind,
552                    name: item.discovered.id.name.clone(),
553                },
554                source_name: local_source_name.clone(),
555                source_path,
556                dest_path,
557                source_hash,
558                is_flat_skill,
559                rewritten_content: None,
560            },
561        );
562    }
563
564    // Project-root hooks are authored outside `.mars-src`; materialize each whole
565    // directory into the canonical store so target sync can use normal item ownership.
566    for hook in crate::compiler::hooks::discover_hook_items(&ctx.project_root, "_self", 0, 0)? {
567        let source_path = hook.hook_dir.clone();
568        let source_hash = ContentHash::from(hash::compute_hash(&source_path, ItemKind::Hook)?);
569        for target_name in hook.def.targets.keys().filter(|target| {
570            resolved
571                .loaded
572                .effective
573                .settings
574                .managed_targets()
575                .contains(target)
576        }) {
577            let dest_path = target::hook_canonical_dest_path(target_name, &hook.def.name);
578            if let Some(existing) = target_state.items.shift_remove(&dest_path)
579                && existing.source_hash != source_hash
580            {
581                diag.warn(
582                    "local-shadow",
583                    format!(
584                        "local hook `{}` shadows dependency `{}` hook on target `{target_name}`",
585                        hook.def.name, existing.source_name
586                    ),
587                );
588            }
589            let disk_path = dest_path.resolve(managed_root);
590            if !old_lock_index.contains_installed_output(CANONICAL_TARGET_ROOT, &dest_path)
591                && disk_path.symlink_metadata().is_ok()
592            {
593                diag.warn("unmanaged-collision", format!("local hook `{}` collides with unmanaged path `{dest_path}` — leaving existing content untouched", hook.def.name));
594                continue;
595            }
596            target_state.items.insert(
597                dest_path.clone(),
598                TargetItem {
599                    id: ItemId {
600                        kind: ItemKind::Hook,
601                        name: format!("{}@{}", hook.def.name, target_name.trim_start_matches('.'))
602                            .into(),
603                    },
604                    source_name: local_source_name.clone(),
605                    source_path: source_path.clone(),
606                    dest_path,
607                    source_hash: source_hash.clone(),
608                    is_flat_skill: false,
609                    rewritten_content: None,
610                },
611            );
612        }
613    }
614
615    // Prevent managed installs from overwriting unmanaged files.
616    let unmanaged_collisions = target::check_unmanaged_collisions(
617        managed_root,
618        &resolved.loaded.old_lock,
619        &target_state,
620        request.options.force,
621    );
622    for collision in &unmanaged_collisions {
623        diag.warn(
624            "unmanaged-collision",
625            format!(
626                "source `{}` collides with unmanaged path `{}` — leaving existing content untouched",
627                collision.source_name, collision.path
628            ),
629        );
630        target_state.items.shift_remove(&collision.path);
631    }
632
633    // Rewrite frontmatter refs against the post-prune target state.
634    let rename_index = rewrite::RenameIndex::new(&renames, &collision_renames, &target_state);
635    if !rename_index.is_empty() {
636        let dep_precedence: Vec<SourceName> = resolved
637            .loaded
638            .effective
639            .dependencies
640            .keys()
641            .cloned()
642            .collect();
643        let rewrite_warnings = rewrite::apply_renames(
644            &mut target_state,
645            &rename_index,
646            &resolved.graph,
647            &dep_precedence,
648        )?;
649        for w in &rewrite_warnings {
650            diag.warn("rewrite-warning", w.to_string());
651        }
652    }
653
654    validate::warn_config_dangles_after_rename(
655        &renames,
656        &collision_renames,
657        &target_state,
658        &resolved.loaded,
659        diag,
660    );
661
662    validate::validate_skill_frontmatter_in_target(&target_state, diag);
663
664    // Validate skill references.
665    let warnings = validate::validate_skill_refs(&target_state);
666
667    Ok(TargetedState {
668        resolved,
669        target: target_state,
670        warnings,
671    })
672}
673
674/// Phase 4: Compute diff, create plan.
675pub(crate) fn create_plan(
676    ctx: &MarsContext,
677    targeted: TargetedState,
678    request: &SyncRequest,
679    diag: &mut DiagnosticCollector,
680) -> Result<PlannedState, MarsError> {
681    // Diff against .mars/ canonical store.
682    let mars_dir = ctx.project_root.join(".mars");
683    let managed_root = &mars_dir;
684
685    // Compute diff.
686    let sync_diff = diff::compute(
687        managed_root,
688        &targeted.resolved.loaded.old_lock,
689        &targeted.target,
690        request.options.force,
691    )?;
692
693    if !request.options.force {
694        for entry in &sync_diff.items {
695            if let diff::DiffEntry::LocalModified { target, .. } = entry {
696                diag.warn(
697                    "disk-lock-divergent",
698                    format!(
699                        "{} diverged from mars.lock checksum; preserving local content (run `{cmd1}` or `{cmd2}` to reset)",
700                        target.dest_path,
701                        cmd1 = managed_cmd("mars sync --force"),
702                        cmd2 = managed_cmd("mars repair"),
703                    ),
704                );
705            }
706        }
707    }
708
709    // Create plan.
710    let sync_plan = plan::create(&sync_diff, &request.options, diag);
711
712    Ok(PlannedState {
713        targeted,
714        plan: sync_plan,
715    })
716}
717
718/// Check that a frozen sync has no pending changes.
719pub(crate) fn check_frozen_gate(planned: &PlannedState) -> Result<(), MarsError> {
720    let has_changes = planned.plan.actions.iter().any(|a| {
721        !matches!(
722            a,
723            plan::PlannedAction::Skip { .. } | plan::PlannedAction::KeepLocal { .. }
724        )
725    });
726    if has_changes {
727        return Err(MarsError::FrozenViolation {
728            message: "lock file would change but --frozen is set".into(),
729        });
730    }
731    Ok(())
732}
733
734/// Phase 5: Persist config if mutated, apply plan to .mars/ canonical store.
735pub(crate) fn apply_plan(
736    ctx: &MarsContext,
737    planned: PlannedState,
738    request: &SyncRequest,
739) -> Result<AppliedState, MarsError> {
740    let project_root = &ctx.project_root;
741    let mars_dir = project_root.join(".mars");
742
743    // Persist config/local only after validation gate and before apply.
744    persist_pending_config_mutation(ctx, &planned.targeted.resolved.loaded, request)?;
745
746    // Apply plan to .mars/ canonical store (D25).
747    // Content is written to .mars/agents/ and .mars/skills/, then
748    // sync_targets() copies to all managed target directories.
749    let applied = apply::execute(&mars_dir, &planned.plan, &request.options)?;
750
751    Ok(AppliedState { planned, applied })
752}
753
754fn has_bump_version_changes(loaded: &LoadedConfig, request: &SyncRequest) -> bool {
755    has_version_changes(&loaded.dependency_changes)
756        && matches!(
757            request.resolution,
758            ResolutionMode::Maximize { bump: true, .. }
759        )
760}
761
762/// Persist only the user's pending intent mutation. This is shared by the
763/// normal apply phase and the recovery halt at the reader/compiler boundary.
764fn persist_pending_config_mutation(
765    ctx: &MarsContext,
766    loaded: &LoadedConfig,
767    request: &SyncRequest,
768) -> Result<(), MarsError> {
769    if request.options.dry_run {
770        return Ok(());
771    }
772    let bump_changed = has_bump_version_changes(loaded, request);
773    match &request.mutation {
774        Some(ConfigMutation::SetOverride { .. }) => {
775            crate::config::save_local(&ctx.project_root, &loaded.local)?;
776        }
777        Some(
778            ConfigMutation::UpsertDependency { .. }
779            | ConfigMutation::BatchUpsert(..)
780            | ConfigMutation::RemoveDependency { .. }
781            | ConfigMutation::SetRename { .. },
782        ) => {
783            crate::config::save(&ctx.project_root, &loaded.config)?;
784        }
785        None if bump_changed => {
786            crate::config::save(&ctx.project_root, &loaded.config)?;
787        }
788        None => {}
789    }
790    Ok(())
791}
792
793fn persisted_intent_descriptions(resolved: &ResolvedState, request: &SyncRequest) -> Vec<String> {
794    let dry_prefix = if request.options.dry_run {
795        "would persist"
796    } else {
797        "persisted"
798    };
799    match &request.mutation {
800        Some(ConfigMutation::SetOverride {
801            source_name,
802            local_path,
803        }) => vec![format!(
804            "{dry_prefix} override for `{source_name}` to `{}` in mars.local.toml",
805            local_path.display()
806        )],
807        Some(ConfigMutation::RemoveDependency { name }) => {
808            vec![format!(
809                "{dry_prefix} removal of direct dependency `{name}` in mars.toml"
810            )]
811        }
812        Some(_) => vec![format!("{dry_prefix} config mutation")],
813        None if has_bump_version_changes(&resolved.loaded, request) => resolved
814            .loaded
815            .dependency_changes
816            .iter()
817            .filter(|change| change.old_version != change.new_version)
818            .map(|change| {
819                format!(
820                    "{dry_prefix} bumped constraint for `{}` to `{}` in mars.toml",
821                    change.name,
822                    change.new_version.as_deref().unwrap_or("latest")
823                )
824            })
825            .collect(),
826        None => vec!["nothing persisted".to_string()],
827    }
828}
829
830/// Phase 6: Sync managed targets from .mars/ canonical store.
831///
832/// Copies content from .mars/ to all configured target directories.
833/// Non-fatal — target sync errors are recorded as diagnostics.
834/// Lock is written regardless of target sync outcome (D21).
835pub(crate) fn sync_targets(
836    ctx: &MarsContext,
837    applied: AppliedState,
838    request: &SyncRequest,
839    agent_surface_policy: crate::compiler::AgentSurfacePolicy,
840    diag: &mut DiagnosticCollector,
841) -> SyncedState {
842    if request.options.dry_run {
843        return SyncedState {
844            applied,
845            target_outcomes: Vec::new(),
846            config_entries: BTreeMap::new(),
847            config_entry_outputs: Vec::new(),
848            removed_config_entry_outputs: Vec::new(),
849            compiled_native_outputs: Vec::new(),
850            removed_native_outputs: Vec::new(),
851        };
852    }
853
854    let mars_dir = ctx.project_root.join(".mars");
855    let targets = applied
856        .planned
857        .targeted
858        .resolved
859        .loaded
860        .effective
861        .settings
862        .managed_targets();
863    let old_lock = &applied.planned.targeted.resolved.loaded.old_lock;
864
865    let filtered_outcomes;
866    let target_outcomes_source = match &agent_surface_policy {
867        crate::compiler::AgentSurfacePolicy::SuppressAll => {
868            filtered_outcomes = crate::compiler::suppress_agent_outcomes(&applied.applied.outcomes);
869            &filtered_outcomes
870        }
871        crate::compiler::AgentSurfacePolicy::EmitSelective(_) => {
872            filtered_outcomes = crate::compiler::omit_agent_outcomes(&applied.applied.outcomes);
873            &filtered_outcomes
874        }
875        crate::compiler::AgentSurfacePolicy::EmitAll => &applied.applied.outcomes,
876    };
877    let mut orphan_preserve_paths =
878        crate::compiler::config_entries::file_hook_output_preserve_paths(old_lock);
879    for (target, paths) in crate::compiler::native_agent_orphan_preserve_paths(old_lock, &targets) {
880        orphan_preserve_paths
881            .entry(target)
882            .or_default()
883            .extend(paths);
884    }
885    let orphan_preserve = (!orphan_preserve_paths.is_empty()).then_some(&orphan_preserve_paths);
886
887    let target_sync_ctx = crate::target_sync::TargetSyncContext {
888        old_lock,
889        force: request.options.force,
890        collision_hint: crate::surface_ownership::CollisionAdoptHint::SyncForce,
891        orphan_preserve_paths: orphan_preserve,
892    };
893    let target_outcomes = crate::target_sync::sync_managed_targets(
894        &ctx.project_root,
895        &mars_dir,
896        &targets,
897        target_outcomes_source,
898        &target_sync_ctx,
899        diag,
900    );
901
902    SyncedState {
903        applied,
904        target_outcomes,
905        config_entries: BTreeMap::new(),
906        config_entry_outputs: Vec::new(),
907        removed_config_entry_outputs: Vec::new(),
908        compiled_native_outputs: Vec::new(),
909        removed_native_outputs: Vec::new(),
910    }
911}
912
913/// Phase 7: Write lock file, construct SyncReport.
914///
915/// Lock is written regardless of target sync outcome (D21).
916pub(crate) fn finalize(
917    ctx: &MarsContext,
918    state: SyncedState,
919    request: &SyncRequest,
920    diag: &mut DiagnosticCollector,
921) -> Result<SyncReport, MarsError> {
922    let project_root = &ctx.project_root;
923    let old_lock = &state.applied.planned.targeted.resolved.loaded.old_lock;
924    let graph = &state.applied.planned.targeted.resolved.graph;
925    // Native-agent surface deltas for the summary: removals are unambiguous; emits
926    // are filtered to new/changed outputs so steady-state re-emits stay quiet.
927    let native_removed: Vec<(String, String)> = state.removed_native_outputs.clone();
928    let native_emitted: Vec<(String, String)> = state
929        .compiled_native_outputs
930        .iter()
931        .filter(|out| crate::lock::native_output_is_new_or_changed(old_lock, out))
932        .map(|out| (out.target_root.clone(), out.dest_path.clone()))
933        .collect();
934
935    // Write lock file (D21 — regardless of target sync outcome).
936    if !request.options.dry_run {
937        let dep_models = crate::models::declaration_ordered_dep_models(
938            graph,
939            &state.applied.planned.targeted.resolved.loaded.effective,
940        );
941        let mut dep_model_aliases = crate::models::dependency_alias_snapshot(&dep_models);
942        dep_model_aliases.sort_keys();
943
944        let mut new_lock = crate::lock::build(
945            graph,
946            &state.applied.applied,
947            old_lock,
948            state.config_entries,
949        )?;
950        new_lock.dependency_model_aliases = dep_model_aliases;
951        let mut confirmed_output_removals: Vec<(String, String)> = state
952            .target_outcomes
953            .iter()
954            .flat_map(|outcome| {
955                outcome
956                    .removed_dest_paths
957                    .iter()
958                    .map(|dest_path| (outcome.target.clone(), dest_path.clone()))
959            })
960            .collect();
961        confirmed_output_removals.extend(state.removed_config_entry_outputs.iter().cloned());
962        confirmed_output_removals.extend(state.removed_native_outputs.iter().cloned());
963        crate::lock::apply_target_sync_outputs(&mut new_lock, &state.target_outcomes);
964        crate::lock::apply_removed_native_outputs(
965            &mut new_lock,
966            &state.removed_config_entry_outputs,
967        );
968        crate::lock::apply_compiled_native_outputs(&mut new_lock, &state.config_entry_outputs)?;
969        crate::lock::apply_removed_native_outputs(&mut new_lock, &state.removed_native_outputs);
970        crate::lock::apply_compiled_native_outputs(&mut new_lock, &state.compiled_native_outputs)?;
971        confirmed_output_removals.extend(retry_tombstone_removals(
972            project_root,
973            old_lock,
974            &new_lock,
975            diag,
976        ));
977        crate::lock::retain_unremoved_noncanonical_outputs(
978            &mut new_lock,
979            old_lock,
980            &confirmed_output_removals,
981        );
982        if let Some(warning) =
983            crate::compiler::persist_lock_then_native_agent_manifest(project_root, &new_lock)?
984        {
985            diag.warn("native-agent-manifest-write", warning);
986        }
987
988        // Best-effort models cache refresh: ensure the catalog covers any
989        // new aliases we're about to persist. Sync never aborts on refresh
990        // failure — warn and continue.
991        let mars_path = ctx.project_root.join(".mars");
992        let ttl = state
993            .applied
994            .planned
995            .targeted
996            .resolved
997            .loaded
998            .effective
999            .settings
1000            .models_cache_ttl_hours;
1001        let refresh = crate::models::resolve_models_refresh_control(
1002            request.options.refresh_models,
1003            request.options.no_refresh_models,
1004        )?;
1005        match crate::models::ensure_fresh(&mars_path, ttl, refresh.catalog_mode) {
1006            Ok((_, crate::models::RefreshOutcome::StaleFallback { reason })) => {
1007                diag.warn(
1008                    "models-cache-refresh",
1009                    format!("using stale models cache: {reason}"),
1010                );
1011            }
1012            Ok((_, crate::models::RefreshOutcome::Offline)) => {}
1013            Ok(_) => {}
1014            Err(err) => {
1015                diag.warn(
1016                    "models-cache-refresh",
1017                    format!("failed to refresh models cache: {err}"),
1018                );
1019            }
1020        }
1021    }
1022
1023    for w in &state.applied.planned.targeted.warnings {
1024        match w {
1025            ValidationWarning::MissingSkill {
1026                agent,
1027                skill_name,
1028                suggestion,
1029            } => {
1030                let msg = match suggestion {
1031                    Some(s) => format!(
1032                        "agent `{}` references missing skill `{}` (did you mean `{}`?)",
1033                        agent.name, skill_name, s
1034                    ),
1035                    None => {
1036                        format!(
1037                            "agent `{}` references missing skill `{}`",
1038                            agent.name, skill_name
1039                        )
1040                    }
1041                };
1042                diag.warn("missing-skill", msg);
1043            }
1044        }
1045    }
1046    let dependency_changes = state
1047        .applied
1048        .planned
1049        .targeted
1050        .resolved
1051        .loaded
1052        .dependency_changes;
1053    let upgrades_available = state.applied.planned.targeted.resolved.upgrades_available;
1054
1055    let diagnostics = diag.drain();
1056
1057    Ok(SyncReport {
1058        applied: state.applied.applied,
1059        diagnostics,
1060        dependency_changes,
1061        upgrades_available,
1062        target_outcomes: state.target_outcomes,
1063        dry_run: request.options.dry_run,
1064        native_emitted,
1065        native_removed,
1066        recovery_halt: None,
1067    })
1068}
1069
1070fn retry_tombstone_removals(
1071    project_root: &Path,
1072    old_lock: &crate::lock::LockFile,
1073    current_lock: &crate::lock::LockFile,
1074    diag: &mut DiagnosticCollector,
1075) -> Vec<(String, String)> {
1076    let mut removed = Vec::new();
1077    for item in old_lock.items.values().filter(|item| {
1078        !item
1079            .outputs
1080            .iter()
1081            .any(|output| output.target_root == crate::lock::CANONICAL_TARGET_ROOT)
1082    }) {
1083        for output in &item.outputs {
1084            let current_pass_owns_output = current_lock.items.values().any(|current_item| {
1085                current_item.outputs.iter().any(|current_output| {
1086                    current_output.target_root == crate::lock::CANONICAL_TARGET_ROOT
1087                }) && current_item.outputs.iter().any(|current_output| {
1088                    current_output.target_root == output.target_root
1089                        && crate::target::dest_paths_equivalent(
1090                            current_output.dest_path.as_str(),
1091                            output.dest_path.as_str(),
1092                        )
1093                })
1094            });
1095            if output.target_root == crate::lock::CANONICAL_TARGET_ROOT || current_pass_owns_output
1096            {
1097                continue;
1098            }
1099
1100            let path = project_root
1101                .join(&output.target_root)
1102                .join(output.dest_path.as_str());
1103            let result = if matches!(item.kind, ItemKind::Agent)
1104                || (matches!(item.kind, ItemKind::Hook)
1105                    && !output.dest_path.as_str().starts_with("hooks/"))
1106            {
1107                match std::fs::remove_file(&path) {
1108                    Ok(()) => Ok(()),
1109                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1110                    Err(error) => Err(error.into()),
1111                }
1112            } else {
1113                crate::platform::fs::safe_remove(&path)
1114            };
1115
1116            match result {
1117                Ok(()) => removed.push((
1118                    output.target_root.clone(),
1119                    output.dest_path.as_str().to_string(),
1120                )),
1121                Err(error) => diag.warn(
1122                    "tombstone-remove",
1123                    format!(
1124                        "could not remove tombstoned output `{}`: {error}",
1125                        path.display()
1126                    ),
1127                ),
1128            }
1129        }
1130    }
1131    removed
1132}
1133
1134fn default_dest_path(kind: ItemKind, name: &str) -> DestPath {
1135    match kind {
1136        ItemKind::Agent => DestPath::from(format!("agents/{name}.md")),
1137        ItemKind::Skill => DestPath::from(format!("skills/{name}")),
1138        ItemKind::Hook => DestPath::from(format!("hooks/{name}")),
1139        ItemKind::McpServer => DestPath::from(format!("mcp/{name}")),
1140        ItemKind::BootstrapDoc => DestPath::from(format!("bootstrap/{name}/BOOTSTRAP.md")),
1141    }
1142}
1143
1144fn validate_request(request: &SyncRequest) -> Result<(), MarsError> {
1145    if request.options.frozen && matches!(request.resolution, ResolutionMode::Maximize { .. }) {
1146        return Err(MarsError::InvalidRequest {
1147            message:
1148                "cannot use --frozen with upgrade (frozen locks versions; upgrade maximizes them)"
1149                    .to_string(),
1150        });
1151    }
1152
1153    if request.options.frozen && request.mutation.is_some() {
1154        return Err(MarsError::InvalidRequest {
1155            message:
1156                "cannot modify config in --frozen mode (config change would require lock update)"
1157                    .to_string(),
1158        });
1159    }
1160
1161    Ok(())
1162}
1163
1164fn validate_targets(
1165    resolution: &ResolutionMode,
1166    effective: &EffectiveConfig,
1167) -> Result<(), MarsError> {
1168    if let ResolutionMode::Maximize { targets, .. } = resolution {
1169        for name in targets {
1170            if !effective.dependencies.contains_key(name) {
1171                return Err(MarsError::Source {
1172                    source_name: name.to_string(),
1173                    message: format!("dependency `{name}` not found in mars.toml"),
1174                });
1175            }
1176        }
1177    }
1178
1179    Ok(())
1180}
1181
1182fn to_resolve_options(mode: &ResolutionMode, frozen: bool) -> ResolveOptions {
1183    if frozen {
1184        return ResolveOptions::frozen();
1185    }
1186
1187    match mode {
1188        ResolutionMode::Normal => ResolveOptions::sync(),
1189        ResolutionMode::Maximize { targets, bump } => {
1190            ResolveOptions::upgrade(targets.clone(), *bump)
1191        }
1192    }
1193}
1194
1195fn planned_bump_entries(
1196    config: &Config,
1197    graph: &ResolvedGraph,
1198    mode: &ResolutionMode,
1199) -> Vec<(SourceName, crate::config::DependencyEntry)> {
1200    let ResolutionMode::Maximize {
1201        targets,
1202        bump: true,
1203    } = mode
1204    else {
1205        return Vec::new();
1206    };
1207
1208    config
1209        .dependencies
1210        .iter()
1211        .filter_map(|(name, entry)| {
1212            if !targets.is_empty() && !targets.contains(name) {
1213                return None;
1214            }
1215            // Only git dependencies with semver-tagged resolution can be bumped.
1216            entry.url.as_ref()?;
1217            let node = graph.nodes.get(name)?;
1218            let resolved_version = node.resolved_ref.version.as_ref()?;
1219            let resolved_tag = node.resolved_ref.version_tag.as_ref()?;
1220            if !constraint_needs_bump(entry.version.as_deref(), resolved_version) {
1221                return None;
1222            }
1223            if entry.version.as_deref() == Some(resolved_tag.as_str()) {
1224                return None;
1225            }
1226            let mut bumped = entry.clone();
1227            bumped.version = Some(resolved_tag.clone());
1228            Some((name.clone(), bumped))
1229        })
1230        .collect()
1231}
1232
1233fn constraint_needs_bump(current: Option<&str>, resolved: &semver::Version) -> bool {
1234    match crate::resolve::parse_version_constraint(current) {
1235        crate::resolve::VersionConstraint::Semver(req) => !req.matches(resolved),
1236        crate::resolve::VersionConstraint::Latest
1237        | crate::resolve::VersionConstraint::RefPin(_) => false,
1238    }
1239}
1240
1241fn has_version_changes(changes: &[DependencyUpsertChange]) -> bool {
1242    changes
1243        .iter()
1244        .any(|change| change.old_version != change.new_version)
1245}
1246
1247#[cfg(test)]
1248mod tests;