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 agent_surface_policy: crate::compiler::AgentSurfacePolicy,
496 diag: &mut DiagnosticCollector,
497) -> SyncedState {
498 if request.options.dry_run {
499 return SyncedState {
500 applied,
501 target_outcomes: Vec::new(),
502 config_entries: BTreeMap::new(),
503 };
504 }
505
506 let mars_dir = ctx.project_root.join(".mars");
507 let targets = applied
508 .planned
509 .targeted
510 .resolved
511 .loaded
512 .effective
513 .settings
514 .managed_targets();
515 let previous_managed_paths = applied
516 .planned
517 .targeted
518 .resolved
519 .loaded
520 .old_lock
521 .all_output_dest_paths()
522 .map(|dest_path| dest_path.to_string())
523 .collect::<HashSet<String>>();
524
525 let outcomes;
526 let target_outcomes_source = if matches!(
527 agent_surface_policy,
528 crate::compiler::AgentSurfacePolicy::SuppressAll
529 ) {
530 outcomes = crate::compiler::suppress_agent_outcomes(&applied.applied.outcomes);
531 &outcomes
532 } else {
533 &applied.applied.outcomes
534 };
535
536 let target_outcomes = crate::target_sync::sync_managed_targets(
537 &ctx.project_root,
538 &mars_dir,
539 &targets,
540 target_outcomes_source,
541 &previous_managed_paths,
542 request.options.force,
543 diag,
544 );
545
546 SyncedState {
547 applied,
548 target_outcomes,
549 config_entries: BTreeMap::new(),
550 }
551}
552
553pub(crate) fn finalize(
557 ctx: &MarsContext,
558 state: SyncedState,
559 request: &SyncRequest,
560 diag: &mut DiagnosticCollector,
561) -> Result<SyncReport, MarsError> {
562 let project_root = &ctx.project_root;
563 let old_lock = &state.applied.planned.targeted.resolved.loaded.old_lock;
564 let graph = &state.applied.planned.targeted.resolved.graph;
565
566 if !request.options.dry_run {
568 let new_lock = crate::lock::build(
569 graph,
570 &state.applied.applied,
571 old_lock,
572 state.config_entries,
573 )?;
574 crate::lock::write(project_root, &new_lock)?;
575
576 let dep_models = declaration_ordered_dep_models(
580 graph,
581 &state.applied.planned.targeted.resolved.loaded.effective,
582 );
583 let empty_consumer: indexmap::IndexMap<String, crate::models::ModelAlias> =
584 indexmap::IndexMap::new();
585 let mut ignored_diag = DiagnosticCollector::new();
586 let dep_model_aliases = crate::models::merge_model_config(
587 &empty_consumer,
588 &dep_models,
589 &mut ignored_diag,
590 None,
591 );
592
593 let mars_path = ctx.project_root.join(".mars");
597 let ttl = crate::models::load_models_cache_ttl(ctx);
598 let mode = crate::models::resolve_refresh_mode(request.options.no_refresh_models);
599 match crate::models::ensure_fresh(&mars_path, ttl, mode) {
600 Ok((_, crate::models::RefreshOutcome::StaleFallback { reason })) => {
601 diag.warn(
602 "models-cache-refresh",
603 format!("using stale models cache: {reason}"),
604 );
605 }
606 Ok((_, crate::models::RefreshOutcome::Offline)) => {}
607 Ok(_) => {}
608 Err(err) => {
609 diag.warn(
610 "models-cache-refresh",
611 format!("failed to refresh models cache: {err}"),
612 );
613 }
614 }
615
616 match serde_json::to_string_pretty(&dep_model_aliases) {
617 Ok(json) => {
618 let merged_path = ctx.project_root.join(".mars").join("models-merged.json");
619 if let Err(err) = crate::fs::atomic_write(&merged_path, json.as_bytes()) {
620 diag.warn(
621 "models-merge-write",
622 format!("failed to write models-merged.json: {err}"),
623 );
624 }
625 }
626 Err(err) => {
627 diag.warn(
628 "models-merge-write",
629 format!("failed to serialize merged model aliases: {err}"),
630 );
631 }
632 }
633 }
634
635 for w in &state.applied.planned.targeted.warnings {
636 match w {
637 ValidationWarning::MissingSkill {
638 agent,
639 skill_name,
640 suggestion,
641 } => {
642 let msg = match suggestion {
643 Some(s) => format!(
644 "agent `{}` references missing skill `{}` (did you mean `{}`?)",
645 agent.name, skill_name, s
646 ),
647 None => {
648 format!(
649 "agent `{}` references missing skill `{}`",
650 agent.name, skill_name
651 )
652 }
653 };
654 diag.warn("missing-skill", msg);
655 }
656 }
657 }
658 let dependency_changes = state
659 .applied
660 .planned
661 .targeted
662 .resolved
663 .loaded
664 .dependency_changes;
665 let upgrades_available = if request.options.frozen {
666 0
667 } else {
668 graph
669 .nodes
670 .values()
671 .filter(|node| {
672 matches!(
673 (&node.resolved_ref.version, &node.latest_version),
674 (Some(resolved), Some(latest)) if latest > resolved
675 )
676 })
677 .count()
678 };
679
680 Ok(SyncReport {
681 applied: state.applied.applied,
682 pruned: Vec::new(),
683 diagnostics: diag.drain(),
684 dependency_changes,
685 upgrades_available,
686 target_outcomes: state.target_outcomes,
687 dry_run: request.options.dry_run,
688 })
689}
690
691fn declaration_ordered_dep_models(
692 graph: &ResolvedGraph,
693 config: &EffectiveConfig,
694) -> Vec<crate::models::ResolvedDepModels> {
695 let mut decl_pos: HashMap<SourceName, usize> = HashMap::new();
697 for (idx, name) in config.dependencies.keys().enumerate() {
698 decl_pos.insert(name.clone(), idx);
699 }
700
701 for (idx, sponsor) in config.dependencies.keys().enumerate() {
704 let Some(sponsor_node) = graph.nodes.get(sponsor) else {
705 continue;
706 };
707
708 let mut queue: VecDeque<SourceName> = sponsor_node.deps.iter().cloned().collect();
709 let mut visited: HashSet<SourceName> = HashSet::new();
710
711 while let Some(dep) = queue.pop_front() {
712 if !visited.insert(dep.clone()) {
713 continue;
714 }
715
716 decl_pos
717 .entry(dep.clone())
718 .and_modify(|pos| *pos = (*pos).min(idx))
719 .or_insert(idx);
720
721 if let Some(dep_node) = graph.nodes.get(&dep) {
722 queue.extend(dep_node.deps.iter().cloned());
723 }
724 }
725 }
726
727 let mut in_degree: HashMap<SourceName, usize> = HashMap::new();
730 let mut adjacency: HashMap<SourceName, Vec<SourceName>> = HashMap::new();
731
732 for name in graph.nodes.keys() {
733 in_degree.entry(name.clone()).or_insert(0);
734 adjacency.entry(name.clone()).or_default();
735 }
736
737 for (name, node) in &graph.nodes {
738 for dep in &node.deps {
739 if graph.nodes.contains_key(dep) {
740 *in_degree.entry(name.clone()).or_insert(0) += 1;
741 adjacency.entry(dep.clone()).or_default().push(name.clone());
742 }
743 }
744 }
745
746 let mut ready: BinaryHeap<Reverse<(usize, SourceName)>> = BinaryHeap::new();
747 for (name, degree) in &in_degree {
748 if *degree == 0 {
749 let position = decl_pos.get(name).copied().unwrap_or(usize::MAX);
750 ready.push(Reverse((position, name.clone())));
751 }
752 }
753
754 let mut ordered: Vec<SourceName> = Vec::with_capacity(graph.nodes.len());
755 while let Some(Reverse((_, current))) = ready.pop() {
756 ordered.push(current.clone());
757
758 if let Some(dependents) = adjacency.get(¤t) {
759 for dependent in dependents {
760 if let Some(degree) = in_degree.get_mut(dependent) {
761 *degree -= 1;
762 if *degree == 0 {
763 let position = decl_pos.get(dependent).copied().unwrap_or(usize::MAX);
764 ready.push(Reverse((position, dependent.clone())));
765 }
766 }
767 }
768 }
769 }
770
771 let ordered_names: Vec<SourceName> = if ordered.len() == graph.nodes.len() {
774 ordered
775 } else {
776 graph.order.clone()
777 };
778
779 ordered_names
780 .iter()
781 .filter_map(|name| {
782 let node = graph.nodes.get(name)?;
783 let manifest = node.manifest.as_ref()?;
784 if manifest.models.is_empty() {
785 return None;
786 }
787 Some(crate::models::ResolvedDepModels {
788 source_name: name.to_string(),
789 models: manifest.models.clone(),
790 })
791 })
792 .collect()
793}
794
795fn default_dest_path(kind: ItemKind, name: &str) -> DestPath {
796 match kind {
797 ItemKind::Agent => DestPath::from(format!("agents/{name}.md")),
798 ItemKind::Skill => DestPath::from(format!("skills/{name}")),
799 ItemKind::Hook => DestPath::from(format!("hooks/{name}")),
800 ItemKind::McpServer => DestPath::from(format!("mcp/{name}")),
801 ItemKind::BootstrapDoc => DestPath::from(format!("bootstrap/{name}/BOOTSTRAP.md")),
802 }
803}
804
805fn validate_request(request: &SyncRequest) -> Result<(), MarsError> {
806 if request.options.frozen && matches!(request.resolution, ResolutionMode::Maximize { .. }) {
807 return Err(MarsError::InvalidRequest {
808 message:
809 "cannot use --frozen with upgrade (frozen locks versions; upgrade maximizes them)"
810 .to_string(),
811 });
812 }
813
814 if request.options.frozen && request.mutation.is_some() {
815 return Err(MarsError::InvalidRequest {
816 message:
817 "cannot modify config in --frozen mode (config change would require lock update)"
818 .to_string(),
819 });
820 }
821
822 Ok(())
823}
824
825fn validate_targets(
826 resolution: &ResolutionMode,
827 effective: &EffectiveConfig,
828) -> Result<(), MarsError> {
829 if let ResolutionMode::Maximize { targets, .. } = resolution {
830 for name in targets {
831 if !effective.dependencies.contains_key(name) {
832 return Err(MarsError::Source {
833 source_name: name.to_string(),
834 message: format!("dependency `{name}` not found in mars.toml"),
835 });
836 }
837 }
838 }
839
840 Ok(())
841}
842
843fn to_resolve_options(mode: &ResolutionMode, frozen: bool) -> ResolveOptions {
844 match mode {
845 ResolutionMode::Normal => ResolveOptions {
846 frozen,
847 ..ResolveOptions::default()
848 },
849 ResolutionMode::Maximize { targets, bump } => ResolveOptions {
850 maximize: true,
851 upgrade_targets: targets.clone(),
852 bump_direct_constraints: *bump,
853 frozen,
854 },
855 }
856}
857
858fn planned_bump_entries(
859 config: &Config,
860 graph: &ResolvedGraph,
861 mode: &ResolutionMode,
862) -> Vec<(SourceName, crate::config::DependencyEntry)> {
863 let ResolutionMode::Maximize {
864 targets,
865 bump: true,
866 } = mode
867 else {
868 return Vec::new();
869 };
870
871 config
872 .dependencies
873 .iter()
874 .filter_map(|(name, entry)| {
875 if !targets.is_empty() && !targets.contains(name) {
876 return None;
877 }
878 entry.url.as_ref()?;
880 let node = graph.nodes.get(name)?;
881 let resolved_version = node.resolved_ref.version.as_ref()?;
882 let resolved_tag = node.resolved_ref.version_tag.as_ref()?;
883 if !constraint_needs_bump(entry.version.as_deref(), resolved_version) {
884 return None;
885 }
886 if entry.version.as_deref() == Some(resolved_tag.as_str()) {
887 return None;
888 }
889 let mut bumped = entry.clone();
890 bumped.version = Some(resolved_tag.clone());
891 Some((name.clone(), bumped))
892 })
893 .collect()
894}
895
896fn constraint_needs_bump(current: Option<&str>, resolved: &semver::Version) -> bool {
897 match crate::resolve::parse_version_constraint(current) {
898 crate::resolve::VersionConstraint::Semver(req) => !req.matches(resolved),
899 crate::resolve::VersionConstraint::Latest
900 | crate::resolve::VersionConstraint::RefPin(_) => false,
901 }
902}
903
904fn has_version_changes(changes: &[DependencyUpsertChange]) -> bool {
905 changes
906 .iter()
907 .any(|change| change.old_version != change.new_version)
908}
909
910fn validate_skill_refs(
913 install_target: &std::path::Path,
914 target: &target::TargetState,
915) -> Vec<ValidationWarning> {
916 use crate::lock::ItemKind;
917
918 let available_skills: HashSet<String> = target
920 .items
921 .values()
922 .filter(|item| item.id.kind == ItemKind::Skill)
923 .map(|item| item.id.name.to_string())
924 .collect();
925
926 let agents: Vec<(String, PathBuf)> = target
928 .items
929 .values()
930 .filter(|item| item.id.kind == ItemKind::Agent)
931 .map(|item| {
932 let disk_path = item.dest_path.resolve(install_target);
933 let path = if disk_path.exists() {
936 disk_path
937 } else {
938 item.source_path.clone()
939 };
940 (item.id.name.to_string(), path)
941 })
942 .collect();
943
944 crate::validate::check_deps(&agents, &available_skills).unwrap_or_default()
945}
946
947fn validate_skill_frontmatter_in_target(
948 target: &target::TargetState,
949 diag: &mut DiagnosticCollector,
950) {
951 use crate::lock::ItemKind;
952
953 for item in target
954 .items
955 .values()
956 .filter(|item| item.id.kind == ItemKind::Skill)
957 {
958 validate_skill_frontmatter_at_source(&item.source_path, item.id.name.as_str(), diag);
959 }
960}
961
962fn validate_skill_frontmatter_at_source(
963 source_path: &Path,
964 skill_name: &str,
965 diag: &mut DiagnosticCollector,
966) {
967 let skill_md = if source_path.is_dir() {
968 source_path.join("SKILL.md")
969 } else {
970 source_path.to_path_buf()
971 };
972 let Ok(content) = std::fs::read_to_string(&skill_md) else {
973 return;
974 };
975 let mut skill_diags = Vec::new();
976 let _ = crate::compiler::skills::parse_skill_content(&content, &mut skill_diags);
977 for d in skill_diags {
978 if d.is_error() {
979 diag.error_with_category(
980 "skill-schema-error",
981 format!("skill `{skill_name}`: {}", d.message()),
982 crate::diagnostic::DiagnosticCategory::Validation,
983 );
984 } else {
985 diag.warn(
986 "skill-schema-warning",
987 format!("skill `{skill_name}`: {}", d.message()),
988 );
989 }
990 }
991}
992
993#[cfg(test)]
994mod tests {
995 use super::*;
996 use crate::config::*;
997 use crate::lock::{ItemKind, LockFile};
998 use crate::resolve::{ResolvedGraph, ResolvedNode};
999 use indexmap::IndexMap;
1000 use std::fs;
1001 use tempfile::TempDir;
1002
1003 struct TestFixture {
1005 project_root: TempDir,
1006 managed_root: PathBuf,
1007 source_trees: Vec<TempDir>,
1008 }
1009
1010 impl TestFixture {
1011 fn new() -> Self {
1012 let project_root = TempDir::new().unwrap();
1013 let managed_root = project_root.path().join(".agents");
1014 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
1016 TestFixture {
1017 project_root,
1018 managed_root,
1019 source_trees: Vec::new(),
1020 }
1021 }
1022
1023 fn add_source(&mut self, agents: &[(&str, &str)], skills: &[(&str, &str)]) -> usize {
1024 let dir = TempDir::new().unwrap();
1025 if !agents.is_empty() {
1026 let agents_dir = dir.path().join("agents");
1027 fs::create_dir_all(&agents_dir).unwrap();
1028 for (name, content) in agents {
1029 fs::write(agents_dir.join(name), content).unwrap();
1030 }
1031 }
1032 if !skills.is_empty() {
1033 let skills_dir = dir.path().join("skills");
1034 fs::create_dir_all(&skills_dir).unwrap();
1035 for (name, content) in skills {
1036 let skill_dir = skills_dir.join(name);
1037 fs::create_dir_all(&skill_dir).unwrap();
1038 fs::write(skill_dir.join("SKILL.md"), content).unwrap();
1039 }
1040 }
1041 self.source_trees.push(dir);
1042 self.source_trees.len() - 1
1043 }
1044
1045 fn project_root(&self) -> &std::path::Path {
1046 self.project_root.path()
1047 }
1048
1049 fn managed_root(&self) -> &std::path::Path {
1050 &self.managed_root
1051 }
1052
1053 fn tree_path(&self, idx: usize) -> PathBuf {
1054 self.source_trees[idx].path().to_path_buf()
1055 }
1056 }
1057
1058 fn make_graph_config(
1059 fixture: &TestFixture,
1060 sources: Vec<(&str, usize, FilterMode)>,
1061 ) -> (ResolvedGraph, EffectiveConfig) {
1062 let mut nodes = IndexMap::new();
1063 let mut order = Vec::new();
1064 let mut config_dependencies = IndexMap::new();
1065
1066 for (name, tree_idx, filter) in sources {
1067 let tree_path = fixture.tree_path(tree_idx);
1068 nodes.insert(
1069 name.into(),
1070 ResolvedNode {
1071 source_name: name.into(),
1072 source_id: crate::types::SourceId::Path {
1073 canonical: tree_path.clone(),
1074 subpath: None,
1075 },
1076 rooted_ref: crate::resolve::RootedSourceRef {
1077 checkout_root: tree_path.clone(),
1078 package_root: tree_path.clone(),
1079 },
1080 resolved_ref: crate::source::ResolvedRef {
1081 source_name: name.into(),
1082 version: None,
1083 version_tag: None,
1084 commit: None,
1085 tree_path: tree_path.clone(),
1086 },
1087 latest_version: None,
1088 manifest: None,
1089 deps: vec![],
1090 },
1091 );
1092 order.push(name.into());
1093
1094 config_dependencies.insert(
1095 name.into(),
1096 EffectiveDependency {
1097 name: name.into(),
1098 id: crate::types::SourceId::Path {
1099 canonical: tree_path.clone(),
1100 subpath: None,
1101 },
1102 spec: SourceSpec::Path(tree_path),
1103 subpath: None,
1104 filter,
1105 rename: crate::types::RenameMap::new(),
1106 is_overridden: false,
1107 original_git: None,
1108 },
1109 );
1110 }
1111
1112 (
1113 ResolvedGraph {
1114 nodes,
1115 order,
1116 filters: std::collections::HashMap::new(),
1117 },
1118 EffectiveConfig {
1119 dependencies: config_dependencies,
1120 settings: Settings::default(),
1121 },
1122 )
1123 }
1124
1125 fn path_dependency_entry(path: &std::path::Path) -> DependencyEntry {
1126 DependencyEntry {
1127 url: None,
1128 path: Some(path.to_path_buf()),
1129 subpath: None,
1130 version: None,
1131 filter: FilterConfig::default(),
1132 }
1133 }
1134
1135 fn git_dependency_entry(url: &str, version: &str, filter: FilterConfig) -> DependencyEntry {
1136 DependencyEntry {
1137 url: Some(url.into()),
1138 path: None,
1139 subpath: None,
1140 version: Some(version.to_string()),
1141 filter,
1142 }
1143 }
1144
1145 fn create_sync_plan(
1146 sync_diff: &diff::SyncDiff,
1147 options: &SyncOptions,
1148 cache_bases_dir: &std::path::Path,
1149 ) -> plan::SyncPlan {
1150 let mut diag = DiagnosticCollector::new();
1151 plan::create(sync_diff, options, cache_bases_dir, &mut diag)
1152 }
1153
1154 fn graph_with_versions(entries: &[(&str, &str, &str)]) -> ResolvedGraph {
1155 let mut nodes = IndexMap::new();
1156 let mut order = Vec::new();
1157 for (name, url, tag) in entries {
1158 let version = semver::Version::parse(tag.trim_start_matches('v')).unwrap();
1159 nodes.insert(
1160 (*name).into(),
1161 ResolvedNode {
1162 source_name: (*name).into(),
1163 source_id: crate::types::SourceId::git(crate::types::SourceUrl::from(*url)),
1164 rooted_ref: crate::resolve::RootedSourceRef {
1165 checkout_root: PathBuf::from(format!("/tmp/{name}")),
1166 package_root: PathBuf::from(format!("/tmp/{name}")),
1167 },
1168 resolved_ref: crate::source::ResolvedRef {
1169 source_name: (*name).into(),
1170 version: Some(version),
1171 version_tag: Some((*tag).to_string()),
1172 commit: Some("abc123".into()),
1173 tree_path: PathBuf::from(format!("/tmp/{name}")),
1174 },
1175 latest_version: None,
1176 manifest: None,
1177 deps: vec![],
1178 },
1179 );
1180 order.push((*name).into());
1181 }
1182
1183 ResolvedGraph {
1184 nodes,
1185 order,
1186 filters: std::collections::HashMap::new(),
1187 }
1188 }
1189
1190 fn model_alias(model: &str) -> crate::models::ModelAlias {
1191 crate::models::ModelAlias {
1192 harness: None,
1193 description: None,
1194 default_effort: None,
1195 autocompact: None,
1196 spec: crate::models::ModelSpec::Pinned {
1197 model: model.to_string(),
1198 provider: None,
1199 },
1200 }
1201 }
1202
1203 fn manifest_with_models(name: &str) -> Manifest {
1204 let mut models = IndexMap::new();
1205 models.insert(
1206 format!("{name}-alias"),
1207 model_alias(&format!("{name}-model")),
1208 );
1209 Manifest {
1210 package: PackageInfo {
1211 name: name.to_string(),
1212 version: "1.0.0".to_string(),
1213 description: None,
1214 },
1215 dependencies: IndexMap::new(),
1216 models,
1217 }
1218 }
1219
1220 fn resolved_node(name: &str, deps: &[&str], with_models: bool) -> ResolvedNode {
1221 let canonical = PathBuf::from(format!("/tmp/{name}"));
1222 ResolvedNode {
1223 source_name: name.into(),
1224 source_id: crate::types::SourceId::Path {
1225 canonical: canonical.clone(),
1226 subpath: None,
1227 },
1228 rooted_ref: crate::resolve::RootedSourceRef {
1229 checkout_root: canonical.clone(),
1230 package_root: canonical.clone(),
1231 },
1232 resolved_ref: crate::source::ResolvedRef {
1233 source_name: name.into(),
1234 version: None,
1235 version_tag: None,
1236 commit: None,
1237 tree_path: canonical,
1238 },
1239 latest_version: None,
1240 manifest: with_models.then(|| manifest_with_models(name)),
1241 deps: deps.iter().map(|dep| (*dep).into()).collect(),
1242 }
1243 }
1244
1245 fn effective_config_with_decl_order(names: &[&str]) -> EffectiveConfig {
1246 let mut dependencies = IndexMap::new();
1247 for name in names {
1248 let canonical = PathBuf::from(format!("/tmp/dep-{name}"));
1249 dependencies.insert(
1250 (*name).into(),
1251 EffectiveDependency {
1252 name: (*name).into(),
1253 id: crate::types::SourceId::Path {
1254 canonical: canonical.clone(),
1255 subpath: None,
1256 },
1257 spec: SourceSpec::Path(canonical),
1258 subpath: None,
1259 filter: FilterMode::All,
1260 rename: crate::types::RenameMap::new(),
1261 is_overridden: false,
1262 original_git: None,
1263 },
1264 );
1265 }
1266 EffectiveConfig {
1267 dependencies,
1268 settings: Settings::default(),
1269 }
1270 }
1271
1272 fn dep_model_names(models: &[crate::models::ResolvedDepModels]) -> Vec<String> {
1273 models.iter().map(|m| m.source_name.clone()).collect()
1274 }
1275
1276 #[test]
1277 fn declaration_ordered_dep_models_sibling_order() {
1278 let mut nodes = IndexMap::new();
1279 nodes.insert("a".into(), resolved_node("a", &[], true));
1280 nodes.insert("b".into(), resolved_node("b", &[], true));
1281
1282 let graph = ResolvedGraph {
1283 nodes,
1284 order: vec!["a".into(), "b".into()],
1285 filters: std::collections::HashMap::new(),
1286 };
1287 let config = effective_config_with_decl_order(&["a", "b"]);
1288
1289 let dep_models = declaration_ordered_dep_models(&graph, &config);
1290 assert_eq!(dep_model_names(&dep_models), vec!["a", "b"]);
1291 }
1292
1293 #[test]
1294 fn declaration_ordered_dep_models_diamond_uses_minimum_sponsor_position() {
1295 let mut nodes = IndexMap::new();
1296 nodes.insert("a".into(), resolved_node("a", &["d"], true));
1297 nodes.insert("b".into(), resolved_node("b", &["d"], true));
1298 nodes.insert("d".into(), resolved_node("d", &[], true));
1299
1300 let graph = ResolvedGraph {
1301 nodes,
1302 order: vec!["d".into(), "a".into(), "b".into()],
1303 filters: std::collections::HashMap::new(),
1304 };
1305 let config = effective_config_with_decl_order(&["a", "b"]);
1306
1307 let dep_models = declaration_ordered_dep_models(&graph, &config);
1308 assert_eq!(dep_model_names(&dep_models), vec!["d", "a", "b"]);
1309 }
1310
1311 #[test]
1312 fn declaration_ordered_dep_models_transitives_follow_sponsor_declaration_order() {
1313 let mut nodes = IndexMap::new();
1314 nodes.insert("a".into(), resolved_node("a", &["d"], false));
1315 nodes.insert("b".into(), resolved_node("b", &["e"], false));
1316 nodes.insert("d".into(), resolved_node("d", &[], true));
1317 nodes.insert("e".into(), resolved_node("e", &[], true));
1318
1319 let graph = ResolvedGraph {
1320 nodes,
1321 order: vec!["d".into(), "e".into(), "a".into(), "b".into()],
1322 filters: std::collections::HashMap::new(),
1323 };
1324 let config = effective_config_with_decl_order(&["a", "b"]);
1325
1326 let dep_models = declaration_ordered_dep_models(&graph, &config);
1327 assert_eq!(dep_model_names(&dep_models), vec!["d", "e"]);
1328 }
1329
1330 #[test]
1331 fn declaration_ordered_dep_models_keeps_deps_before_dependents() {
1332 let mut nodes = IndexMap::new();
1333 nodes.insert("a".into(), resolved_node("a", &["d"], true));
1334 nodes.insert("d".into(), resolved_node("d", &[], true));
1335
1336 let graph = ResolvedGraph {
1337 nodes,
1338 order: vec!["d".into(), "a".into()],
1339 filters: std::collections::HashMap::new(),
1340 };
1341 let config = effective_config_with_decl_order(&["a", "d"]);
1343
1344 let dep_models = declaration_ordered_dep_models(&graph, &config);
1345 assert_eq!(dep_model_names(&dep_models), vec!["d", "a"]);
1346 }
1347
1348 #[test]
1349 fn declaration_ordered_dep_models_is_deterministic() {
1350 let mut nodes = IndexMap::new();
1351 nodes.insert("a".into(), resolved_node("a", &["d"], true));
1352 nodes.insert("b".into(), resolved_node("b", &["e"], true));
1353 nodes.insert("d".into(), resolved_node("d", &[], true));
1354 nodes.insert("e".into(), resolved_node("e", &[], true));
1355
1356 let graph = ResolvedGraph {
1357 nodes,
1358 order: vec!["d".into(), "e".into(), "a".into(), "b".into()],
1359 filters: std::collections::HashMap::new(),
1360 };
1361 let config = effective_config_with_decl_order(&["a", "b"]);
1362
1363 let first = dep_model_names(&declaration_ordered_dep_models(&graph, &config));
1364 for _ in 0..10 {
1365 let current = dep_model_names(&declaration_ordered_dep_models(&graph, &config));
1366 assert_eq!(current, first);
1367 }
1368 }
1369
1370 #[test]
1371 fn declaration_ordered_dep_models_is_used_by_resolve_graph_and_finalize() {
1372 let source = include_str!("mod.rs");
1373 assert!(source.contains("declaration_ordered_dep_models(&graph, &loaded.effective)"));
1374 assert!(source.contains("&state.applied.planned.targeted.resolved.loaded.effective"));
1375 }
1376
1377 #[test]
1378 fn validate_request_rejects_frozen_with_maximize() {
1379 let request = SyncRequest {
1380 resolution: ResolutionMode::Maximize {
1381 targets: HashSet::new(),
1382 bump: false,
1383 },
1384 mutation: None,
1385 options: SyncOptions {
1386 force: false,
1387 dry_run: false,
1388 frozen: true,
1389 no_refresh_models: false,
1390 },
1391 };
1392
1393 let err = validate_request(&request).unwrap_err();
1394 assert!(matches!(err, MarsError::InvalidRequest { .. }));
1395 assert!(err.to_string().contains("--frozen"));
1396 }
1397
1398 #[test]
1399 fn validate_request_rejects_frozen_with_mutation() {
1400 let request = SyncRequest {
1401 resolution: ResolutionMode::Normal,
1402 mutation: Some(ConfigMutation::RemoveDependency {
1403 name: "base".into(),
1404 }),
1405 options: SyncOptions {
1406 force: false,
1407 dry_run: false,
1408 frozen: true,
1409 no_refresh_models: false,
1410 },
1411 };
1412
1413 let err = validate_request(&request).unwrap_err();
1414 assert!(matches!(err, MarsError::InvalidRequest { .. }));
1415 assert!(err.to_string().contains("cannot modify config"));
1416 }
1417
1418 #[test]
1419 fn planned_bump_entries_bump_all_outdated_pins() {
1420 let mut config = Config::default();
1421 config.dependencies.insert(
1422 "base".into(),
1423 git_dependency_entry(
1424 "https://example.com/base.git",
1425 "v1.0.0",
1426 FilterConfig::default(),
1427 ),
1428 );
1429 config.dependencies.insert(
1430 "tools".into(),
1431 git_dependency_entry(
1432 "https://example.com/tools.git",
1433 "v2.0.0",
1434 FilterConfig::default(),
1435 ),
1436 );
1437 config.dependencies.insert(
1438 "floating".into(),
1439 DependencyEntry {
1440 url: Some("https://example.com/floating.git".into()),
1441 path: None,
1442 subpath: None,
1443 version: None,
1444 filter: FilterConfig::default(),
1445 },
1446 );
1447
1448 let graph = graph_with_versions(&[
1449 ("base", "https://example.com/base.git", "v1.2.0"),
1450 ("tools", "https://example.com/tools.git", "v2.0.0"),
1451 ("floating", "https://example.com/floating.git", "v3.0.0"),
1452 ]);
1453
1454 let mode = ResolutionMode::Maximize {
1455 targets: HashSet::new(),
1456 bump: true,
1457 };
1458 let entries = planned_bump_entries(&config, &graph, &mode);
1459 assert_eq!(entries.len(), 1);
1460 assert_eq!(entries[0].0, SourceName::from("base"));
1461 assert_eq!(entries[0].1.version.as_deref(), Some("v1.2.0"));
1462 }
1463
1464 #[test]
1465 fn planned_bump_entries_bump_specific_targets_only() {
1466 let mut config = Config::default();
1467 config.dependencies.insert(
1468 "base".into(),
1469 git_dependency_entry(
1470 "https://example.com/base.git",
1471 "v1.0.0",
1472 FilterConfig::default(),
1473 ),
1474 );
1475 config.dependencies.insert(
1476 "tools".into(),
1477 git_dependency_entry(
1478 "https://example.com/tools.git",
1479 "v1.0.0",
1480 FilterConfig::default(),
1481 ),
1482 );
1483
1484 let graph = graph_with_versions(&[
1485 ("base", "https://example.com/base.git", "v2.0.0"),
1486 ("tools", "https://example.com/tools.git", "v2.0.0"),
1487 ]);
1488
1489 let mode = ResolutionMode::Maximize {
1490 targets: HashSet::from([SourceName::from("tools")]),
1491 bump: true,
1492 };
1493 let entries = planned_bump_entries(&config, &graph, &mode);
1494 assert_eq!(entries.len(), 1);
1495 assert_eq!(entries[0].0, SourceName::from("tools"));
1496 assert_eq!(entries[0].1.version.as_deref(), Some("v2.0.0"));
1497 }
1498
1499 #[test]
1500 fn planned_bump_entries_noop_when_already_latest() {
1501 let mut config = Config::default();
1502 config.dependencies.insert(
1503 "base".into(),
1504 git_dependency_entry(
1505 "https://example.com/base.git",
1506 "v1.2.0",
1507 FilterConfig::default(),
1508 ),
1509 );
1510
1511 let graph = graph_with_versions(&[("base", "https://example.com/base.git", "v1.2.0")]);
1512
1513 let mode = ResolutionMode::Maximize {
1514 targets: HashSet::new(),
1515 bump: true,
1516 };
1517 let entries = planned_bump_entries(&config, &graph, &mode);
1518 assert!(entries.is_empty());
1519 }
1520
1521 #[test]
1522 fn planned_bump_entries_preserve_filters_and_renames() {
1523 let mut rename = crate::types::RenameMap::new();
1524 rename.insert("coder".into(), "coder-v2".into());
1525
1526 let mut config = Config::default();
1527 config.dependencies.insert(
1528 "base".into(),
1529 git_dependency_entry(
1530 "https://example.com/base.git",
1531 "v1.0.0",
1532 FilterConfig {
1533 agents: Some(vec!["coder".into()]),
1534 rename: Some(rename.clone()),
1535 ..FilterConfig::default()
1536 },
1537 ),
1538 );
1539
1540 let graph = graph_with_versions(&[("base", "https://example.com/base.git", "v2.0.0")]);
1541 let mode = ResolutionMode::Maximize {
1542 targets: HashSet::new(),
1543 bump: true,
1544 };
1545 let entries = planned_bump_entries(&config, &graph, &mode);
1546 let mut mutated = config.clone();
1547 let changes =
1548 mutation::apply_mutation(&mut mutated, &ConfigMutation::BatchUpsert(entries)).unwrap();
1549
1550 assert_eq!(changes.len(), 1);
1551 assert_eq!(changes[0].old_version.as_deref(), Some("v1.0.0"));
1552 assert_eq!(changes[0].new_version.as_deref(), Some("v2.0.0"));
1553
1554 let dep = &mutated.dependencies["base"];
1555 assert_eq!(dep.version.as_deref(), Some("v2.0.0"));
1556 assert_eq!(dep.filter.agents.as_deref(), Some(&["coder".into()][..]));
1557 assert_eq!(dep.filter.rename.as_ref(), Some(&rename));
1558 }
1559
1560 #[test]
1561 fn execute_auto_inits_config_for_mutation() {
1562 let project_root = TempDir::new().unwrap();
1563 let managed_root = project_root.path().join(".agents");
1564 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
1565 let source = TempDir::new().unwrap();
1566 fs::create_dir_all(source.path().join("agents")).unwrap();
1567 fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
1568
1569 let request = SyncRequest {
1570 resolution: ResolutionMode::Normal,
1571 mutation: Some(ConfigMutation::UpsertDependency {
1572 name: "base".into(),
1573 entry: path_dependency_entry(source.path()),
1574 }),
1575 options: SyncOptions::default(),
1576 };
1577
1578 let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
1579 let report = execute(&ctx, &request).unwrap();
1580 assert!(!report.applied.outcomes.is_empty());
1581 assert!(project_root.path().join("mars.toml").exists());
1582
1583 let saved = crate::config::load(project_root.path()).unwrap();
1584 assert!(saved.dependencies.contains_key("base"));
1585 }
1586
1587 #[test]
1588 fn execute_dry_run_with_mutation_does_not_write_config() {
1589 let project_root = TempDir::new().unwrap();
1590 let managed_root = project_root.path().join(".agents");
1591 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
1592 crate::config::save(
1593 project_root.path(),
1594 &Config {
1595 dependencies: IndexMap::new(),
1596 settings: Settings::default(),
1597 ..Config::default()
1598 },
1599 )
1600 .unwrap();
1601
1602 let source = TempDir::new().unwrap();
1603 fs::create_dir_all(source.path().join("agents")).unwrap();
1604 fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
1605
1606 let request = SyncRequest {
1607 resolution: ResolutionMode::Normal,
1608 mutation: Some(ConfigMutation::UpsertDependency {
1609 name: "base".into(),
1610 entry: path_dependency_entry(source.path()),
1611 }),
1612 options: SyncOptions {
1613 force: false,
1614 dry_run: true,
1615 frozen: false,
1616 no_refresh_models: false,
1617 },
1618 };
1619
1620 let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
1621 let report = execute(&ctx, &request).unwrap();
1622 assert!(!report.applied.outcomes.is_empty());
1623
1624 let saved = crate::config::load(project_root.path()).unwrap();
1625 assert!(!saved.dependencies.contains_key("base"));
1626 assert!(!managed_root.join("agents/coder.md").exists());
1627 assert!(!project_root.path().join("mars.lock").exists());
1628 }
1629
1630 #[test]
1633 fn full_pipeline_fresh_sync() {
1634 let mut fixture = TestFixture::new();
1635 let src_idx = fixture.add_source(
1636 &[("coder.md", "# Coder agent")],
1637 &[("planning", "# Planning skill")],
1638 );
1639
1640 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1641
1642 let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1644 assert!(renames.is_empty());
1645 assert_eq!(target.items.len(), 2);
1646
1647 let lock = LockFile::empty();
1649 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1650
1651 assert_eq!(sync_diff.items.len(), 2);
1653 for entry in &sync_diff.items {
1654 assert!(matches!(entry, diff::DiffEntry::Add { .. }));
1655 }
1656
1657 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1659 let options = SyncOptions {
1660 force: false,
1661 dry_run: false,
1662 frozen: false,
1663 no_refresh_models: false,
1664 };
1665 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1666 assert_eq!(sync_plan.actions.len(), 2);
1667 for action in &sync_plan.actions {
1668 assert!(matches!(action, plan::PlannedAction::Install { .. }));
1669 }
1670
1671 let result =
1673 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1674 assert_eq!(result.outcomes.len(), 2);
1675
1676 assert!(fixture.managed_root().join("agents/coder.md").exists());
1678 assert!(
1679 fixture
1680 .managed_root()
1681 .join("skills/planning/SKILL.md")
1682 .exists()
1683 );
1684
1685 let new_lock =
1687 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1688 assert_eq!(new_lock.items.len(), 2);
1689 assert!(new_lock.items.contains_key("agent/coder"));
1690 assert!(new_lock.items.contains_key("skill/planning"));
1691 }
1692
1693 #[test]
1694 fn re_sync_no_changes() {
1695 let mut fixture = TestFixture::new();
1696 let content = "# Coder agent";
1697 let src_idx = fixture.add_source(&[("coder.md", content)], &[]);
1698
1699 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1700
1701 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1703 let lock = LockFile::empty();
1704 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1705 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1706 let options = SyncOptions {
1707 force: false,
1708 dry_run: false,
1709 frozen: false,
1710 no_refresh_models: false,
1711 };
1712 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1713 let result =
1714 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1715 let first_lock =
1716 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1717
1718 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1720 let sync_diff2 =
1721 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1722
1723 for entry in &sync_diff2.items {
1725 assert!(
1726 matches!(entry, diff::DiffEntry::Unchanged { .. }),
1727 "expected Unchanged, got {entry:?}"
1728 );
1729 }
1730
1731 let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1732 for action in &sync_plan2.actions {
1733 assert!(matches!(action, plan::PlannedAction::Skip { .. }));
1734 }
1735 }
1736
1737 #[test]
1738 fn source_update_detects_changes() {
1739 let mut fixture = TestFixture::new();
1740 let src_idx = fixture.add_source(&[("coder.md", "# Version 1")], &[]);
1741
1742 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1743
1744 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1746 let lock = LockFile::empty();
1747 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1748 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1749 let options = SyncOptions {
1750 force: false,
1751 dry_run: false,
1752 frozen: false,
1753 no_refresh_models: false,
1754 };
1755 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1756 let result =
1757 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1758 let first_lock =
1759 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1760
1761 let agents_dir = fixture.tree_path(src_idx).join("agents");
1763 fs::write(agents_dir.join("coder.md"), "# Version 2").unwrap();
1764
1765 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1767 let sync_diff2 =
1768 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1769
1770 assert_eq!(sync_diff2.items.len(), 1);
1772 assert!(matches!(
1773 &sync_diff2.items[0],
1774 diff::DiffEntry::Update { .. }
1775 ));
1776 }
1777
1778 #[test]
1779 fn local_modification_preserved() {
1780 let mut fixture = TestFixture::new();
1781 let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1782
1783 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1784
1785 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1787 let lock = LockFile::empty();
1788 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1789 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1790 let options = SyncOptions {
1791 force: false,
1792 dry_run: false,
1793 frozen: false,
1794 no_refresh_models: false,
1795 };
1796 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1797 let result =
1798 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1799 let first_lock =
1800 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1801
1802 fs::write(
1804 fixture.managed_root().join("agents/coder.md"),
1805 "# Locally modified",
1806 )
1807 .unwrap();
1808
1809 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1811 let sync_diff2 =
1812 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1813
1814 assert_eq!(sync_diff2.items.len(), 1);
1816 assert!(matches!(
1817 &sync_diff2.items[0],
1818 diff::DiffEntry::LocalModified { .. }
1819 ));
1820
1821 let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1823 assert!(matches!(
1824 &sync_plan2.actions[0],
1825 plan::PlannedAction::KeepLocal { .. }
1826 ));
1827 }
1828
1829 #[test]
1830 fn force_overwrites_local_modifications() {
1831 let mut fixture = TestFixture::new();
1832 let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1833
1834 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1835
1836 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1838 let lock = LockFile::empty();
1839 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1840 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1841 let options = SyncOptions {
1842 force: false,
1843 dry_run: false,
1844 frozen: false,
1845 no_refresh_models: false,
1846 };
1847 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1848 let result =
1849 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1850 let first_lock =
1851 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1852
1853 fs::write(
1855 fixture.managed_root().join("agents/coder.md"),
1856 "# Locally modified",
1857 )
1858 .unwrap();
1859
1860 let agents_dir = fixture.tree_path(src_idx).join("agents");
1862 fs::write(agents_dir.join("coder.md"), "# Upstream update").unwrap();
1863
1864 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1866 let sync_diff2 =
1867 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1868
1869 let force_options = SyncOptions {
1870 force: true,
1871 dry_run: false,
1872 frozen: false,
1873 no_refresh_models: false,
1874 };
1875 let sync_plan2 = create_sync_plan(&sync_diff2, &force_options, &cache_dir);
1876 assert!(matches!(
1877 &sync_plan2.actions[0],
1878 plan::PlannedAction::Overwrite { .. }
1879 ));
1880
1881 let result2 = apply::execute(
1882 fixture.managed_root(),
1883 &sync_plan2,
1884 &force_options,
1885 &cache_dir,
1886 )
1887 .unwrap();
1888 assert!(matches!(
1889 result2.outcomes[0].action,
1890 apply::ActionTaken::Updated
1891 ));
1892
1893 let content = fs::read_to_string(fixture.managed_root().join("agents/coder.md")).unwrap();
1895 assert_eq!(content, "# Upstream update");
1896 }
1897
1898 #[test]
1899 fn orphan_removed_when_source_drops_item() {
1900 let mut fixture = TestFixture::new();
1901 let src_idx = fixture.add_source(
1902 &[("coder.md", "# Coder"), ("reviewer.md", "# Reviewer")],
1903 &[],
1904 );
1905
1906 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1907
1908 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1910 let lock = LockFile::empty();
1911 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1912 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1913 let options = SyncOptions {
1914 force: false,
1915 dry_run: false,
1916 frozen: false,
1917 no_refresh_models: false,
1918 };
1919 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1920 let result =
1921 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1922 let first_lock =
1923 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1924
1925 assert!(fixture.managed_root().join("agents/coder.md").exists());
1926 assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1927
1928 fs::remove_file(fixture.tree_path(src_idx).join("agents/reviewer.md")).unwrap();
1930
1931 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1933 let sync_diff2 =
1934 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1935
1936 let orphan_count = sync_diff2
1938 .items
1939 .iter()
1940 .filter(|e| matches!(e, diff::DiffEntry::Orphan { .. }))
1941 .count();
1942 assert_eq!(orphan_count, 1);
1943
1944 let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1945 let result2 =
1946 apply::execute(fixture.managed_root(), &sync_plan2, &options, &cache_dir).unwrap();
1947
1948 assert!(!fixture.managed_root().join("agents/reviewer.md").exists());
1950 assert!(fixture.managed_root().join("agents/coder.md").exists());
1952
1953 let removed = result2
1955 .outcomes
1956 .iter()
1957 .any(|o| matches!(o.action, apply::ActionTaken::Removed));
1958 assert!(removed);
1959 }
1960
1961 #[test]
1962 fn dry_run_produces_plan_without_changes() {
1963 let mut fixture = TestFixture::new();
1964 let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1965
1966 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1967
1968 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1969 let lock = LockFile::empty();
1970 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1971
1972 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1973 let dry_options = SyncOptions {
1974 force: false,
1975 dry_run: true,
1976 frozen: false,
1977 no_refresh_models: false,
1978 };
1979
1980 let sync_plan = create_sync_plan(&sync_diff, &dry_options, &cache_dir);
1981 assert!(!sync_plan.actions.is_empty());
1982
1983 let result =
1985 apply::execute(fixture.managed_root(), &sync_plan, &dry_options, &cache_dir).unwrap();
1986 assert!(!result.outcomes.is_empty());
1987
1988 assert!(!fixture.managed_root().join("agents/coder.md").exists());
1990 }
1991
1992 #[test]
1993 fn lock_written_after_apply() {
1994 let mut fixture = TestFixture::new();
1995 let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1996
1997 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1998
1999 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2001 let lock = LockFile::empty();
2002 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
2003 let cache_dir = fixture.project_root().join(".mars/cache/bases");
2004 let options = SyncOptions {
2005 force: false,
2006 dry_run: false,
2007 frozen: false,
2008 no_refresh_models: false,
2009 };
2010 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
2011 let result =
2012 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
2013
2014 let new_lock =
2015 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
2016 crate::lock::write(fixture.project_root(), &new_lock).unwrap();
2017
2018 let reloaded = crate::lock::load(fixture.project_root()).unwrap();
2020 assert_eq!(reloaded.items.len(), 1);
2021 assert!(reloaded.items.contains_key("agent/coder"));
2022
2023 let item = &reloaded.items["agent/coder"];
2024 assert_eq!(item.kind, ItemKind::Agent);
2025 assert!(!item.source_checksum.is_empty());
2026 assert!(!item.outputs[0].installed_checksum.is_empty());
2027 }
2028
2029 #[test]
2030 fn two_sources_no_collision() {
2031 let mut fixture = TestFixture::new();
2032 let src_a = fixture.add_source(&[("coder.md", "# Coder from A")], &[]);
2033 let src_b = fixture.add_source(&[("reviewer.md", "# Reviewer from B")], &[]);
2034
2035 let (graph, config) = make_graph_config(
2036 &fixture,
2037 vec![
2038 ("source-a", src_a, FilterMode::All),
2039 ("source-b", src_b, FilterMode::All),
2040 ],
2041 );
2042
2043 let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
2044 assert!(renames.is_empty());
2045 assert_eq!(target.items.len(), 2);
2046
2047 let lock = LockFile::empty();
2048 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
2049 let cache_dir = fixture.project_root().join(".mars/cache/bases");
2050 let options = SyncOptions {
2051 force: false,
2052 dry_run: false,
2053 frozen: false,
2054 no_refresh_models: false,
2055 };
2056 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
2057 let result =
2058 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
2059
2060 assert!(fixture.managed_root().join("agents/coder.md").exists());
2061 assert!(fixture.managed_root().join("agents/reviewer.md").exists());
2062 assert_eq!(result.outcomes.len(), 2);
2063 }
2064
2065 #[test]
2068 fn pipeline_only_skills_filter() {
2069 let mut fixture = TestFixture::new();
2070 let src_idx = fixture.add_source(
2071 &[("coder.md", "# Coder agent")],
2072 &[("planning", "# Planning skill")],
2073 );
2074
2075 let (graph, config) =
2076 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlySkills)]);
2077
2078 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2079 assert_eq!(target.items.len(), 1);
2081 assert!(target.items.contains_key("skills/planning"));
2082 }
2083
2084 #[test]
2085 fn pipeline_only_agents_filter() {
2086 let mut fixture = TestFixture::new();
2087 let agent_content = "---\nskills:\n - planning\n---\n# Coder agent";
2089 let src_idx = fixture.add_source(
2090 &[("coder.md", agent_content)],
2091 &[
2092 ("planning", "# Planning skill"),
2093 ("standalone", "# Standalone skill"),
2094 ],
2095 );
2096
2097 let (graph, config) =
2098 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
2099
2100 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2101 assert_eq!(target.items.len(), 2);
2103 assert!(target.items.contains_key("agents/coder.md"));
2104 assert!(target.items.contains_key("skills/planning"));
2105 assert!(!target.items.contains_key("skills/standalone"));
2106 }
2107
2108 #[test]
2109 fn pipeline_only_agents_no_agents_source() {
2110 let mut fixture = TestFixture::new();
2111 let src_idx = fixture.add_source(&[], &[("planning", "# Planning skill")]);
2112
2113 let (graph, config) =
2114 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
2115
2116 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2117 assert_eq!(target.items.len(), 0);
2119 }
2120}