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