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