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, SourceId, SourceName, SourceOrigin};
31use crate::validate::ValidationWarning;
32
33// Re-export mutation types for public API compatibility.
34pub use crate::sync::mutation::{ConfigMutation, DependencyUpsertChange, apply_config_mutation};
35
36/// Report from a completed sync operation.
37#[derive(Debug)]
38pub struct SyncReport {
39    pub applied: ApplyResult,
40    pub pruned: Vec<apply::ActionOutcome>,
41    pub diagnostics: Vec<Diagnostic>,
42    pub dependency_changes: Vec<DependencyUpsertChange>,
43    pub upgrades_available: usize,
44    /// Per-target sync outcomes from the target sync phase.
45    pub target_outcomes: Vec<crate::target_sync::TargetSyncOutcome>,
46    /// Whether this was a dry run (`--diff`). Affects output wording only.
47    pub dry_run: bool,
48    /// Native harness agent outputs emitted this run that are new or content-changed
49    /// vs the previous lock, as `(target_root, dest_path)`. Surfaced so native
50    /// emission is not silent in the summary.
51    pub native_emitted: Vec<(String, String)>,
52    /// Native harness agent outputs removed this run, as `(target_root, dest_path)`.
53    /// Surfaced so SuppressAll / selective prunes are not reported as "up to date".
54    pub native_removed: Vec<(String, String)>,
55}
56
57impl SyncReport {
58    /// Whether the sync produced any unresolved conflicts.
59    pub fn has_conflicts(&self) -> bool {
60        self.applied
61            .outcomes
62            .iter()
63            .any(|o| matches!(o.action, apply::ActionTaken::Conflicted))
64    }
65}
66
67/// What a CLI command requests from the sync pipeline.
68#[derive(Debug, Clone)]
69pub struct SyncRequest {
70    /// How to resolve versions.
71    pub resolution: ResolutionMode,
72    /// Config mutation to apply under flock.
73    pub mutation: Option<ConfigMutation>,
74    /// Behavior flags.
75    pub options: SyncOptions,
76    /// Whether lossiness warnings are included in the returned report.
77    /// `Surface` for `mars sync` / `mars upgrade`; `Hidden` for validate/export/add/repair.
78    pub lossiness_mode: LossinessMode,
79}
80
81/// Resolution behavior for the resolver stage.
82#[derive(Debug, Clone)]
83pub enum ResolutionMode {
84    /// Normal sync behavior.
85    Normal,
86    /// Upgrade behavior (maximize versions), optionally scoped to specific
87    /// sources and optionally bumping direct constraints.
88    Maximize {
89        targets: HashSet<SourceName>,
90        bump: bool,
91    },
92}
93
94// ---------------------------------------------------------------------------
95// Pipeline phase structs — typed handoffs between pipeline stages.
96// Phase functions consume prior state by value (move semantics, no cloning).
97// ---------------------------------------------------------------------------
98
99/// Phase 1: Load and validate configuration under sync lock.
100pub(crate) struct LoadedConfig {
101    pub config: Config,
102    pub local: LocalConfig,
103    pub effective: EffectiveConfig,
104    pub old_lock: LockFile,
105    pub dependency_changes: Vec<DependencyUpsertChange>,
106    /// Intentional keepalive — holds the sync file lock for the duration of the pipeline. Dropping this field releases the lock.
107    #[allow(dead_code)]
108    pub sync_lock: FileLock,
109}
110
111/// Phase 2: Resolved dependency graph.
112pub(crate) struct ResolvedState {
113    pub loaded: LoadedConfig,
114    pub graph: ResolvedGraph,
115    pub upgrades_available: usize,
116}
117
118/// Phase 3: Desired target state after discovery + filtering.
119pub(crate) struct TargetedState {
120    pub resolved: ResolvedState,
121    pub target: TargetState,
122    pub warnings: Vec<ValidationWarning>,
123}
124
125/// Phase 4: Diff + plan ready for execution.
126pub(crate) struct PlannedState {
127    pub targeted: TargetedState,
128    pub plan: plan::SyncPlan,
129}
130
131/// Phase 5: Applied results.
132pub(crate) struct AppliedState {
133    pub planned: PlannedState,
134    pub applied: ApplyResult,
135}
136
137/// Phase 6: Target sync results.
138pub(crate) struct SyncedState {
139    pub applied: AppliedState,
140    pub target_outcomes: Vec<crate::target_sync::TargetSyncOutcome>,
141    pub config_entries: BTreeMap<String, BTreeMap<String, crate::lock::ConfigEntryRecord>>,
142    pub compiled_native_outputs: Vec<crate::lock::CompiledNativeOutput>,
143    pub removed_native_outputs: Vec<crate::compiler::RemovedNativeOutput>,
144}
145
146/// Execute the unified sync pipeline.
147///
148/// Orchestrates phase functions, each consuming the prior phase's output struct.
149pub fn execute(ctx: &MarsContext, request: &SyncRequest) -> Result<SyncReport, MarsError> {
150    validate_request(request)?;
151    let mut diag = DiagnosticCollector::with_lossiness_mode(request.lossiness_mode);
152    let ir = crate::reader::read(ctx, request, &mut diag)?;
153    crate::compiler::compile(ctx, ir, request, &mut diag)
154}
155
156// ---------------------------------------------------------------------------
157// Phase functions
158// ---------------------------------------------------------------------------
159
160/// Phase 1: Acquire sync lock, load config, apply mutations, merge effective config,
161/// and load the existing lock file.
162pub(crate) fn load_config(
163    ctx: &MarsContext,
164    request: &SyncRequest,
165    diag: &mut DiagnosticCollector,
166) -> Result<LoadedConfig, MarsError> {
167    let project_root = &ctx.project_root;
168    let mars_dir = project_root.join(".mars");
169
170    std::fs::create_dir_all(mars_dir.join("cache"))?;
171
172    // Acquire sync lock before any config reads/mutations.
173    let lock_path = mars_dir.join("sync.lock");
174    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
175
176    // Load config under lock (auto-init when mutating and missing).
177    let mut config = match crate::config::load(project_root) {
178        Ok(config) => config,
179        Err(err) if mutation::is_config_not_found(&err) && request.mutation.is_some() => Config {
180            settings: Settings::default(),
181            ..Config::default()
182        },
183        Err(err) => return Err(err),
184    };
185
186    // Apply config mutation.
187    let dependency_changes = if let Some(m) = &request.mutation {
188        mutation::apply_mutation(&mut config, m)?
189    } else {
190        Vec::new()
191    };
192
193    // Load/mutate local overrides under the same lock.
194    let mut local = crate::config::load_local(project_root)?;
195    if let Some(m) = &request.mutation {
196        mutation::apply_local_mutation(&mut local, m);
197    }
198
199    // Build effective config.
200    let (effective, config_diagnostics) =
201        crate::config::merge_with_root(config.clone(), local.clone(), project_root)?;
202    diag.extend(config_diagnostics);
203
204    // Load existing lock file, routing legacy promotion warnings through sync diagnostics.
205    let (old_lock, lock_diagnostics) = crate::lock::load_with_diagnostics(project_root)?;
206    diag.extend(lock_diagnostics);
207
208    Ok(LoadedConfig {
209        config,
210        local,
211        effective,
212        old_lock,
213        dependency_changes,
214        sync_lock: _sync_lock,
215    })
216}
217
218/// Phase 2: Validate upgrade targets, resolve the dependency graph.
219pub(crate) fn resolve_graph(
220    ctx: &MarsContext,
221    mut loaded: LoadedConfig,
222    request: &SyncRequest,
223    diag: &mut DiagnosticCollector,
224) -> Result<ResolvedState, MarsError> {
225    validate_targets(&request.resolution, &loaded.effective)?;
226
227    let cache = GlobalCache::new()?;
228    let source_provider = provider::RealSourceProvider::new(&cache, &ctx.project_root);
229    let resolve_options = to_resolve_options(&request.resolution, request.options.frozen)
230        .with_staging_root(ctx.project_root.join(".mars/staging"));
231    let graph = crate::resolve::resolve(
232        &loaded.effective,
233        &source_provider,
234        Some(&loaded.old_lock),
235        &resolve_options,
236        diag,
237    )?;
238    let upgrades_available = if request.options.frozen || !request.options.check_upgrades {
239        0
240    } else {
241        upgrades::count_compatible_upgrades(&graph, &source_provider, diag)
242    };
243
244    let bump_entries = planned_bump_entries(&loaded.config, &graph, &request.resolution);
245    if !bump_entries.is_empty() {
246        let bump_changes = mutation::apply_mutation(
247            &mut loaded.config,
248            &ConfigMutation::BatchUpsert(bump_entries),
249        )?;
250        loaded.dependency_changes.extend(bump_changes);
251    }
252
253    // Merge model config from dependency tree (for diagnostics side effects).
254    let _ = crate::models::merged_model_aliases(
255        &graph,
256        &loaded.effective,
257        &loaded.config,
258        &loaded.local,
259        diag,
260    );
261
262    Ok(ResolvedState {
263        loaded,
264        graph,
265        upgrades_available,
266    })
267}
268
269/// Phase 3: Build target state, handle collisions, rewrite frontmatter refs, validate.
270///
271/// `local_items` are pre-discovered by the reader stage; no discovery is
272/// performed here so that dest-path assignment remains the only compiler
273/// concern for local content.
274pub(crate) fn build_target(
275    ctx: &MarsContext,
276    resolved: ResolvedState,
277    local_items: Vec<crate::local_source::LocalDiscoveredItem>,
278    request: &SyncRequest,
279    diag: &mut DiagnosticCollector,
280) -> Result<TargetedState, MarsError> {
281    // Use .mars/ as the canonical content root for diff/collision checks.
282    let mars_dir = ctx.project_root.join(".mars");
283    let managed_root = &mars_dir;
284
285    // Build target state from resolved graph.
286    let (mut target_state, renames, collision_renames) =
287        target::build_with_collisions_and_diag(&resolved.graph, &resolved.loaded.effective, diag)?;
288
289    let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
290    let local_source_id = SourceId::Path {
291        canonical: dunce::canonicalize(&ctx.project_root)
292            .unwrap_or_else(|_| ctx.project_root.clone()),
293        subpath: None,
294    };
295    let old_lock_index = LockIndex::new(&resolved.loaded.old_lock);
296
297    for item in local_items {
298        let staging_root = ctx.project_root.join(".mars/staging");
299        let item_key = format!("{}:{}", item.discovered.id.kind, item.discovered.id.name);
300        let staged_path = crate::staging::stage_local_item(
301            &item.disk_path(),
302            item.discovered.id.kind,
303            crate::dialect::Dialect::resolve_local(None, &item.root),
304            &resolved.loaded.effective.skills,
305            &staging_root,
306            &item_key,
307            (item.discovered.id.kind == ItemKind::Skill).then(|| item.discovered.id.name.as_str()),
308            diag,
309        )?;
310        let source_path = staged_path;
311        let is_flat_skill = item.discovered.id.kind == ItemKind::Skill
312            && item.discovered.source_path == Path::new(".");
313        let source_hash = if is_flat_skill {
314            ContentHash::from(hash::compute_skill_hash_filtered(
315                &source_path,
316                crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
317            )?)
318        } else {
319            ContentHash::from(hash::compute_hash(&source_path, item.discovered.id.kind)?)
320        };
321        if item.discovered.id.kind == ItemKind::Agent
322            && let Err(message) =
323                crate::target::validate_agent_filename(item.discovered.id.name.as_str())
324        {
325            diag.error_with_category(
326                "invalid-agent-filename",
327                format!("{message}; skipping local agent"),
328                crate::diagnostic::DiagnosticCategory::Validation,
329            );
330            continue;
331        }
332        let dest_path =
333            default_dest_path(item.discovered.id.kind, item.discovered.id.name.as_str());
334
335        if let Some(existing) = target_state.items.shift_remove(&dest_path)
336            && existing.source_hash != source_hash
337        {
338            diag.warn(
339                "local-shadow",
340                format!(
341                    "local {} `{}` shadows dependency `{}` {} `{}`",
342                    item.discovered.id.kind,
343                    item.discovered.id.name,
344                    existing.source_name,
345                    existing.id.kind,
346                    existing.id.name
347                ),
348            );
349        }
350
351        let disk_path = dest_path.resolve(managed_root);
352        if !old_lock_index.contains_output(CANONICAL_TARGET_ROOT, &dest_path)
353            && disk_path.symlink_metadata().is_ok()
354        {
355            diag.warn(
356                "unmanaged-collision",
357                format!(
358                    "local {} `{}` collides with unmanaged path `{}` — leaving existing content untouched",
359                    item.discovered.id.kind, item.discovered.id.name, dest_path
360                ),
361            );
362            continue;
363        }
364
365        target_state.items.insert(
366            dest_path.clone(),
367            TargetItem {
368                id: ItemId {
369                    kind: item.discovered.id.kind,
370                    name: item.discovered.id.name.clone(),
371                },
372                source_name: local_source_name.clone(),
373                origin: SourceOrigin::LocalPackage,
374                source_id: local_source_id.clone(),
375                source_path,
376                dest_path,
377                source_hash,
378                is_flat_skill,
379                rewritten_content: None,
380            },
381        );
382    }
383
384    // Prevent managed installs from overwriting unmanaged files.
385    let unmanaged_collisions = target::check_unmanaged_collisions(
386        managed_root,
387        &resolved.loaded.old_lock,
388        &target_state,
389        request.options.force,
390    );
391    for collision in &unmanaged_collisions {
392        diag.warn(
393            "unmanaged-collision",
394            format!(
395                "source `{}` collides with unmanaged path `{}` — leaving existing content untouched",
396                collision.source_name, collision.path
397            ),
398        );
399        target_state.items.shift_remove(&collision.path);
400    }
401
402    // Rewrite frontmatter refs against the post-prune target state.
403    let rename_index = rewrite::RenameIndex::new(&renames, &collision_renames, &target_state);
404    if !rename_index.is_empty() {
405        let dep_precedence: Vec<SourceName> = resolved
406            .loaded
407            .effective
408            .dependencies
409            .keys()
410            .cloned()
411            .collect();
412        let rewrite_warnings = rewrite::apply_renames(
413            &mut target_state,
414            &rename_index,
415            &resolved.graph,
416            &dep_precedence,
417        )?;
418        for w in &rewrite_warnings {
419            diag.warn("rewrite-warning", w.to_string());
420        }
421    }
422
423    validate::warn_config_dangles_after_rename(
424        &renames,
425        &collision_renames,
426        &target_state,
427        &resolved.loaded,
428        diag,
429    );
430
431    validate::validate_skill_frontmatter_in_target(&target_state, diag);
432
433    // Validate skill references.
434    let warnings = validate::validate_skill_refs(&target_state);
435
436    Ok(TargetedState {
437        resolved,
438        target: target_state,
439        warnings,
440    })
441}
442
443/// Phase 4: Compute diff, create plan.
444pub(crate) fn create_plan(
445    ctx: &MarsContext,
446    targeted: TargetedState,
447    request: &SyncRequest,
448    diag: &mut DiagnosticCollector,
449) -> Result<PlannedState, MarsError> {
450    // Diff against .mars/ canonical store.
451    let mars_dir = ctx.project_root.join(".mars");
452    let managed_root = &mars_dir;
453    let cache_bases_dir = mars_dir.join("cache").join("bases");
454
455    // Compute diff.
456    let sync_diff = diff::compute(
457        managed_root,
458        &targeted.resolved.loaded.old_lock,
459        &targeted.target,
460        request.options.force,
461    )?;
462
463    if !request.options.force {
464        for entry in &sync_diff.items {
465            if let diff::DiffEntry::LocalModified { target, .. } = entry {
466                diag.warn(
467                    "disk-lock-divergent",
468                    format!(
469                        "{} diverged from mars.lock checksum; preserving local content (run `{cmd1}` or `{cmd2}` to reset)",
470                        target.dest_path,
471                        cmd1 = managed_cmd("mars sync --force"),
472                        cmd2 = managed_cmd("mars repair"),
473                    ),
474                );
475            }
476        }
477    }
478
479    // Create plan.
480    let sync_plan = plan::create(&sync_diff, &request.options, &cache_bases_dir, diag);
481
482    Ok(PlannedState {
483        targeted,
484        plan: sync_plan,
485    })
486}
487
488/// Check that a frozen sync has no pending changes.
489pub(crate) fn check_frozen_gate(planned: &PlannedState) -> Result<(), MarsError> {
490    let has_changes = planned.plan.actions.iter().any(|a| {
491        !matches!(
492            a,
493            plan::PlannedAction::Skip { .. } | plan::PlannedAction::KeepLocal { .. }
494        )
495    });
496    if has_changes {
497        return Err(MarsError::FrozenViolation {
498            message: "lock file would change but --frozen is set".into(),
499        });
500    }
501    Ok(())
502}
503
504/// Phase 5: Persist config if mutated, apply plan to .mars/ canonical store.
505pub(crate) fn apply_plan(
506    ctx: &MarsContext,
507    planned: PlannedState,
508    request: &SyncRequest,
509) -> Result<AppliedState, MarsError> {
510    let project_root = &ctx.project_root;
511    let mars_dir = project_root.join(".mars");
512    let cache_bases_dir = mars_dir.join("cache").join("bases");
513
514    let has_bump_version_changes =
515        has_version_changes(&planned.targeted.resolved.loaded.dependency_changes)
516            && matches!(
517                request.resolution,
518                ResolutionMode::Maximize { bump: true, .. }
519            );
520    let has_mutation = request.mutation.is_some() || has_bump_version_changes;
521
522    // Persist config/local only after validation gate and before apply.
523    if has_mutation && !request.options.dry_run {
524        match &request.mutation {
525            Some(ConfigMutation::SetOverride { .. } | ConfigMutation::ClearOverride { .. }) => {
526                crate::config::save_local(project_root, &planned.targeted.resolved.loaded.local)?;
527            }
528            Some(
529                ConfigMutation::UpsertDependency { .. }
530                | ConfigMutation::BatchUpsert(..)
531                | ConfigMutation::RemoveDependency { .. }
532                | ConfigMutation::SetRename { .. },
533            ) => {
534                crate::config::save(project_root, &planned.targeted.resolved.loaded.config)?;
535            }
536            None => {
537                if has_bump_version_changes {
538                    crate::config::save(project_root, &planned.targeted.resolved.loaded.config)?;
539                }
540            }
541        }
542    }
543
544    // Apply plan to .mars/ canonical store (D25).
545    // Content is written to .mars/agents/ and .mars/skills/, then
546    // sync_targets() copies to all managed target directories.
547    let applied = apply::execute(&mars_dir, &planned.plan, &request.options, &cache_bases_dir)?;
548
549    Ok(AppliedState { planned, applied })
550}
551
552/// Phase 6: Sync managed targets from .mars/ canonical store.
553///
554/// Copies content from .mars/ to all configured target directories.
555/// Non-fatal — target sync errors are recorded as diagnostics.
556/// Lock is written regardless of target sync outcome (D21).
557pub(crate) fn sync_targets(
558    ctx: &MarsContext,
559    applied: AppliedState,
560    request: &SyncRequest,
561    agent_surface_policy: crate::compiler::AgentSurfacePolicy,
562    diag: &mut DiagnosticCollector,
563) -> SyncedState {
564    if request.options.dry_run {
565        return SyncedState {
566            applied,
567            target_outcomes: Vec::new(),
568            config_entries: BTreeMap::new(),
569            compiled_native_outputs: Vec::new(),
570            removed_native_outputs: Vec::new(),
571        };
572    }
573
574    let mars_dir = ctx.project_root.join(".mars");
575    let targets = applied
576        .planned
577        .targeted
578        .resolved
579        .loaded
580        .effective
581        .settings
582        .managed_targets();
583    let old_lock = &applied.planned.targeted.resolved.loaded.old_lock;
584
585    let filtered_outcomes;
586    let orphan_preserve_paths;
587    let (target_outcomes_source, orphan_preserve) = match &agent_surface_policy {
588        crate::compiler::AgentSurfacePolicy::SuppressAll => {
589            filtered_outcomes = crate::compiler::suppress_agent_outcomes(&applied.applied.outcomes);
590            (&filtered_outcomes, None)
591        }
592        crate::compiler::AgentSurfacePolicy::EmitSelective(spec) => {
593            orphan_preserve_paths =
594                crate::compiler::selective_native_orphan_preserve_paths(old_lock, spec);
595            filtered_outcomes = crate::compiler::omit_agent_outcomes(&applied.applied.outcomes);
596            (&filtered_outcomes, Some(&orphan_preserve_paths))
597        }
598        crate::compiler::AgentSurfacePolicy::EmitAll => (&applied.applied.outcomes, None),
599    };
600
601    let target_sync_ctx = crate::target_sync::TargetSyncContext {
602        old_lock,
603        force: request.options.force,
604        collision_hint: crate::surface_ownership::CollisionAdoptHint::SyncForce,
605        orphan_preserve_paths: orphan_preserve,
606    };
607    let target_outcomes = crate::target_sync::sync_managed_targets(
608        &ctx.project_root,
609        &mars_dir,
610        &targets,
611        target_outcomes_source,
612        &target_sync_ctx,
613        diag,
614    );
615
616    SyncedState {
617        applied,
618        target_outcomes,
619        config_entries: BTreeMap::new(),
620        compiled_native_outputs: Vec::new(),
621        removed_native_outputs: Vec::new(),
622    }
623}
624
625/// Phase 7: Write lock file, construct SyncReport.
626///
627/// Lock is written regardless of target sync outcome (D21).
628pub(crate) fn finalize(
629    ctx: &MarsContext,
630    state: SyncedState,
631    request: &SyncRequest,
632    diag: &mut DiagnosticCollector,
633) -> Result<SyncReport, MarsError> {
634    let project_root = &ctx.project_root;
635    let old_lock = &state.applied.planned.targeted.resolved.loaded.old_lock;
636    let graph = &state.applied.planned.targeted.resolved.graph;
637    // Native-agent surface deltas for the summary: removals are unambiguous; emits
638    // are filtered to new/changed outputs so steady-state re-emits stay quiet.
639    let native_removed: Vec<(String, String)> = state.removed_native_outputs.clone();
640    let native_emitted: Vec<(String, String)> = state
641        .compiled_native_outputs
642        .iter()
643        .filter(|out| crate::lock::native_output_is_new_or_changed(old_lock, out))
644        .map(|out| (out.target_root.clone(), out.dest_path.clone()))
645        .collect();
646
647    // Write lock file (D21 — regardless of target sync outcome).
648    if !request.options.dry_run {
649        let dep_models = crate::models::declaration_ordered_dep_models(
650            graph,
651            &state.applied.planned.targeted.resolved.loaded.effective,
652        );
653        let mut dep_model_aliases = crate::models::dependency_alias_snapshot(&dep_models);
654        dep_model_aliases.sort_keys();
655
656        let mut new_lock = crate::lock::build(
657            graph,
658            &state.applied.applied,
659            old_lock,
660            state.config_entries,
661        )?;
662        new_lock.dependency_model_aliases = dep_model_aliases;
663        crate::lock::apply_target_sync_outputs(&mut new_lock, &state.target_outcomes);
664        crate::lock::apply_removed_native_outputs(&mut new_lock, &state.removed_native_outputs);
665        crate::lock::apply_compiled_native_outputs(&mut new_lock, &state.compiled_native_outputs);
666        if let Some(warning) =
667            crate::compiler::persist_lock_then_native_agent_manifest(project_root, &new_lock)?
668        {
669            diag.warn("native-agent-manifest-write", warning);
670        }
671
672        // Best-effort models cache refresh: ensure the catalog covers any
673        // new aliases we're about to persist. Sync never aborts on refresh
674        // failure — warn and continue.
675        let mars_path = ctx.project_root.join(".mars");
676        let ttl = state
677            .applied
678            .planned
679            .targeted
680            .resolved
681            .loaded
682            .effective
683            .settings
684            .models_cache_ttl_hours;
685        let refresh = crate::models::resolve_models_refresh_control(
686            request.options.refresh_models,
687            request.options.no_refresh_models,
688        )?;
689        match crate::models::ensure_fresh(&mars_path, ttl, refresh.catalog_mode) {
690            Ok((_, crate::models::RefreshOutcome::StaleFallback { reason })) => {
691                diag.warn(
692                    "models-cache-refresh",
693                    format!("using stale models cache: {reason}"),
694                );
695            }
696            Ok((_, crate::models::RefreshOutcome::Offline)) => {}
697            Ok(_) => {}
698            Err(err) => {
699                diag.warn(
700                    "models-cache-refresh",
701                    format!("failed to refresh models cache: {err}"),
702                );
703            }
704        }
705    }
706
707    for w in &state.applied.planned.targeted.warnings {
708        match w {
709            ValidationWarning::MissingSkill {
710                agent,
711                skill_name,
712                suggestion,
713            } => {
714                let msg = match suggestion {
715                    Some(s) => format!(
716                        "agent `{}` references missing skill `{}` (did you mean `{}`?)",
717                        agent.name, skill_name, s
718                    ),
719                    None => {
720                        format!(
721                            "agent `{}` references missing skill `{}`",
722                            agent.name, skill_name
723                        )
724                    }
725                };
726                diag.warn("missing-skill", msg);
727            }
728        }
729    }
730    let dependency_changes = state
731        .applied
732        .planned
733        .targeted
734        .resolved
735        .loaded
736        .dependency_changes;
737    let upgrades_available = state.applied.planned.targeted.resolved.upgrades_available;
738
739    let diagnostics = diag.drain();
740
741    Ok(SyncReport {
742        applied: state.applied.applied,
743        pruned: Vec::new(),
744        diagnostics,
745        dependency_changes,
746        upgrades_available,
747        target_outcomes: state.target_outcomes,
748        dry_run: request.options.dry_run,
749        native_emitted,
750        native_removed,
751    })
752}
753
754fn default_dest_path(kind: ItemKind, name: &str) -> DestPath {
755    match kind {
756        ItemKind::Agent => DestPath::from(format!("agents/{name}.md")),
757        ItemKind::Skill => DestPath::from(format!("skills/{name}")),
758        ItemKind::Hook => DestPath::from(format!("hooks/{name}")),
759        ItemKind::McpServer => DestPath::from(format!("mcp/{name}")),
760        ItemKind::BootstrapDoc => DestPath::from(format!("bootstrap/{name}/BOOTSTRAP.md")),
761    }
762}
763
764fn validate_request(request: &SyncRequest) -> Result<(), MarsError> {
765    if request.options.frozen && matches!(request.resolution, ResolutionMode::Maximize { .. }) {
766        return Err(MarsError::InvalidRequest {
767            message:
768                "cannot use --frozen with upgrade (frozen locks versions; upgrade maximizes them)"
769                    .to_string(),
770        });
771    }
772
773    if request.options.frozen && request.mutation.is_some() {
774        return Err(MarsError::InvalidRequest {
775            message:
776                "cannot modify config in --frozen mode (config change would require lock update)"
777                    .to_string(),
778        });
779    }
780
781    Ok(())
782}
783
784fn validate_targets(
785    resolution: &ResolutionMode,
786    effective: &EffectiveConfig,
787) -> Result<(), MarsError> {
788    if let ResolutionMode::Maximize { targets, .. } = resolution {
789        for name in targets {
790            if !effective.dependencies.contains_key(name) {
791                return Err(MarsError::Source {
792                    source_name: name.to_string(),
793                    message: format!("dependency `{name}` not found in mars.toml"),
794                });
795            }
796        }
797    }
798
799    Ok(())
800}
801
802fn to_resolve_options(mode: &ResolutionMode, frozen: bool) -> ResolveOptions {
803    if frozen {
804        return ResolveOptions::frozen();
805    }
806
807    match mode {
808        ResolutionMode::Normal => ResolveOptions::sync(),
809        ResolutionMode::Maximize { targets, bump } => {
810            ResolveOptions::upgrade(targets.clone(), *bump)
811        }
812    }
813}
814
815fn planned_bump_entries(
816    config: &Config,
817    graph: &ResolvedGraph,
818    mode: &ResolutionMode,
819) -> Vec<(SourceName, crate::config::DependencyEntry)> {
820    let ResolutionMode::Maximize {
821        targets,
822        bump: true,
823    } = mode
824    else {
825        return Vec::new();
826    };
827
828    config
829        .dependencies
830        .iter()
831        .filter_map(|(name, entry)| {
832            if !targets.is_empty() && !targets.contains(name) {
833                return None;
834            }
835            // Only git dependencies with semver-tagged resolution can be bumped.
836            entry.url.as_ref()?;
837            let node = graph.nodes.get(name)?;
838            let resolved_version = node.resolved_ref.version.as_ref()?;
839            let resolved_tag = node.resolved_ref.version_tag.as_ref()?;
840            if !constraint_needs_bump(entry.version.as_deref(), resolved_version) {
841                return None;
842            }
843            if entry.version.as_deref() == Some(resolved_tag.as_str()) {
844                return None;
845            }
846            let mut bumped = entry.clone();
847            bumped.version = Some(resolved_tag.clone());
848            Some((name.clone(), bumped))
849        })
850        .collect()
851}
852
853fn constraint_needs_bump(current: Option<&str>, resolved: &semver::Version) -> bool {
854    match crate::resolve::parse_version_constraint(current) {
855        crate::resolve::VersionConstraint::Semver(req) => !req.matches(resolved),
856        crate::resolve::VersionConstraint::Latest
857        | crate::resolve::VersionConstraint::RefPin(_) => false,
858    }
859}
860
861fn has_version_changes(changes: &[DependencyUpsertChange]) -> bool {
862    changes
863        .iter()
864        .any(|change| change.old_version != change.new_version)
865}
866
867#[cfg(test)]
868mod tests;