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