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