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;
10
11use std::cmp::Reverse;
12use std::collections::BTreeMap;
13use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
14use std::path::Path;
15use std::path::PathBuf;
16
17use crate::config::{Config, EffectiveConfig, LocalConfig, Settings};
18use crate::diagnostic::{Diagnostic, DiagnosticCollector};
19use crate::error::MarsError;
20use crate::fs::FileLock;
21use crate::hash;
22use crate::lock::{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
33pub use crate::sync::mutation::{ConfigMutation, DependencyUpsertChange, apply_config_mutation};
35
36#[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 pub target_outcomes: Vec<crate::target_sync::TargetSyncOutcome>,
46 pub dry_run: bool,
48}
49
50impl SyncReport {
51 pub fn has_conflicts(&self) -> bool {
53 self.applied
54 .outcomes
55 .iter()
56 .any(|o| matches!(o.action, apply::ActionTaken::Conflicted))
57 }
58}
59
60#[derive(Debug, Clone)]
62pub struct SyncRequest {
63 pub resolution: ResolutionMode,
65 pub mutation: Option<ConfigMutation>,
67 pub options: SyncOptions,
69}
70
71#[derive(Debug, Clone)]
73pub enum ResolutionMode {
74 Normal,
76 Maximize {
79 targets: HashSet<SourceName>,
80 bump: bool,
81 },
82}
83
84pub(crate) struct LoadedConfig {
91 pub config: Config,
92 pub local: LocalConfig,
93 pub effective: EffectiveConfig,
94 pub old_lock: LockFile,
95 pub dependency_changes: Vec<DependencyUpsertChange>,
96 #[allow(dead_code)]
98 pub sync_lock: FileLock,
99}
100
101pub(crate) struct ResolvedState {
103 pub loaded: LoadedConfig,
104 pub graph: ResolvedGraph,
105}
106
107pub(crate) struct TargetedState {
109 pub resolved: ResolvedState,
110 pub target: TargetState,
111 pub warnings: Vec<ValidationWarning>,
112}
113
114pub(crate) struct PlannedState {
116 pub targeted: TargetedState,
117 pub plan: plan::SyncPlan,
118}
119
120pub(crate) struct AppliedState {
122 pub planned: PlannedState,
123 pub applied: ApplyResult,
124}
125
126pub(crate) struct SyncedState {
128 pub applied: AppliedState,
129 pub target_outcomes: Vec<crate::target_sync::TargetSyncOutcome>,
130 pub config_entries: BTreeMap<String, BTreeMap<String, crate::lock::ConfigEntryRecord>>,
131}
132
133pub fn execute(ctx: &MarsContext, request: &SyncRequest) -> Result<SyncReport, MarsError> {
137 validate_request(request)?;
138 let mut diag = DiagnosticCollector::new();
139 let ir = crate::reader::read(ctx, request, &mut diag)?;
140 crate::compiler::compile(ctx, ir, request, &mut diag)
141}
142
143pub(crate) fn load_config(
150 ctx: &MarsContext,
151 request: &SyncRequest,
152 diag: &mut DiagnosticCollector,
153) -> Result<LoadedConfig, MarsError> {
154 let project_root = &ctx.project_root;
155 let mars_dir = project_root.join(".mars");
156
157 std::fs::create_dir_all(mars_dir.join("cache"))?;
158
159 let lock_path = mars_dir.join("sync.lock");
161 let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
162
163 let mut config = match crate::config::load(project_root) {
165 Ok(config) => config,
166 Err(err) if mutation::is_config_not_found(&err) && request.mutation.is_some() => Config {
167 settings: Settings::default(),
168 ..Config::default()
169 },
170 Err(err) => return Err(err),
171 };
172
173 let dependency_changes = if let Some(m) = &request.mutation {
175 mutation::apply_mutation(&mut config, m)?
176 } else {
177 Vec::new()
178 };
179
180 let mut local = crate::config::load_local(project_root)?;
182 if let Some(m) = &request.mutation {
183 mutation::apply_local_mutation(&mut local, m);
184 }
185
186 let (effective, config_diagnostics) =
188 crate::config::merge_with_root(config.clone(), local.clone(), project_root)?;
189 diag.extend(config_diagnostics);
190
191 let (old_lock, lock_diagnostics) = crate::lock::load_with_diagnostics(project_root)?;
193 diag.extend(lock_diagnostics);
194
195 Ok(LoadedConfig {
196 config,
197 local,
198 effective,
199 old_lock,
200 dependency_changes,
201 sync_lock: _sync_lock,
202 })
203}
204
205pub(crate) fn resolve_graph(
207 ctx: &MarsContext,
208 mut loaded: LoadedConfig,
209 request: &SyncRequest,
210 diag: &mut DiagnosticCollector,
211) -> Result<ResolvedState, MarsError> {
212 validate_targets(&request.resolution, &loaded.effective)?;
213
214 let cache = GlobalCache::new()?;
215 let source_provider = provider::RealSourceProvider {
216 cache: &cache,
217 project_root: &ctx.project_root,
218 };
219 let resolve_options = to_resolve_options(&request.resolution, request.options.frozen);
220 let graph = crate::resolve::resolve(
221 &loaded.effective,
222 &source_provider,
223 Some(&loaded.old_lock),
224 &resolve_options,
225 diag,
226 )?;
227
228 let bump_entries = planned_bump_entries(&loaded.config, &graph, &request.resolution);
229 if !bump_entries.is_empty() {
230 let bump_changes = mutation::apply_mutation(
231 &mut loaded.config,
232 &ConfigMutation::BatchUpsert(bump_entries),
233 )?;
234 loaded.dependency_changes.extend(bump_changes);
235 }
236
237 let dep_models = declaration_ordered_dep_models(&graph, &loaded.effective);
239 let _ = crate::models::merge_model_config(&loaded.config.models, &dep_models, diag, None);
240
241 Ok(ResolvedState { loaded, graph })
242}
243
244pub(crate) fn build_target(
250 ctx: &MarsContext,
251 resolved: ResolvedState,
252 local_items: Vec<crate::local_source::LocalDiscoveredItem>,
253 _request: &SyncRequest,
254 diag: &mut DiagnosticCollector,
255) -> Result<TargetedState, MarsError> {
256 let mars_dir = ctx.project_root.join(".mars");
258 let managed_root = &mars_dir;
259
260 let (mut target_state, renames) =
262 target::build_with_collisions_and_diag(&resolved.graph, &resolved.loaded.effective, diag)?;
263
264 let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
265 let local_source_id = SourceId::Path {
266 canonical: dunce::canonicalize(&ctx.project_root)
267 .unwrap_or_else(|_| ctx.project_root.clone()),
268 subpath: None,
269 };
270 let old_lock_index = LockIndex::new(&resolved.loaded.old_lock);
271
272 for item in local_items {
273 let source_path = item.disk_path();
274 let is_flat_skill = item.discovered.id.kind == ItemKind::Skill
275 && item.discovered.source_path == Path::new(".");
276 let source_hash = if is_flat_skill {
277 ContentHash::from(hash::compute_skill_hash_filtered(
278 &source_path,
279 crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
280 )?)
281 } else {
282 ContentHash::from(hash::compute_hash(&source_path, item.discovered.id.kind)?)
283 };
284 if item.discovered.id.kind == ItemKind::Agent
285 && let Err(message) =
286 crate::target::validate_agent_filename(item.discovered.id.name.as_str())
287 {
288 diag.error_with_category(
289 "invalid-agent-filename",
290 format!("{message}; skipping local agent"),
291 crate::diagnostic::DiagnosticCategory::Validation,
292 );
293 continue;
294 }
295 let dest_path =
296 default_dest_path(item.discovered.id.kind, item.discovered.id.name.as_str());
297
298 if let Some(existing) = target_state.items.shift_remove(&dest_path)
299 && existing.source_hash != source_hash
300 {
301 diag.warn(
302 "local-shadow",
303 format!(
304 "local {} `{}` shadows dependency `{}` {} `{}`",
305 item.discovered.id.kind,
306 item.discovered.id.name,
307 existing.source_name,
308 existing.id.kind,
309 existing.id.name
310 ),
311 );
312 }
313
314 let disk_path = dest_path.resolve(managed_root);
315 if !old_lock_index.contains_dest_path(&dest_path) && disk_path.symlink_metadata().is_ok() {
316 diag.warn(
317 "unmanaged-collision",
318 format!(
319 "local {} `{}` collides with unmanaged path `{}` — leaving existing content untouched",
320 item.discovered.id.kind, item.discovered.id.name, dest_path
321 ),
322 );
323 continue;
324 }
325
326 target_state.items.insert(
327 dest_path.clone(),
328 TargetItem {
329 id: ItemId {
330 kind: item.discovered.id.kind,
331 name: item.discovered.id.name.clone(),
332 },
333 source_name: local_source_name.clone(),
334 origin: SourceOrigin::LocalPackage,
335 source_id: local_source_id.clone(),
336 source_path,
337 dest_path,
338 source_hash,
339 is_flat_skill,
340 rewritten_content: None,
341 },
342 );
343 }
344
345 if !renames.is_empty() {
347 let rewrite_warnings =
348 target::rewrite_skill_refs(&mut target_state, &renames, &resolved.graph)?;
349 for w in &rewrite_warnings {
350 diag.warn("rewrite-warning", w.to_string());
351 }
352 }
353
354 validate_skill_frontmatter_in_target(&target_state, diag);
355
356 let warnings = validate_skill_refs(managed_root, &target_state);
358
359 let unmanaged_collisions =
361 target::check_unmanaged_collisions(managed_root, &resolved.loaded.old_lock, &target_state);
362 for collision in &unmanaged_collisions {
363 diag.warn(
364 "unmanaged-collision",
365 format!(
366 "source `{}` collides with unmanaged path `{}` — leaving existing content untouched",
367 collision.source_name, collision.path
368 ),
369 );
370 target_state.items.shift_remove(&collision.path);
371 }
372
373 Ok(TargetedState {
374 resolved,
375 target: target_state,
376 warnings,
377 })
378}
379
380pub(crate) fn create_plan(
382 ctx: &MarsContext,
383 targeted: TargetedState,
384 request: &SyncRequest,
385 diag: &mut DiagnosticCollector,
386) -> Result<PlannedState, MarsError> {
387 let mars_dir = ctx.project_root.join(".mars");
389 let managed_root = &mars_dir;
390 let cache_bases_dir = mars_dir.join("cache").join("bases");
391
392 let sync_diff = diff::compute(
394 managed_root,
395 &targeted.resolved.loaded.old_lock,
396 &targeted.target,
397 request.options.force,
398 )?;
399
400 if !request.options.force {
401 for entry in &sync_diff.items {
402 if let diff::DiffEntry::LocalModified { target, .. } = entry {
403 diag.warn(
404 "disk-lock-divergent",
405 format!(
406 "{} diverged from mars.lock checksum; preserving local content (run `{cmd1}` or `{cmd2}` to reset)",
407 target.dest_path,
408 cmd1 = managed_cmd("mars sync --force"),
409 cmd2 = managed_cmd("mars repair"),
410 ),
411 );
412 }
413 }
414 }
415
416 let sync_plan = plan::create(&sync_diff, &request.options, &cache_bases_dir, diag);
418
419 Ok(PlannedState {
420 targeted,
421 plan: sync_plan,
422 })
423}
424
425pub(crate) fn check_frozen_gate(planned: &PlannedState) -> Result<(), MarsError> {
427 let has_changes = planned.plan.actions.iter().any(|a| {
428 !matches!(
429 a,
430 plan::PlannedAction::Skip { .. } | plan::PlannedAction::KeepLocal { .. }
431 )
432 });
433 if has_changes {
434 return Err(MarsError::FrozenViolation {
435 message: "lock file would change but --frozen is set".into(),
436 });
437 }
438 Ok(())
439}
440
441pub(crate) fn apply_plan(
443 ctx: &MarsContext,
444 planned: PlannedState,
445 request: &SyncRequest,
446) -> Result<AppliedState, MarsError> {
447 let project_root = &ctx.project_root;
448 let mars_dir = project_root.join(".mars");
449 let cache_bases_dir = mars_dir.join("cache").join("bases");
450
451 let has_bump_version_changes =
452 has_version_changes(&planned.targeted.resolved.loaded.dependency_changes)
453 && matches!(
454 request.resolution,
455 ResolutionMode::Maximize { bump: true, .. }
456 );
457 let has_mutation = request.mutation.is_some() || has_bump_version_changes;
458
459 if has_mutation && !request.options.dry_run {
461 match &request.mutation {
462 Some(ConfigMutation::SetOverride { .. } | ConfigMutation::ClearOverride { .. }) => {
463 crate::config::save_local(project_root, &planned.targeted.resolved.loaded.local)?;
464 }
465 Some(
466 ConfigMutation::UpsertDependency { .. }
467 | ConfigMutation::BatchUpsert(..)
468 | ConfigMutation::RemoveDependency { .. }
469 | ConfigMutation::SetRename { .. },
470 ) => {
471 crate::config::save(project_root, &planned.targeted.resolved.loaded.config)?;
472 }
473 None => {
474 if has_bump_version_changes {
475 crate::config::save(project_root, &planned.targeted.resolved.loaded.config)?;
476 }
477 }
478 }
479 }
480
481 let applied = apply::execute(&mars_dir, &planned.plan, &request.options, &cache_bases_dir)?;
485
486 Ok(AppliedState { planned, applied })
487}
488
489pub(crate) fn sync_targets(
495 ctx: &MarsContext,
496 applied: AppliedState,
497 request: &SyncRequest,
498 agent_surface_policy: crate::compiler::AgentSurfacePolicy,
499 diag: &mut DiagnosticCollector,
500) -> SyncedState {
501 if request.options.dry_run {
502 return SyncedState {
503 applied,
504 target_outcomes: Vec::new(),
505 config_entries: BTreeMap::new(),
506 };
507 }
508
509 let mars_dir = ctx.project_root.join(".mars");
510 let targets = applied
511 .planned
512 .targeted
513 .resolved
514 .loaded
515 .effective
516 .settings
517 .managed_targets();
518 let previous_managed_paths = applied
519 .planned
520 .targeted
521 .resolved
522 .loaded
523 .old_lock
524 .all_output_dest_paths()
525 .map(|dest_path| dest_path.to_string())
526 .collect::<HashSet<String>>();
527
528 let outcomes;
529 let target_outcomes_source = if matches!(
530 agent_surface_policy,
531 crate::compiler::AgentSurfacePolicy::SuppressAll
532 ) {
533 outcomes = crate::compiler::suppress_agent_outcomes(&applied.applied.outcomes);
534 &outcomes
535 } else {
536 &applied.applied.outcomes
537 };
538
539 let target_outcomes = crate::target_sync::sync_managed_targets(
540 &ctx.project_root,
541 &mars_dir,
542 &targets,
543 target_outcomes_source,
544 &previous_managed_paths,
545 request.options.force,
546 diag,
547 );
548
549 SyncedState {
550 applied,
551 target_outcomes,
552 config_entries: BTreeMap::new(),
553 }
554}
555
556pub(crate) fn finalize(
560 ctx: &MarsContext,
561 state: SyncedState,
562 request: &SyncRequest,
563 diag: &mut DiagnosticCollector,
564) -> Result<SyncReport, MarsError> {
565 let project_root = &ctx.project_root;
566 let old_lock = &state.applied.planned.targeted.resolved.loaded.old_lock;
567 let graph = &state.applied.planned.targeted.resolved.graph;
568
569 if !request.options.dry_run {
571 let new_lock = crate::lock::build(
572 graph,
573 &state.applied.applied,
574 old_lock,
575 state.config_entries,
576 )?;
577 crate::lock::write(project_root, &new_lock)?;
578
579 let dep_models = declaration_ordered_dep_models(
583 graph,
584 &state.applied.planned.targeted.resolved.loaded.effective,
585 );
586 let empty_consumer: indexmap::IndexMap<String, crate::models::ModelAlias> =
587 indexmap::IndexMap::new();
588 let mut ignored_diag = DiagnosticCollector::new();
589 let dep_model_aliases = crate::models::merge_model_config(
590 &empty_consumer,
591 &dep_models,
592 &mut ignored_diag,
593 None,
594 );
595
596 let mars_path = ctx.project_root.join(".mars");
600 let ttl = crate::models::load_models_cache_ttl(ctx);
601 let mode = crate::models::resolve_refresh_mode(request.options.no_refresh_models);
602 match crate::models::ensure_fresh(&mars_path, ttl, mode) {
603 Ok((_, crate::models::RefreshOutcome::StaleFallback { reason })) => {
604 diag.warn(
605 "models-cache-refresh",
606 format!("using stale models cache: {reason}"),
607 );
608 }
609 Ok((_, crate::models::RefreshOutcome::Offline)) => {}
610 Ok(_) => {}
611 Err(err) => {
612 diag.warn(
613 "models-cache-refresh",
614 format!("failed to refresh models cache: {err}"),
615 );
616 }
617 }
618
619 match serde_json::to_string_pretty(&dep_model_aliases) {
620 Ok(json) => {
621 let merged_path = ctx.project_root.join(".mars").join("models-merged.json");
622 if let Err(err) = crate::fs::atomic_write(&merged_path, json.as_bytes()) {
623 diag.warn(
624 "models-merge-write",
625 format!("failed to write models-merged.json: {err}"),
626 );
627 }
628 }
629 Err(err) => {
630 diag.warn(
631 "models-merge-write",
632 format!("failed to serialize merged model aliases: {err}"),
633 );
634 }
635 }
636 }
637
638 for w in &state.applied.planned.targeted.warnings {
639 match w {
640 ValidationWarning::MissingSkill {
641 agent,
642 skill_name,
643 suggestion,
644 } => {
645 let msg = match suggestion {
646 Some(s) => format!(
647 "agent `{}` references missing skill `{}` (did you mean `{}`?)",
648 agent.name, skill_name, s
649 ),
650 None => {
651 format!(
652 "agent `{}` references missing skill `{}`",
653 agent.name, skill_name
654 )
655 }
656 };
657 diag.warn("missing-skill", msg);
658 }
659 }
660 }
661 let dependency_changes = state
662 .applied
663 .planned
664 .targeted
665 .resolved
666 .loaded
667 .dependency_changes;
668 let effective = &state.applied.planned.targeted.resolved.loaded.effective;
669 let upgrades_available = if request.options.frozen {
670 0
671 } else {
672 graph
673 .nodes
674 .values()
675 .filter(|node| {
676 effective.dependencies.contains_key(&node.source_name)
677 && matches!(
678 (&node.resolved_ref.version, &node.latest_version),
679 (Some(resolved), Some(latest)) if latest > resolved
680 )
681 })
682 .count()
683 };
684
685 Ok(SyncReport {
686 applied: state.applied.applied,
687 pruned: Vec::new(),
688 diagnostics: diag.drain(),
689 dependency_changes,
690 upgrades_available,
691 target_outcomes: state.target_outcomes,
692 dry_run: request.options.dry_run,
693 })
694}
695
696fn declaration_ordered_dep_models(
697 graph: &ResolvedGraph,
698 config: &EffectiveConfig,
699) -> Vec<crate::models::ResolvedDepModels> {
700 let mut decl_pos: HashMap<SourceName, usize> = HashMap::new();
702 for (idx, name) in config.dependencies.keys().enumerate() {
703 decl_pos.insert(name.clone(), idx);
704 }
705
706 for (idx, sponsor) in config.dependencies.keys().enumerate() {
709 let Some(sponsor_node) = graph.nodes.get(sponsor) else {
710 continue;
711 };
712
713 let mut queue: VecDeque<SourceName> = sponsor_node.deps.iter().cloned().collect();
714 let mut visited: HashSet<SourceName> = HashSet::new();
715
716 while let Some(dep) = queue.pop_front() {
717 if !visited.insert(dep.clone()) {
718 continue;
719 }
720
721 decl_pos
722 .entry(dep.clone())
723 .and_modify(|pos| *pos = (*pos).min(idx))
724 .or_insert(idx);
725
726 if let Some(dep_node) = graph.nodes.get(&dep) {
727 queue.extend(dep_node.deps.iter().cloned());
728 }
729 }
730 }
731
732 let mut in_degree: HashMap<SourceName, usize> = HashMap::new();
735 let mut adjacency: HashMap<SourceName, Vec<SourceName>> = HashMap::new();
736
737 for name in graph.nodes.keys() {
738 in_degree.entry(name.clone()).or_insert(0);
739 adjacency.entry(name.clone()).or_default();
740 }
741
742 for (name, node) in &graph.nodes {
743 for dep in &node.deps {
744 if graph.nodes.contains_key(dep) {
745 *in_degree.entry(name.clone()).or_insert(0) += 1;
746 adjacency.entry(dep.clone()).or_default().push(name.clone());
747 }
748 }
749 }
750
751 let mut ready: BinaryHeap<Reverse<(usize, SourceName)>> = BinaryHeap::new();
752 for (name, degree) in &in_degree {
753 if *degree == 0 {
754 let position = decl_pos.get(name).copied().unwrap_or(usize::MAX);
755 ready.push(Reverse((position, name.clone())));
756 }
757 }
758
759 let mut ordered: Vec<SourceName> = Vec::with_capacity(graph.nodes.len());
760 while let Some(Reverse((_, current))) = ready.pop() {
761 ordered.push(current.clone());
762
763 if let Some(dependents) = adjacency.get(¤t) {
764 for dependent in dependents {
765 if let Some(degree) = in_degree.get_mut(dependent) {
766 *degree -= 1;
767 if *degree == 0 {
768 let position = decl_pos.get(dependent).copied().unwrap_or(usize::MAX);
769 ready.push(Reverse((position, dependent.clone())));
770 }
771 }
772 }
773 }
774 }
775
776 let ordered_names: Vec<SourceName> = if ordered.len() == graph.nodes.len() {
779 ordered
780 } else {
781 graph.order.clone()
782 };
783
784 ordered_names
785 .iter()
786 .filter_map(|name| {
787 let node = graph.nodes.get(name)?;
788 let manifest = node.manifest.as_ref()?;
789 if manifest.models.is_empty() {
790 return None;
791 }
792 Some(crate::models::ResolvedDepModels {
793 source_name: name.to_string(),
794 models: manifest.models.clone(),
795 })
796 })
797 .collect()
798}
799
800fn default_dest_path(kind: ItemKind, name: &str) -> DestPath {
801 match kind {
802 ItemKind::Agent => DestPath::from(format!("agents/{name}.md")),
803 ItemKind::Skill => DestPath::from(format!("skills/{name}")),
804 ItemKind::Hook => DestPath::from(format!("hooks/{name}")),
805 ItemKind::McpServer => DestPath::from(format!("mcp/{name}")),
806 ItemKind::BootstrapDoc => DestPath::from(format!("bootstrap/{name}/BOOTSTRAP.md")),
807 }
808}
809
810fn validate_request(request: &SyncRequest) -> Result<(), MarsError> {
811 if request.options.frozen && matches!(request.resolution, ResolutionMode::Maximize { .. }) {
812 return Err(MarsError::InvalidRequest {
813 message:
814 "cannot use --frozen with upgrade (frozen locks versions; upgrade maximizes them)"
815 .to_string(),
816 });
817 }
818
819 if request.options.frozen && request.mutation.is_some() {
820 return Err(MarsError::InvalidRequest {
821 message:
822 "cannot modify config in --frozen mode (config change would require lock update)"
823 .to_string(),
824 });
825 }
826
827 Ok(())
828}
829
830fn validate_targets(
831 resolution: &ResolutionMode,
832 effective: &EffectiveConfig,
833) -> Result<(), MarsError> {
834 if let ResolutionMode::Maximize { targets, .. } = resolution {
835 for name in targets {
836 if !effective.dependencies.contains_key(name) {
837 return Err(MarsError::Source {
838 source_name: name.to_string(),
839 message: format!("dependency `{name}` not found in mars.toml"),
840 });
841 }
842 }
843 }
844
845 Ok(())
846}
847
848fn to_resolve_options(mode: &ResolutionMode, frozen: bool) -> ResolveOptions {
849 match mode {
850 ResolutionMode::Normal => ResolveOptions {
851 frozen,
852 ..ResolveOptions::default()
853 },
854 ResolutionMode::Maximize { targets, bump } => ResolveOptions {
855 maximize: true,
856 upgrade_targets: targets.clone(),
857 bump_direct_constraints: *bump,
858 frozen,
859 },
860 }
861}
862
863fn planned_bump_entries(
864 config: &Config,
865 graph: &ResolvedGraph,
866 mode: &ResolutionMode,
867) -> Vec<(SourceName, crate::config::DependencyEntry)> {
868 let ResolutionMode::Maximize {
869 targets,
870 bump: true,
871 } = mode
872 else {
873 return Vec::new();
874 };
875
876 config
877 .dependencies
878 .iter()
879 .filter_map(|(name, entry)| {
880 if !targets.is_empty() && !targets.contains(name) {
881 return None;
882 }
883 entry.url.as_ref()?;
885 let node = graph.nodes.get(name)?;
886 let resolved_version = node.resolved_ref.version.as_ref()?;
887 let resolved_tag = node.resolved_ref.version_tag.as_ref()?;
888 if !constraint_needs_bump(entry.version.as_deref(), resolved_version) {
889 return None;
890 }
891 if entry.version.as_deref() == Some(resolved_tag.as_str()) {
892 return None;
893 }
894 let mut bumped = entry.clone();
895 bumped.version = Some(resolved_tag.clone());
896 Some((name.clone(), bumped))
897 })
898 .collect()
899}
900
901fn constraint_needs_bump(current: Option<&str>, resolved: &semver::Version) -> bool {
902 match crate::resolve::parse_version_constraint(current) {
903 crate::resolve::VersionConstraint::Semver(req) => !req.matches(resolved),
904 crate::resolve::VersionConstraint::Latest
905 | crate::resolve::VersionConstraint::RefPin(_) => false,
906 }
907}
908
909fn has_version_changes(changes: &[DependencyUpsertChange]) -> bool {
910 changes
911 .iter()
912 .any(|change| change.old_version != change.new_version)
913}
914
915fn validate_skill_refs(
918 install_target: &std::path::Path,
919 target: &target::TargetState,
920) -> Vec<ValidationWarning> {
921 use crate::lock::ItemKind;
922
923 let available_skills: HashSet<String> = target
925 .items
926 .values()
927 .filter(|item| item.id.kind == ItemKind::Skill)
928 .map(|item| item.id.name.to_string())
929 .collect();
930
931 let agents: Vec<(String, PathBuf)> = target
933 .items
934 .values()
935 .filter(|item| item.id.kind == ItemKind::Agent)
936 .map(|item| {
937 let disk_path = item.dest_path.resolve(install_target);
938 let path = if disk_path.exists() {
941 disk_path
942 } else {
943 item.source_path.clone()
944 };
945 (item.id.name.to_string(), path)
946 })
947 .collect();
948
949 crate::validate::check_deps(&agents, &available_skills).unwrap_or_default()
950}
951
952fn validate_skill_frontmatter_in_target(
953 target: &target::TargetState,
954 diag: &mut DiagnosticCollector,
955) {
956 use crate::lock::ItemKind;
957
958 for item in target
959 .items
960 .values()
961 .filter(|item| item.id.kind == ItemKind::Skill)
962 {
963 validate_skill_frontmatter_at_source(&item.source_path, item.id.name.as_str(), diag);
964 }
965}
966
967fn validate_skill_frontmatter_at_source(
968 source_path: &Path,
969 skill_name: &str,
970 diag: &mut DiagnosticCollector,
971) {
972 let skill_md = if source_path.is_dir() {
973 source_path.join("SKILL.md")
974 } else {
975 source_path.to_path_buf()
976 };
977 let Ok(content) = std::fs::read_to_string(&skill_md) else {
978 return;
979 };
980 let mut skill_diags = Vec::new();
981 let _ = crate::compiler::skills::parse_skill_content(&content, &mut skill_diags);
982 for d in skill_diags {
983 if d.is_error() {
984 diag.error_with_category(
985 "skill-schema-error",
986 format!("skill `{skill_name}`: {}", d.message()),
987 crate::diagnostic::DiagnosticCategory::Validation,
988 );
989 } else {
990 diag.warn(
991 "skill-schema-warning",
992 format!("skill `{skill_name}`: {}", d.message()),
993 );
994 }
995 }
996}
997
998#[cfg(test)]
999mod tests {
1000 use super::*;
1001 use crate::config::*;
1002 use crate::lock::{ItemKind, LockFile};
1003 use crate::resolve::{ResolvedGraph, ResolvedNode};
1004 use indexmap::IndexMap;
1005 use std::fs;
1006 use tempfile::TempDir;
1007
1008 struct TestFixture {
1010 project_root: TempDir,
1011 managed_root: PathBuf,
1012 source_trees: Vec<TempDir>,
1013 }
1014
1015 impl TestFixture {
1016 fn new() -> Self {
1017 let project_root = TempDir::new().unwrap();
1018 let managed_root = project_root.path().join(".agents");
1019 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
1021 TestFixture {
1022 project_root,
1023 managed_root,
1024 source_trees: Vec::new(),
1025 }
1026 }
1027
1028 fn add_source(&mut self, agents: &[(&str, &str)], skills: &[(&str, &str)]) -> usize {
1029 let dir = TempDir::new().unwrap();
1030 if !agents.is_empty() {
1031 let agents_dir = dir.path().join("agents");
1032 fs::create_dir_all(&agents_dir).unwrap();
1033 for (name, content) in agents {
1034 fs::write(agents_dir.join(name), content).unwrap();
1035 }
1036 }
1037 if !skills.is_empty() {
1038 let skills_dir = dir.path().join("skills");
1039 fs::create_dir_all(&skills_dir).unwrap();
1040 for (name, content) in skills {
1041 let skill_dir = skills_dir.join(name);
1042 fs::create_dir_all(&skill_dir).unwrap();
1043 fs::write(skill_dir.join("SKILL.md"), content).unwrap();
1044 }
1045 }
1046 self.source_trees.push(dir);
1047 self.source_trees.len() - 1
1048 }
1049
1050 fn project_root(&self) -> &std::path::Path {
1051 self.project_root.path()
1052 }
1053
1054 fn managed_root(&self) -> &std::path::Path {
1055 &self.managed_root
1056 }
1057
1058 fn tree_path(&self, idx: usize) -> PathBuf {
1059 self.source_trees[idx].path().to_path_buf()
1060 }
1061 }
1062
1063 fn make_graph_config(
1064 fixture: &TestFixture,
1065 sources: Vec<(&str, usize, FilterMode)>,
1066 ) -> (ResolvedGraph, EffectiveConfig) {
1067 let mut nodes = IndexMap::new();
1068 let mut order = Vec::new();
1069 let mut config_dependencies = IndexMap::new();
1070
1071 for (name, tree_idx, filter) in sources {
1072 let tree_path = fixture.tree_path(tree_idx);
1073 nodes.insert(
1074 name.into(),
1075 ResolvedNode {
1076 source_name: name.into(),
1077 source_id: crate::types::SourceId::Path {
1078 canonical: tree_path.clone(),
1079 subpath: None,
1080 },
1081 rooted_ref: crate::resolve::RootedSourceRef {
1082 checkout_root: tree_path.clone(),
1083 package_root: tree_path.clone(),
1084 },
1085 resolved_ref: crate::source::ResolvedRef {
1086 source_name: name.into(),
1087 version: None,
1088 version_tag: None,
1089 commit: None,
1090 tree_path: tree_path.clone(),
1091 },
1092 latest_version: None,
1093 manifest: None,
1094 deps: vec![],
1095 },
1096 );
1097 order.push(name.into());
1098
1099 config_dependencies.insert(
1100 name.into(),
1101 EffectiveDependency {
1102 name: name.into(),
1103 id: crate::types::SourceId::Path {
1104 canonical: tree_path.clone(),
1105 subpath: None,
1106 },
1107 spec: SourceSpec::Path(tree_path),
1108 subpath: None,
1109 filter,
1110 rename: crate::types::RenameMap::new(),
1111 is_overridden: false,
1112 original_git: None,
1113 },
1114 );
1115 }
1116
1117 (
1118 ResolvedGraph {
1119 nodes,
1120 order,
1121 filters: std::collections::HashMap::new(),
1122 },
1123 EffectiveConfig {
1124 dependencies: config_dependencies,
1125 settings: Settings::default(),
1126 },
1127 )
1128 }
1129
1130 fn path_dependency_entry(path: &std::path::Path) -> DependencyEntry {
1131 DependencyEntry {
1132 url: None,
1133 path: Some(path.to_path_buf()),
1134 subpath: None,
1135 version: None,
1136 filter: FilterConfig::default(),
1137 }
1138 }
1139
1140 fn git_dependency_entry(url: &str, version: &str, filter: FilterConfig) -> DependencyEntry {
1141 DependencyEntry {
1142 url: Some(url.into()),
1143 path: None,
1144 subpath: None,
1145 version: Some(version.to_string()),
1146 filter,
1147 }
1148 }
1149
1150 fn create_sync_plan(
1151 sync_diff: &diff::SyncDiff,
1152 options: &SyncOptions,
1153 cache_bases_dir: &std::path::Path,
1154 ) -> plan::SyncPlan {
1155 let mut diag = DiagnosticCollector::new();
1156 plan::create(sync_diff, options, cache_bases_dir, &mut diag)
1157 }
1158
1159 fn graph_with_versions(entries: &[(&str, &str, &str)]) -> ResolvedGraph {
1160 let mut nodes = IndexMap::new();
1161 let mut order = Vec::new();
1162 for (name, url, tag) in entries {
1163 let version = semver::Version::parse(tag.trim_start_matches('v')).unwrap();
1164 nodes.insert(
1165 (*name).into(),
1166 ResolvedNode {
1167 source_name: (*name).into(),
1168 source_id: crate::types::SourceId::git(crate::types::SourceUrl::from(*url)),
1169 rooted_ref: crate::resolve::RootedSourceRef {
1170 checkout_root: PathBuf::from(format!("/tmp/{name}")),
1171 package_root: PathBuf::from(format!("/tmp/{name}")),
1172 },
1173 resolved_ref: crate::source::ResolvedRef {
1174 source_name: (*name).into(),
1175 version: Some(version),
1176 version_tag: Some((*tag).to_string()),
1177 commit: Some("abc123".into()),
1178 tree_path: PathBuf::from(format!("/tmp/{name}")),
1179 },
1180 latest_version: None,
1181 manifest: None,
1182 deps: vec![],
1183 },
1184 );
1185 order.push((*name).into());
1186 }
1187
1188 ResolvedGraph {
1189 nodes,
1190 order,
1191 filters: std::collections::HashMap::new(),
1192 }
1193 }
1194
1195 fn model_alias(model: &str) -> crate::models::ModelAlias {
1196 crate::models::ModelAlias {
1197 harness: None,
1198 description: None,
1199 default_effort: None,
1200 autocompact: None,
1201 spec: crate::models::ModelSpec::Pinned {
1202 model: model.to_string(),
1203 provider: None,
1204 },
1205 }
1206 }
1207
1208 fn manifest_with_models(name: &str) -> Manifest {
1209 let mut models = IndexMap::new();
1210 models.insert(
1211 format!("{name}-alias"),
1212 model_alias(&format!("{name}-model")),
1213 );
1214 Manifest {
1215 package: PackageInfo {
1216 name: name.to_string(),
1217 version: "1.0.0".to_string(),
1218 description: None,
1219 },
1220 dependencies: IndexMap::new(),
1221 models,
1222 }
1223 }
1224
1225 fn resolved_node(name: &str, deps: &[&str], with_models: bool) -> ResolvedNode {
1226 let canonical = PathBuf::from(format!("/tmp/{name}"));
1227 ResolvedNode {
1228 source_name: name.into(),
1229 source_id: crate::types::SourceId::Path {
1230 canonical: canonical.clone(),
1231 subpath: None,
1232 },
1233 rooted_ref: crate::resolve::RootedSourceRef {
1234 checkout_root: canonical.clone(),
1235 package_root: canonical.clone(),
1236 },
1237 resolved_ref: crate::source::ResolvedRef {
1238 source_name: name.into(),
1239 version: None,
1240 version_tag: None,
1241 commit: None,
1242 tree_path: canonical,
1243 },
1244 latest_version: None,
1245 manifest: with_models.then(|| manifest_with_models(name)),
1246 deps: deps.iter().map(|dep| (*dep).into()).collect(),
1247 }
1248 }
1249
1250 fn effective_config_with_decl_order(names: &[&str]) -> EffectiveConfig {
1251 let mut dependencies = IndexMap::new();
1252 for name in names {
1253 let canonical = PathBuf::from(format!("/tmp/dep-{name}"));
1254 dependencies.insert(
1255 (*name).into(),
1256 EffectiveDependency {
1257 name: (*name).into(),
1258 id: crate::types::SourceId::Path {
1259 canonical: canonical.clone(),
1260 subpath: None,
1261 },
1262 spec: SourceSpec::Path(canonical),
1263 subpath: None,
1264 filter: FilterMode::All,
1265 rename: crate::types::RenameMap::new(),
1266 is_overridden: false,
1267 original_git: None,
1268 },
1269 );
1270 }
1271 EffectiveConfig {
1272 dependencies,
1273 settings: Settings::default(),
1274 }
1275 }
1276
1277 fn dep_model_names(models: &[crate::models::ResolvedDepModels]) -> Vec<String> {
1278 models.iter().map(|m| m.source_name.clone()).collect()
1279 }
1280
1281 #[test]
1282 fn declaration_ordered_dep_models_sibling_order() {
1283 let mut nodes = IndexMap::new();
1284 nodes.insert("a".into(), resolved_node("a", &[], true));
1285 nodes.insert("b".into(), resolved_node("b", &[], true));
1286
1287 let graph = ResolvedGraph {
1288 nodes,
1289 order: vec!["a".into(), "b".into()],
1290 filters: std::collections::HashMap::new(),
1291 };
1292 let config = effective_config_with_decl_order(&["a", "b"]);
1293
1294 let dep_models = declaration_ordered_dep_models(&graph, &config);
1295 assert_eq!(dep_model_names(&dep_models), vec!["a", "b"]);
1296 }
1297
1298 #[test]
1299 fn declaration_ordered_dep_models_diamond_uses_minimum_sponsor_position() {
1300 let mut nodes = IndexMap::new();
1301 nodes.insert("a".into(), resolved_node("a", &["d"], true));
1302 nodes.insert("b".into(), resolved_node("b", &["d"], true));
1303 nodes.insert("d".into(), resolved_node("d", &[], true));
1304
1305 let graph = ResolvedGraph {
1306 nodes,
1307 order: vec!["d".into(), "a".into(), "b".into()],
1308 filters: std::collections::HashMap::new(),
1309 };
1310 let config = effective_config_with_decl_order(&["a", "b"]);
1311
1312 let dep_models = declaration_ordered_dep_models(&graph, &config);
1313 assert_eq!(dep_model_names(&dep_models), vec!["d", "a", "b"]);
1314 }
1315
1316 #[test]
1317 fn declaration_ordered_dep_models_transitives_follow_sponsor_declaration_order() {
1318 let mut nodes = IndexMap::new();
1319 nodes.insert("a".into(), resolved_node("a", &["d"], false));
1320 nodes.insert("b".into(), resolved_node("b", &["e"], false));
1321 nodes.insert("d".into(), resolved_node("d", &[], true));
1322 nodes.insert("e".into(), resolved_node("e", &[], true));
1323
1324 let graph = ResolvedGraph {
1325 nodes,
1326 order: vec!["d".into(), "e".into(), "a".into(), "b".into()],
1327 filters: std::collections::HashMap::new(),
1328 };
1329 let config = effective_config_with_decl_order(&["a", "b"]);
1330
1331 let dep_models = declaration_ordered_dep_models(&graph, &config);
1332 assert_eq!(dep_model_names(&dep_models), vec!["d", "e"]);
1333 }
1334
1335 #[test]
1336 fn declaration_ordered_dep_models_keeps_deps_before_dependents() {
1337 let mut nodes = IndexMap::new();
1338 nodes.insert("a".into(), resolved_node("a", &["d"], true));
1339 nodes.insert("d".into(), resolved_node("d", &[], true));
1340
1341 let graph = ResolvedGraph {
1342 nodes,
1343 order: vec!["d".into(), "a".into()],
1344 filters: std::collections::HashMap::new(),
1345 };
1346 let config = effective_config_with_decl_order(&["a", "d"]);
1348
1349 let dep_models = declaration_ordered_dep_models(&graph, &config);
1350 assert_eq!(dep_model_names(&dep_models), vec!["d", "a"]);
1351 }
1352
1353 #[test]
1354 fn declaration_ordered_dep_models_is_deterministic() {
1355 let mut nodes = IndexMap::new();
1356 nodes.insert("a".into(), resolved_node("a", &["d"], true));
1357 nodes.insert("b".into(), resolved_node("b", &["e"], true));
1358 nodes.insert("d".into(), resolved_node("d", &[], true));
1359 nodes.insert("e".into(), resolved_node("e", &[], true));
1360
1361 let graph = ResolvedGraph {
1362 nodes,
1363 order: vec!["d".into(), "e".into(), "a".into(), "b".into()],
1364 filters: std::collections::HashMap::new(),
1365 };
1366 let config = effective_config_with_decl_order(&["a", "b"]);
1367
1368 let first = dep_model_names(&declaration_ordered_dep_models(&graph, &config));
1369 for _ in 0..10 {
1370 let current = dep_model_names(&declaration_ordered_dep_models(&graph, &config));
1371 assert_eq!(current, first);
1372 }
1373 }
1374
1375 #[test]
1376 fn declaration_ordered_dep_models_is_used_by_resolve_graph_and_finalize() {
1377 let source = include_str!("mod.rs");
1378 assert!(source.contains("declaration_ordered_dep_models(&graph, &loaded.effective)"));
1379 assert!(source.contains("&state.applied.planned.targeted.resolved.loaded.effective"));
1380 }
1381
1382 #[test]
1383 fn validate_request_rejects_frozen_with_maximize() {
1384 let request = SyncRequest {
1385 resolution: ResolutionMode::Maximize {
1386 targets: HashSet::new(),
1387 bump: false,
1388 },
1389 mutation: None,
1390 options: SyncOptions {
1391 force: false,
1392 dry_run: false,
1393 frozen: true,
1394 no_refresh_models: false,
1395 },
1396 };
1397
1398 let err = validate_request(&request).unwrap_err();
1399 assert!(matches!(err, MarsError::InvalidRequest { .. }));
1400 assert!(err.to_string().contains("--frozen"));
1401 }
1402
1403 #[test]
1404 fn validate_request_rejects_frozen_with_mutation() {
1405 let request = SyncRequest {
1406 resolution: ResolutionMode::Normal,
1407 mutation: Some(ConfigMutation::RemoveDependency {
1408 name: "base".into(),
1409 }),
1410 options: SyncOptions {
1411 force: false,
1412 dry_run: false,
1413 frozen: true,
1414 no_refresh_models: false,
1415 },
1416 };
1417
1418 let err = validate_request(&request).unwrap_err();
1419 assert!(matches!(err, MarsError::InvalidRequest { .. }));
1420 assert!(err.to_string().contains("cannot modify config"));
1421 }
1422
1423 #[test]
1424 fn planned_bump_entries_bump_all_outdated_pins() {
1425 let mut config = Config::default();
1426 config.dependencies.insert(
1427 "base".into(),
1428 git_dependency_entry(
1429 "https://example.com/base.git",
1430 "v1.0.0",
1431 FilterConfig::default(),
1432 ),
1433 );
1434 config.dependencies.insert(
1435 "tools".into(),
1436 git_dependency_entry(
1437 "https://example.com/tools.git",
1438 "v2.0.0",
1439 FilterConfig::default(),
1440 ),
1441 );
1442 config.dependencies.insert(
1443 "floating".into(),
1444 DependencyEntry {
1445 url: Some("https://example.com/floating.git".into()),
1446 path: None,
1447 subpath: None,
1448 version: None,
1449 filter: FilterConfig::default(),
1450 },
1451 );
1452
1453 let graph = graph_with_versions(&[
1454 ("base", "https://example.com/base.git", "v1.2.0"),
1455 ("tools", "https://example.com/tools.git", "v2.0.0"),
1456 ("floating", "https://example.com/floating.git", "v3.0.0"),
1457 ]);
1458
1459 let mode = ResolutionMode::Maximize {
1460 targets: HashSet::new(),
1461 bump: true,
1462 };
1463 let entries = planned_bump_entries(&config, &graph, &mode);
1464 assert_eq!(entries.len(), 1);
1465 assert_eq!(entries[0].0, SourceName::from("base"));
1466 assert_eq!(entries[0].1.version.as_deref(), Some("v1.2.0"));
1467 }
1468
1469 #[test]
1470 fn planned_bump_entries_bump_specific_targets_only() {
1471 let mut config = Config::default();
1472 config.dependencies.insert(
1473 "base".into(),
1474 git_dependency_entry(
1475 "https://example.com/base.git",
1476 "v1.0.0",
1477 FilterConfig::default(),
1478 ),
1479 );
1480 config.dependencies.insert(
1481 "tools".into(),
1482 git_dependency_entry(
1483 "https://example.com/tools.git",
1484 "v1.0.0",
1485 FilterConfig::default(),
1486 ),
1487 );
1488
1489 let graph = graph_with_versions(&[
1490 ("base", "https://example.com/base.git", "v2.0.0"),
1491 ("tools", "https://example.com/tools.git", "v2.0.0"),
1492 ]);
1493
1494 let mode = ResolutionMode::Maximize {
1495 targets: HashSet::from([SourceName::from("tools")]),
1496 bump: true,
1497 };
1498 let entries = planned_bump_entries(&config, &graph, &mode);
1499 assert_eq!(entries.len(), 1);
1500 assert_eq!(entries[0].0, SourceName::from("tools"));
1501 assert_eq!(entries[0].1.version.as_deref(), Some("v2.0.0"));
1502 }
1503
1504 #[test]
1505 fn planned_bump_entries_noop_when_already_latest() {
1506 let mut config = Config::default();
1507 config.dependencies.insert(
1508 "base".into(),
1509 git_dependency_entry(
1510 "https://example.com/base.git",
1511 "v1.2.0",
1512 FilterConfig::default(),
1513 ),
1514 );
1515
1516 let graph = graph_with_versions(&[("base", "https://example.com/base.git", "v1.2.0")]);
1517
1518 let mode = ResolutionMode::Maximize {
1519 targets: HashSet::new(),
1520 bump: true,
1521 };
1522 let entries = planned_bump_entries(&config, &graph, &mode);
1523 assert!(entries.is_empty());
1524 }
1525
1526 #[test]
1527 fn planned_bump_entries_preserve_filters_and_renames() {
1528 let mut rename = crate::types::RenameMap::new();
1529 rename.insert("coder".into(), "coder-v2".into());
1530
1531 let mut config = Config::default();
1532 config.dependencies.insert(
1533 "base".into(),
1534 git_dependency_entry(
1535 "https://example.com/base.git",
1536 "v1.0.0",
1537 FilterConfig {
1538 agents: Some(vec!["coder".into()]),
1539 rename: Some(rename.clone()),
1540 ..FilterConfig::default()
1541 },
1542 ),
1543 );
1544
1545 let graph = graph_with_versions(&[("base", "https://example.com/base.git", "v2.0.0")]);
1546 let mode = ResolutionMode::Maximize {
1547 targets: HashSet::new(),
1548 bump: true,
1549 };
1550 let entries = planned_bump_entries(&config, &graph, &mode);
1551 let mut mutated = config.clone();
1552 let changes =
1553 mutation::apply_mutation(&mut mutated, &ConfigMutation::BatchUpsert(entries)).unwrap();
1554
1555 assert_eq!(changes.len(), 1);
1556 assert_eq!(changes[0].old_version.as_deref(), Some("v1.0.0"));
1557 assert_eq!(changes[0].new_version.as_deref(), Some("v2.0.0"));
1558
1559 let dep = &mutated.dependencies["base"];
1560 assert_eq!(dep.version.as_deref(), Some("v2.0.0"));
1561 assert_eq!(dep.filter.agents.as_deref(), Some(&["coder".into()][..]));
1562 assert_eq!(dep.filter.rename.as_ref(), Some(&rename));
1563 }
1564
1565 #[test]
1566 fn execute_auto_inits_config_for_mutation() {
1567 let project_root = TempDir::new().unwrap();
1568 let managed_root = project_root.path().join(".agents");
1569 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
1570 let source = TempDir::new().unwrap();
1571 fs::create_dir_all(source.path().join("agents")).unwrap();
1572 fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
1573
1574 let request = SyncRequest {
1575 resolution: ResolutionMode::Normal,
1576 mutation: Some(ConfigMutation::UpsertDependency {
1577 name: "base".into(),
1578 entry: path_dependency_entry(source.path()),
1579 }),
1580 options: SyncOptions::default(),
1581 };
1582
1583 let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
1584 let report = execute(&ctx, &request).unwrap();
1585 assert!(!report.applied.outcomes.is_empty());
1586 assert!(project_root.path().join("mars.toml").exists());
1587
1588 let saved = crate::config::load(project_root.path()).unwrap();
1589 assert!(saved.dependencies.contains_key("base"));
1590 }
1591
1592 #[test]
1593 fn execute_dry_run_with_mutation_does_not_write_config() {
1594 let project_root = TempDir::new().unwrap();
1595 let managed_root = project_root.path().join(".agents");
1596 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
1597 crate::config::save(
1598 project_root.path(),
1599 &Config {
1600 dependencies: IndexMap::new(),
1601 settings: Settings::default(),
1602 ..Config::default()
1603 },
1604 )
1605 .unwrap();
1606
1607 let source = TempDir::new().unwrap();
1608 fs::create_dir_all(source.path().join("agents")).unwrap();
1609 fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
1610
1611 let request = SyncRequest {
1612 resolution: ResolutionMode::Normal,
1613 mutation: Some(ConfigMutation::UpsertDependency {
1614 name: "base".into(),
1615 entry: path_dependency_entry(source.path()),
1616 }),
1617 options: SyncOptions {
1618 force: false,
1619 dry_run: true,
1620 frozen: false,
1621 no_refresh_models: false,
1622 },
1623 };
1624
1625 let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
1626 let report = execute(&ctx, &request).unwrap();
1627 assert!(!report.applied.outcomes.is_empty());
1628
1629 let saved = crate::config::load(project_root.path()).unwrap();
1630 assert!(!saved.dependencies.contains_key("base"));
1631 assert!(!managed_root.join("agents/coder.md").exists());
1632 assert!(!project_root.path().join("mars.lock").exists());
1633 }
1634
1635 #[test]
1638 fn full_pipeline_fresh_sync() {
1639 let mut fixture = TestFixture::new();
1640 let src_idx = fixture.add_source(
1641 &[("coder.md", "# Coder agent")],
1642 &[("planning", "# Planning skill")],
1643 );
1644
1645 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1646
1647 let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1649 assert!(renames.is_empty());
1650 assert_eq!(target.items.len(), 2);
1651
1652 let lock = LockFile::empty();
1654 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1655
1656 assert_eq!(sync_diff.items.len(), 2);
1658 for entry in &sync_diff.items {
1659 assert!(matches!(entry, diff::DiffEntry::Add { .. }));
1660 }
1661
1662 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1664 let options = SyncOptions {
1665 force: false,
1666 dry_run: false,
1667 frozen: false,
1668 no_refresh_models: false,
1669 };
1670 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1671 assert_eq!(sync_plan.actions.len(), 2);
1672 for action in &sync_plan.actions {
1673 assert!(matches!(action, plan::PlannedAction::Install { .. }));
1674 }
1675
1676 let result =
1678 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1679 assert_eq!(result.outcomes.len(), 2);
1680
1681 assert!(fixture.managed_root().join("agents/coder.md").exists());
1683 assert!(
1684 fixture
1685 .managed_root()
1686 .join("skills/planning/SKILL.md")
1687 .exists()
1688 );
1689
1690 let new_lock =
1692 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1693 assert_eq!(new_lock.items.len(), 2);
1694 assert!(new_lock.items.contains_key("agent/coder"));
1695 assert!(new_lock.items.contains_key("skill/planning"));
1696 }
1697
1698 #[test]
1699 fn re_sync_no_changes() {
1700 let mut fixture = TestFixture::new();
1701 let content = "# Coder agent";
1702 let src_idx = fixture.add_source(&[("coder.md", content)], &[]);
1703
1704 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1705
1706 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1708 let lock = LockFile::empty();
1709 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1710 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1711 let options = SyncOptions {
1712 force: false,
1713 dry_run: false,
1714 frozen: false,
1715 no_refresh_models: false,
1716 };
1717 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1718 let result =
1719 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1720 let first_lock =
1721 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1722
1723 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1725 let sync_diff2 =
1726 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1727
1728 for entry in &sync_diff2.items {
1730 assert!(
1731 matches!(entry, diff::DiffEntry::Unchanged { .. }),
1732 "expected Unchanged, got {entry:?}"
1733 );
1734 }
1735
1736 let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1737 for action in &sync_plan2.actions {
1738 assert!(matches!(action, plan::PlannedAction::Skip { .. }));
1739 }
1740 }
1741
1742 #[test]
1743 fn source_update_detects_changes() {
1744 let mut fixture = TestFixture::new();
1745 let src_idx = fixture.add_source(&[("coder.md", "# Version 1")], &[]);
1746
1747 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1748
1749 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1751 let lock = LockFile::empty();
1752 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1753 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1754 let options = SyncOptions {
1755 force: false,
1756 dry_run: false,
1757 frozen: false,
1758 no_refresh_models: false,
1759 };
1760 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1761 let result =
1762 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1763 let first_lock =
1764 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1765
1766 let agents_dir = fixture.tree_path(src_idx).join("agents");
1768 fs::write(agents_dir.join("coder.md"), "# Version 2").unwrap();
1769
1770 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1772 let sync_diff2 =
1773 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1774
1775 assert_eq!(sync_diff2.items.len(), 1);
1777 assert!(matches!(
1778 &sync_diff2.items[0],
1779 diff::DiffEntry::Update { .. }
1780 ));
1781 }
1782
1783 #[test]
1784 fn local_modification_preserved() {
1785 let mut fixture = TestFixture::new();
1786 let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1787
1788 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1789
1790 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1792 let lock = LockFile::empty();
1793 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1794 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1795 let options = SyncOptions {
1796 force: false,
1797 dry_run: false,
1798 frozen: false,
1799 no_refresh_models: false,
1800 };
1801 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1802 let result =
1803 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1804 let first_lock =
1805 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1806
1807 fs::write(
1809 fixture.managed_root().join("agents/coder.md"),
1810 "# Locally modified",
1811 )
1812 .unwrap();
1813
1814 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1816 let sync_diff2 =
1817 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1818
1819 assert_eq!(sync_diff2.items.len(), 1);
1821 assert!(matches!(
1822 &sync_diff2.items[0],
1823 diff::DiffEntry::LocalModified { .. }
1824 ));
1825
1826 let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1828 assert!(matches!(
1829 &sync_plan2.actions[0],
1830 plan::PlannedAction::KeepLocal { .. }
1831 ));
1832 }
1833
1834 #[test]
1835 fn force_overwrites_local_modifications() {
1836 let mut fixture = TestFixture::new();
1837 let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1838
1839 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1840
1841 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1843 let lock = LockFile::empty();
1844 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1845 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1846 let options = SyncOptions {
1847 force: false,
1848 dry_run: false,
1849 frozen: false,
1850 no_refresh_models: false,
1851 };
1852 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1853 let result =
1854 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1855 let first_lock =
1856 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1857
1858 fs::write(
1860 fixture.managed_root().join("agents/coder.md"),
1861 "# Locally modified",
1862 )
1863 .unwrap();
1864
1865 let agents_dir = fixture.tree_path(src_idx).join("agents");
1867 fs::write(agents_dir.join("coder.md"), "# Upstream update").unwrap();
1868
1869 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1871 let sync_diff2 =
1872 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1873
1874 let force_options = SyncOptions {
1875 force: true,
1876 dry_run: false,
1877 frozen: false,
1878 no_refresh_models: false,
1879 };
1880 let sync_plan2 = create_sync_plan(&sync_diff2, &force_options, &cache_dir);
1881 assert!(matches!(
1882 &sync_plan2.actions[0],
1883 plan::PlannedAction::Overwrite { .. }
1884 ));
1885
1886 let result2 = apply::execute(
1887 fixture.managed_root(),
1888 &sync_plan2,
1889 &force_options,
1890 &cache_dir,
1891 )
1892 .unwrap();
1893 assert!(matches!(
1894 result2.outcomes[0].action,
1895 apply::ActionTaken::Updated
1896 ));
1897
1898 let content = fs::read_to_string(fixture.managed_root().join("agents/coder.md")).unwrap();
1900 assert_eq!(content, "# Upstream update");
1901 }
1902
1903 #[test]
1904 fn orphan_removed_when_source_drops_item() {
1905 let mut fixture = TestFixture::new();
1906 let src_idx = fixture.add_source(
1907 &[("coder.md", "# Coder"), ("reviewer.md", "# Reviewer")],
1908 &[],
1909 );
1910
1911 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1912
1913 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1915 let lock = LockFile::empty();
1916 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1917 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1918 let options = SyncOptions {
1919 force: false,
1920 dry_run: false,
1921 frozen: false,
1922 no_refresh_models: false,
1923 };
1924 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1925 let result =
1926 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1927 let first_lock =
1928 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1929
1930 assert!(fixture.managed_root().join("agents/coder.md").exists());
1931 assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1932
1933 fs::remove_file(fixture.tree_path(src_idx).join("agents/reviewer.md")).unwrap();
1935
1936 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1938 let sync_diff2 =
1939 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1940
1941 let orphan_count = sync_diff2
1943 .items
1944 .iter()
1945 .filter(|e| matches!(e, diff::DiffEntry::Orphan { .. }))
1946 .count();
1947 assert_eq!(orphan_count, 1);
1948
1949 let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1950 let result2 =
1951 apply::execute(fixture.managed_root(), &sync_plan2, &options, &cache_dir).unwrap();
1952
1953 assert!(!fixture.managed_root().join("agents/reviewer.md").exists());
1955 assert!(fixture.managed_root().join("agents/coder.md").exists());
1957
1958 let removed = result2
1960 .outcomes
1961 .iter()
1962 .any(|o| matches!(o.action, apply::ActionTaken::Removed));
1963 assert!(removed);
1964 }
1965
1966 #[test]
1967 fn dry_run_produces_plan_without_changes() {
1968 let mut fixture = TestFixture::new();
1969 let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1970
1971 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1972
1973 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1974 let lock = LockFile::empty();
1975 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1976
1977 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1978 let dry_options = SyncOptions {
1979 force: false,
1980 dry_run: true,
1981 frozen: false,
1982 no_refresh_models: false,
1983 };
1984
1985 let sync_plan = create_sync_plan(&sync_diff, &dry_options, &cache_dir);
1986 assert!(!sync_plan.actions.is_empty());
1987
1988 let result =
1990 apply::execute(fixture.managed_root(), &sync_plan, &dry_options, &cache_dir).unwrap();
1991 assert!(!result.outcomes.is_empty());
1992
1993 assert!(!fixture.managed_root().join("agents/coder.md").exists());
1995 }
1996
1997 #[test]
1998 fn lock_written_after_apply() {
1999 let mut fixture = TestFixture::new();
2000 let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
2001
2002 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
2003
2004 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2006 let lock = LockFile::empty();
2007 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
2008 let cache_dir = fixture.project_root().join(".mars/cache/bases");
2009 let options = SyncOptions {
2010 force: false,
2011 dry_run: false,
2012 frozen: false,
2013 no_refresh_models: false,
2014 };
2015 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
2016 let result =
2017 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
2018
2019 let new_lock =
2020 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
2021 crate::lock::write(fixture.project_root(), &new_lock).unwrap();
2022
2023 let reloaded = crate::lock::load(fixture.project_root()).unwrap();
2025 assert_eq!(reloaded.items.len(), 1);
2026 assert!(reloaded.items.contains_key("agent/coder"));
2027
2028 let item = &reloaded.items["agent/coder"];
2029 assert_eq!(item.kind, ItemKind::Agent);
2030 assert!(!item.source_checksum.is_empty());
2031 assert!(!item.outputs[0].installed_checksum.is_empty());
2032 }
2033
2034 #[test]
2035 fn two_sources_no_collision() {
2036 let mut fixture = TestFixture::new();
2037 let src_a = fixture.add_source(&[("coder.md", "# Coder from A")], &[]);
2038 let src_b = fixture.add_source(&[("reviewer.md", "# Reviewer from B")], &[]);
2039
2040 let (graph, config) = make_graph_config(
2041 &fixture,
2042 vec![
2043 ("source-a", src_a, FilterMode::All),
2044 ("source-b", src_b, FilterMode::All),
2045 ],
2046 );
2047
2048 let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
2049 assert!(renames.is_empty());
2050 assert_eq!(target.items.len(), 2);
2051
2052 let lock = LockFile::empty();
2053 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
2054 let cache_dir = fixture.project_root().join(".mars/cache/bases");
2055 let options = SyncOptions {
2056 force: false,
2057 dry_run: false,
2058 frozen: false,
2059 no_refresh_models: false,
2060 };
2061 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
2062 let result =
2063 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
2064
2065 assert!(fixture.managed_root().join("agents/coder.md").exists());
2066 assert!(fixture.managed_root().join("agents/reviewer.md").exists());
2067 assert_eq!(result.outcomes.len(), 2);
2068 }
2069
2070 #[test]
2073 fn pipeline_only_skills_filter() {
2074 let mut fixture = TestFixture::new();
2075 let src_idx = fixture.add_source(
2076 &[("coder.md", "# Coder agent")],
2077 &[("planning", "# Planning skill")],
2078 );
2079
2080 let (graph, config) =
2081 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlySkills)]);
2082
2083 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2084 assert_eq!(target.items.len(), 1);
2086 assert!(target.items.contains_key("skills/planning"));
2087 }
2088
2089 #[test]
2090 fn pipeline_only_agents_filter() {
2091 let mut fixture = TestFixture::new();
2092 let agent_content = "---\nskills:\n - planning\n---\n# Coder agent";
2094 let src_idx = fixture.add_source(
2095 &[("coder.md", agent_content)],
2096 &[
2097 ("planning", "# Planning skill"),
2098 ("standalone", "# Standalone skill"),
2099 ],
2100 );
2101
2102 let (graph, config) =
2103 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
2104
2105 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2106 assert_eq!(target.items.len(), 2);
2108 assert!(target.items.contains_key("agents/coder.md"));
2109 assert!(target.items.contains_key("skills/planning"));
2110 assert!(!target.items.contains_key("skills/standalone"));
2111 }
2112
2113 #[test]
2114 fn pipeline_only_agents_no_agents_source() {
2115 let mut fixture = TestFixture::new();
2116 let src_idx = fixture.add_source(&[], &[("planning", "# Planning skill")]);
2117
2118 let (graph, config) =
2119 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
2120
2121 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2122 assert_eq!(target.items.len(), 0);
2124 }
2125}