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#[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 pub target_outcomes: Vec<crate::target_sync::TargetSyncOutcome>,
44 pub dry_run: bool,
46 pub native_emitted: Vec<(String, String)>,
50 pub native_removed: Vec<(String, String)>,
53 pub recovery_halt: Option<RecoveryHalt>,
56 pub engine_fallbacks: Vec<crate::resolve::EngineFallback>,
58}
59
60#[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#[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#[derive(Debug, Clone)]
80pub struct SyncRequest {
81 pub resolution: ResolutionMode,
83 pub mutation: Option<ConfigMutation>,
85 pub options: SyncOptions,
87 pub recovery: RecoveryPolicy,
90 pub lossiness_mode: LossinessMode,
93}
94
95#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
100pub enum RecoveryPolicy {
101 #[default]
102 Strict,
103 DeferOnUnreadable,
104 Repair,
109}
110
111impl RecoveryPolicy {
112 fn defers_on_unreadable(self) -> bool {
113 matches!(self, Self::DeferOnUnreadable | Self::Repair)
114 }
115}
116
117#[derive(Debug, Clone)]
119pub enum ResolutionMode {
120 Normal,
122 Maximize {
125 targets: HashSet<SourceName>,
126 bump: bool,
127 },
128}
129
130pub(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 #[allow(dead_code)]
144 pub sync_lock: FileLock,
145}
146
147pub(crate) struct ResolvedState {
149 pub loaded: LoadedConfig,
150 pub graph: ResolvedGraph,
151 pub upgrades_available: usize,
152}
153
154pub(crate) struct TargetedState {
156 pub resolved: ResolvedState,
157 pub target: TargetState,
158 pub warnings: Vec<ValidationWarning>,
159}
160
161pub(crate) struct PlannedState {
163 pub targeted: TargetedState,
164 pub plan: plan::SyncPlan,
165}
166
167pub(crate) struct AppliedState {
169 pub planned: PlannedState,
170 pub applied: ApplyResult,
171}
172
173pub(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
184pub 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
315pub(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 let lock_path = mars_dir.join("sync.lock");
333 let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
334
335 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 let dependency_changes = if let Some(m) = &request.mutation {
347 mutation::apply_mutation(&mut config, m)?
348 } else {
349 Vec::new()
350 };
351
352 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 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 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
406pub(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 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
489pub(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 let mars_dir = ctx.project_root.join(".mars");
503 let managed_root = &mars_dir;
504
505 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 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 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 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 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 let warnings = validate::validate_skill_refs(&target_state);
705
706 Ok(TargetedState {
707 resolved,
708 target: target_state,
709 warnings,
710 })
711}
712
713pub(crate) fn create_plan(
715 ctx: &MarsContext,
716 targeted: TargetedState,
717 request: &SyncRequest,
718 diag: &mut DiagnosticCollector,
719) -> Result<PlannedState, MarsError> {
720 let mars_dir = ctx.project_root.join(".mars");
722 let managed_root = &mars_dir;
723
724 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 let sync_plan = plan::create(&sync_diff, &request.options, diag);
750
751 Ok(PlannedState {
752 targeted,
753 plan: sync_plan,
754 })
755}
756
757pub(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
773pub(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_pending_config_mutation(ctx, &planned.targeted.resolved.loaded, request)?;
784
785 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
801fn 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
869pub(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
952pub(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 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 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 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 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;