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