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