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 =
246 crate::models::merge_model_config(&loaded.config.models, &dep_models, diag, None);
247
248 Ok(ResolvedState {
249 loaded,
250 graph,
251 model_aliases,
252 })
253}
254
255fn build_target(
257 ctx: &MarsContext,
258 resolved: ResolvedState,
259 _request: &SyncRequest,
260 diag: &mut DiagnosticCollector,
261) -> Result<TargetedState, MarsError> {
262 let mars_dir = ctx.project_root.join(".mars");
264 let managed_root = &mars_dir;
265
266 let (mut target_state, renames) =
268 target::build_with_collisions(&resolved.graph, &resolved.loaded.effective)?;
269
270 let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
271 let local_source_id = SourceId::Path {
272 canonical: dunce::canonicalize(&ctx.project_root)
273 .unwrap_or_else(|_| ctx.project_root.clone()),
274 subpath: None,
275 };
276
277 let local_items = local_source::discover_local_items(
278 &ctx.project_root,
279 resolved.loaded.config.package.is_some(),
280 Some(local_source_name.as_str()),
281 diag,
282 )?;
283 for item in local_items {
284 let source_path = item.disk_path();
285 let is_flat_skill = item.discovered.id.kind == ItemKind::Skill
286 && item.discovered.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.discovered.id.kind)?)
294 };
295 let dest_path =
296 default_dest_path(item.discovered.id.kind, item.discovered.id.name.as_str());
297
298 if let Some(existing) = target_state.items.shift_remove(&dest_path)
299 && existing.source_hash != source_hash
300 {
301 diag.warn(
302 "local-shadow",
303 format!(
304 "local {} `{}` shadows dependency `{}` {} `{}`",
305 item.discovered.id.kind,
306 item.discovered.id.name,
307 existing.source_name,
308 existing.id.kind,
309 existing.id.name
310 ),
311 );
312 }
313
314 let disk_path = dest_path.resolve(managed_root);
315 if !resolved.loaded.old_lock.items.contains_key(&dest_path)
316 && disk_path.symlink_metadata().is_ok()
317 {
318 diag.warn(
319 "unmanaged-collision",
320 format!(
321 "local {} `{}` collides with unmanaged path `{}` — leaving existing content untouched",
322 item.discovered.id.kind, item.discovered.id.name, dest_path
323 ),
324 );
325 continue;
326 }
327
328 target_state.items.insert(
329 dest_path.clone(),
330 TargetItem {
331 id: ItemId {
332 kind: item.discovered.id.kind,
333 name: item.discovered.id.name.clone(),
334 },
335 source_name: local_source_name.clone(),
336 origin: SourceOrigin::LocalPackage,
337 source_id: local_source_id.clone(),
338 source_path,
339 dest_path,
340 source_hash,
341 is_flat_skill,
342 rewritten_content: None,
343 },
344 );
345 }
346
347 if !renames.is_empty() {
349 let rewrite_warnings =
350 target::rewrite_skill_refs(&mut target_state, &renames, &resolved.graph)?;
351 for w in &rewrite_warnings {
352 diag.warn("rewrite-warning", w.to_string());
353 }
354 }
355
356 let warnings = validate_skill_refs(managed_root, &target_state);
358
359 let unmanaged_collisions =
361 target::check_unmanaged_collisions(managed_root, &resolved.loaded.old_lock, &target_state);
362 for collision in &unmanaged_collisions {
363 diag.warn(
364 "unmanaged-collision",
365 format!(
366 "source `{}` collides with unmanaged path `{}` — leaving existing content untouched",
367 collision.source_name, collision.path
368 ),
369 );
370 target_state.items.shift_remove(&collision.path);
371 }
372
373 Ok(TargetedState {
374 resolved,
375 target: target_state,
376 renames,
377 warnings,
378 })
379}
380
381fn create_plan(
383 ctx: &MarsContext,
384 targeted: TargetedState,
385 request: &SyncRequest,
386 diag: &mut DiagnosticCollector,
387) -> Result<PlannedState, MarsError> {
388 let mars_dir = ctx.project_root.join(".mars");
390 let managed_root = &mars_dir;
391 let cache_bases_dir = mars_dir.join("cache").join("bases");
392
393 let sync_diff = diff::compute(
395 managed_root,
396 &targeted.resolved.loaded.old_lock,
397 &targeted.target,
398 request.options.force,
399 )?;
400
401 if !request.options.force {
402 for entry in &sync_diff.items {
403 if let diff::DiffEntry::LocalModified { target, .. } = entry {
404 diag.warn(
405 "disk-lock-divergent",
406 format!(
407 "{} diverged from mars.lock checksum; preserving local content (run `mars sync --force` or `mars repair` to reset)",
408 target.dest_path
409 ),
410 );
411 }
412 }
413 }
414
415 let sync_plan = plan::create(&sync_diff, &request.options, &cache_bases_dir, diag);
417
418 Ok(PlannedState {
419 targeted,
420 plan: sync_plan,
421 })
422}
423
424fn check_frozen_gate(planned: &PlannedState) -> Result<(), MarsError> {
426 let has_changes = planned.plan.actions.iter().any(|a| {
427 !matches!(
428 a,
429 plan::PlannedAction::Skip { .. } | plan::PlannedAction::KeepLocal { .. }
430 )
431 });
432 if has_changes {
433 return Err(MarsError::FrozenViolation {
434 message: "lock file would change but --frozen is set".into(),
435 });
436 }
437 Ok(())
438}
439
440fn apply_plan(
442 ctx: &MarsContext,
443 planned: PlannedState,
444 request: &SyncRequest,
445) -> Result<AppliedState, MarsError> {
446 let project_root = &ctx.project_root;
447 let mars_dir = project_root.join(".mars");
448 let cache_bases_dir = mars_dir.join("cache").join("bases");
449
450 let has_bump_version_changes =
451 has_version_changes(&planned.targeted.resolved.loaded.dependency_changes)
452 && matches!(
453 request.resolution,
454 ResolutionMode::Maximize { bump: true, .. }
455 );
456 let has_mutation = request.mutation.is_some() || has_bump_version_changes;
457
458 if has_mutation && !request.options.dry_run {
460 match &request.mutation {
461 Some(ConfigMutation::SetOverride { .. } | ConfigMutation::ClearOverride { .. }) => {
462 crate::config::save_local(project_root, &planned.targeted.resolved.loaded.local)?;
463 }
464 Some(
465 ConfigMutation::UpsertDependency { .. }
466 | ConfigMutation::BatchUpsert(..)
467 | ConfigMutation::RemoveDependency { .. }
468 | ConfigMutation::SetRename { .. },
469 ) => {
470 crate::config::save(project_root, &planned.targeted.resolved.loaded.config)?;
471 }
472 None => {
473 if has_bump_version_changes {
474 crate::config::save(project_root, &planned.targeted.resolved.loaded.config)?;
475 }
476 }
477 }
478 }
479
480 let applied = apply::execute(&mars_dir, &planned.plan, &request.options, &cache_bases_dir)?;
484
485 Ok(AppliedState { planned, applied })
486}
487
488fn sync_targets(
494 ctx: &MarsContext,
495 applied: AppliedState,
496 request: &SyncRequest,
497 diag: &mut DiagnosticCollector,
498) -> SyncedState {
499 if request.options.dry_run {
500 return SyncedState {
501 applied,
502 target_outcomes: Vec::new(),
503 };
504 }
505
506 let mars_dir = ctx.project_root.join(".mars");
507 let targets = applied
508 .planned
509 .targeted
510 .resolved
511 .loaded
512 .effective
513 .settings
514 .managed_targets();
515 let previous_managed_paths = applied
516 .planned
517 .targeted
518 .resolved
519 .loaded
520 .old_lock
521 .items
522 .keys()
523 .map(|dest_path| dest_path.to_string())
524 .collect::<HashSet<String>>();
525
526 let target_outcomes = crate::target_sync::sync_managed_targets(
527 &ctx.project_root,
528 &mars_dir,
529 &targets,
530 &applied.applied.outcomes,
531 &previous_managed_paths,
532 request.options.force,
533 diag,
534 );
535
536 SyncedState {
537 applied,
538 target_outcomes,
539 }
540}
541
542fn finalize(
546 ctx: &MarsContext,
547 state: SyncedState,
548 request: &SyncRequest,
549 diag: &mut DiagnosticCollector,
550) -> Result<SyncReport, MarsError> {
551 let project_root = &ctx.project_root;
552 let old_lock = &state.applied.planned.targeted.resolved.loaded.old_lock;
553 let graph = &state.applied.planned.targeted.resolved.graph;
554
555 if !request.options.dry_run {
557 let new_lock = crate::lock::build(graph, &state.applied.applied, old_lock)?;
558 crate::lock::write(project_root, &new_lock)?;
559
560 let dep_models = declaration_ordered_dep_models(
564 graph,
565 &state.applied.planned.targeted.resolved.loaded.effective,
566 );
567 let empty_consumer: indexmap::IndexMap<String, crate::models::ModelAlias> =
568 indexmap::IndexMap::new();
569 let mut ignored_diag = DiagnosticCollector::new();
570 let dep_model_aliases = crate::models::merge_model_config(
571 &empty_consumer,
572 &dep_models,
573 &mut ignored_diag,
574 None,
575 );
576
577 if !request.options.dry_run {
581 let mars_path = ctx.project_root.join(".mars");
582 let ttl = crate::models::load_models_cache_ttl(ctx);
583 let mode = crate::models::resolve_refresh_mode(request.options.no_refresh_models);
584 match crate::models::ensure_fresh(&mars_path, ttl, mode) {
585 Ok((_, crate::models::RefreshOutcome::StaleFallback { reason })) => {
586 diag.warn(
587 "models-cache-refresh",
588 format!("using stale models cache: {reason}"),
589 );
590 }
591 Ok((_, crate::models::RefreshOutcome::Offline)) => {}
592 Ok(_) => {}
593 Err(err) => {
594 diag.warn(
595 "models-cache-refresh",
596 format!("failed to refresh models cache: {err}"),
597 );
598 }
599 }
600 }
601
602 match serde_json::to_string_pretty(&dep_model_aliases) {
603 Ok(json) => {
604 let merged_path = ctx.project_root.join(".mars").join("models-merged.json");
605 if let Err(err) = crate::fs::atomic_write(&merged_path, json.as_bytes()) {
606 diag.warn(
607 "models-merge-write",
608 format!("failed to write models-merged.json: {err}"),
609 );
610 }
611 }
612 Err(err) => {
613 diag.warn(
614 "models-merge-write",
615 format!("failed to serialize merged model aliases: {err}"),
616 );
617 }
618 }
619 }
620
621 for w in &state.applied.planned.targeted.warnings {
622 match w {
623 ValidationWarning::MissingSkill {
624 agent,
625 skill_name,
626 suggestion,
627 } => {
628 let msg = match suggestion {
629 Some(s) => format!(
630 "agent `{}` references missing skill `{}` (did you mean `{}`?)",
631 agent.name, skill_name, s
632 ),
633 None => {
634 format!(
635 "agent `{}` references missing skill `{}`",
636 agent.name, skill_name
637 )
638 }
639 };
640 diag.warn("missing-skill", msg);
641 }
642 }
643 }
644 let dependency_changes = state
645 .applied
646 .planned
647 .targeted
648 .resolved
649 .loaded
650 .dependency_changes;
651 let upgrades_available = if request.options.frozen {
652 0
653 } else {
654 graph
655 .nodes
656 .values()
657 .filter(|node| {
658 matches!(
659 (&node.resolved_ref.version, &node.latest_version),
660 (Some(resolved), Some(latest)) if latest > resolved
661 )
662 })
663 .count()
664 };
665
666 Ok(SyncReport {
667 applied: state.applied.applied,
668 pruned: Vec::new(),
669 diagnostics: diag.drain(),
670 dependency_changes,
671 upgrades_available,
672 target_outcomes: state.target_outcomes,
673 dry_run: request.options.dry_run,
674 })
675}
676
677fn declaration_ordered_dep_models(
678 graph: &ResolvedGraph,
679 config: &EffectiveConfig,
680) -> Vec<crate::models::ResolvedDepModels> {
681 let mut decl_pos: HashMap<SourceName, usize> = HashMap::new();
683 for (idx, name) in config.dependencies.keys().enumerate() {
684 decl_pos.insert(name.clone(), idx);
685 }
686
687 for (idx, sponsor) in config.dependencies.keys().enumerate() {
690 let Some(sponsor_node) = graph.nodes.get(sponsor) else {
691 continue;
692 };
693
694 let mut queue: VecDeque<SourceName> = sponsor_node.deps.iter().cloned().collect();
695 let mut visited: HashSet<SourceName> = HashSet::new();
696
697 while let Some(dep) = queue.pop_front() {
698 if !visited.insert(dep.clone()) {
699 continue;
700 }
701
702 decl_pos
703 .entry(dep.clone())
704 .and_modify(|pos| *pos = (*pos).min(idx))
705 .or_insert(idx);
706
707 if let Some(dep_node) = graph.nodes.get(&dep) {
708 queue.extend(dep_node.deps.iter().cloned());
709 }
710 }
711 }
712
713 let mut in_degree: HashMap<SourceName, usize> = HashMap::new();
716 let mut adjacency: HashMap<SourceName, Vec<SourceName>> = HashMap::new();
717
718 for name in graph.nodes.keys() {
719 in_degree.entry(name.clone()).or_insert(0);
720 adjacency.entry(name.clone()).or_default();
721 }
722
723 for (name, node) in &graph.nodes {
724 for dep in &node.deps {
725 if graph.nodes.contains_key(dep) {
726 *in_degree.entry(name.clone()).or_insert(0) += 1;
727 adjacency.entry(dep.clone()).or_default().push(name.clone());
728 }
729 }
730 }
731
732 let mut ready: BinaryHeap<Reverse<(usize, SourceName)>> = BinaryHeap::new();
733 for (name, degree) in &in_degree {
734 if *degree == 0 {
735 let position = decl_pos.get(name).copied().unwrap_or(usize::MAX);
736 ready.push(Reverse((position, name.clone())));
737 }
738 }
739
740 let mut ordered: Vec<SourceName> = Vec::with_capacity(graph.nodes.len());
741 while let Some(Reverse((_, current))) = ready.pop() {
742 ordered.push(current.clone());
743
744 if let Some(dependents) = adjacency.get(¤t) {
745 for dependent in dependents {
746 if let Some(degree) = in_degree.get_mut(dependent) {
747 *degree -= 1;
748 if *degree == 0 {
749 let position = decl_pos.get(dependent).copied().unwrap_or(usize::MAX);
750 ready.push(Reverse((position, dependent.clone())));
751 }
752 }
753 }
754 }
755 }
756
757 let ordered_names: Vec<SourceName> = if ordered.len() == graph.nodes.len() {
760 ordered
761 } else {
762 graph.order.clone()
763 };
764
765 ordered_names
766 .iter()
767 .filter_map(|name| {
768 let node = graph.nodes.get(name)?;
769 let manifest = node.manifest.as_ref()?;
770 if manifest.models.is_empty() {
771 return None;
772 }
773 Some(crate::models::ResolvedDepModels {
774 source_name: name.to_string(),
775 models: manifest.models.clone(),
776 })
777 })
778 .collect()
779}
780
781fn default_dest_path(kind: ItemKind, name: &str) -> DestPath {
782 match kind {
783 ItemKind::Agent => DestPath::from(format!("agents/{name}.md")),
784 ItemKind::Skill => DestPath::from(format!("skills/{name}")),
785 }
786}
787
788fn validate_request(request: &SyncRequest) -> Result<(), MarsError> {
789 if request.options.frozen && matches!(request.resolution, ResolutionMode::Maximize { .. }) {
790 return Err(MarsError::InvalidRequest {
791 message:
792 "cannot use --frozen with upgrade (frozen locks versions; upgrade maximizes them)"
793 .to_string(),
794 });
795 }
796
797 if request.options.frozen && request.mutation.is_some() {
798 return Err(MarsError::InvalidRequest {
799 message:
800 "cannot modify config in --frozen mode (config change would require lock update)"
801 .to_string(),
802 });
803 }
804
805 Ok(())
806}
807
808fn validate_targets(
809 resolution: &ResolutionMode,
810 effective: &EffectiveConfig,
811) -> Result<(), MarsError> {
812 if let ResolutionMode::Maximize { targets, .. } = resolution {
813 for name in targets {
814 if !effective.dependencies.contains_key(name) {
815 return Err(MarsError::Source {
816 source_name: name.to_string(),
817 message: format!("dependency `{name}` not found in mars.toml"),
818 });
819 }
820 }
821 }
822
823 Ok(())
824}
825
826fn to_resolve_options(mode: &ResolutionMode, frozen: bool) -> ResolveOptions {
827 match mode {
828 ResolutionMode::Normal => ResolveOptions {
829 frozen,
830 ..ResolveOptions::default()
831 },
832 ResolutionMode::Maximize { targets, bump } => ResolveOptions {
833 maximize: true,
834 upgrade_targets: targets.clone(),
835 bump_direct_constraints: *bump,
836 frozen,
837 },
838 }
839}
840
841fn planned_bump_entries(
842 config: &Config,
843 graph: &ResolvedGraph,
844 mode: &ResolutionMode,
845) -> Vec<(SourceName, crate::config::DependencyEntry)> {
846 let ResolutionMode::Maximize {
847 targets,
848 bump: true,
849 } = mode
850 else {
851 return Vec::new();
852 };
853
854 config
855 .dependencies
856 .iter()
857 .filter_map(|(name, entry)| {
858 if !targets.is_empty() && !targets.contains(name) {
859 return None;
860 }
861 entry.url.as_ref()?;
863 let node = graph.nodes.get(name)?;
864 let resolved_version = node.resolved_ref.version.as_ref()?;
865 let resolved_tag = node.resolved_ref.version_tag.as_ref()?;
866 if !constraint_needs_bump(entry.version.as_deref(), resolved_version) {
867 return None;
868 }
869 if entry.version.as_deref() == Some(resolved_tag.as_str()) {
870 return None;
871 }
872 let mut bumped = entry.clone();
873 bumped.version = Some(resolved_tag.clone());
874 Some((name.clone(), bumped))
875 })
876 .collect()
877}
878
879fn constraint_needs_bump(current: Option<&str>, resolved: &semver::Version) -> bool {
880 match crate::resolve::parse_version_constraint(current) {
881 crate::resolve::VersionConstraint::Semver(req) => !req.matches(resolved),
882 crate::resolve::VersionConstraint::Latest
883 | crate::resolve::VersionConstraint::RefPin(_) => false,
884 }
885}
886
887fn has_version_changes(changes: &[DependencyUpsertChange]) -> bool {
888 changes
889 .iter()
890 .any(|change| change.old_version != change.new_version)
891}
892
893fn validate_skill_refs(
896 install_target: &std::path::Path,
897 target: &target::TargetState,
898) -> Vec<ValidationWarning> {
899 use crate::lock::ItemKind;
900
901 let available_skills: HashSet<String> = target
903 .items
904 .values()
905 .filter(|item| item.id.kind == ItemKind::Skill)
906 .map(|item| item.id.name.to_string())
907 .collect();
908
909 let agents: Vec<(String, PathBuf)> = target
911 .items
912 .values()
913 .filter(|item| item.id.kind == ItemKind::Agent)
914 .map(|item| {
915 let disk_path = item.dest_path.resolve(install_target);
916 let path = if disk_path.exists() {
919 disk_path
920 } else {
921 item.source_path.clone()
922 };
923 (item.id.name.to_string(), path)
924 })
925 .collect();
926
927 crate::validate::check_deps(&agents, &available_skills).unwrap_or_default()
928}
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933 use crate::config::*;
934 use crate::lock::{ItemKind, LockFile};
935 use crate::resolve::{ResolvedGraph, ResolvedNode};
936 use indexmap::IndexMap;
937 use std::fs;
938 use tempfile::TempDir;
939
940 struct TestFixture {
942 project_root: TempDir,
943 managed_root: PathBuf,
944 source_trees: Vec<TempDir>,
945 }
946
947 impl TestFixture {
948 fn new() -> Self {
949 let project_root = TempDir::new().unwrap();
950 let managed_root = project_root.path().join(".agents");
951 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
953 TestFixture {
954 project_root,
955 managed_root,
956 source_trees: Vec::new(),
957 }
958 }
959
960 fn add_source(&mut self, agents: &[(&str, &str)], skills: &[(&str, &str)]) -> usize {
961 let dir = TempDir::new().unwrap();
962 if !agents.is_empty() {
963 let agents_dir = dir.path().join("agents");
964 fs::create_dir_all(&agents_dir).unwrap();
965 for (name, content) in agents {
966 fs::write(agents_dir.join(name), content).unwrap();
967 }
968 }
969 if !skills.is_empty() {
970 let skills_dir = dir.path().join("skills");
971 fs::create_dir_all(&skills_dir).unwrap();
972 for (name, content) in skills {
973 let skill_dir = skills_dir.join(name);
974 fs::create_dir_all(&skill_dir).unwrap();
975 fs::write(skill_dir.join("SKILL.md"), content).unwrap();
976 }
977 }
978 self.source_trees.push(dir);
979 self.source_trees.len() - 1
980 }
981
982 fn project_root(&self) -> &std::path::Path {
983 self.project_root.path()
984 }
985
986 fn managed_root(&self) -> &std::path::Path {
987 &self.managed_root
988 }
989
990 fn tree_path(&self, idx: usize) -> PathBuf {
991 self.source_trees[idx].path().to_path_buf()
992 }
993 }
994
995 fn make_graph_config(
996 fixture: &TestFixture,
997 sources: Vec<(&str, usize, FilterMode)>,
998 ) -> (ResolvedGraph, EffectiveConfig) {
999 let mut nodes = IndexMap::new();
1000 let mut order = Vec::new();
1001 let mut config_dependencies = IndexMap::new();
1002
1003 for (name, tree_idx, filter) in sources {
1004 let tree_path = fixture.tree_path(tree_idx);
1005 nodes.insert(
1006 name.into(),
1007 ResolvedNode {
1008 source_name: name.into(),
1009 source_id: crate::types::SourceId::Path {
1010 canonical: tree_path.clone(),
1011 subpath: None,
1012 },
1013 rooted_ref: crate::resolve::RootedSourceRef {
1014 checkout_root: tree_path.clone(),
1015 package_root: tree_path.clone(),
1016 },
1017 resolved_ref: crate::source::ResolvedRef {
1018 source_name: name.into(),
1019 version: None,
1020 version_tag: None,
1021 commit: None,
1022 tree_path: tree_path.clone(),
1023 },
1024 latest_version: None,
1025 manifest: None,
1026 deps: vec![],
1027 },
1028 );
1029 order.push(name.into());
1030
1031 config_dependencies.insert(
1032 name.into(),
1033 EffectiveDependency {
1034 name: name.into(),
1035 id: crate::types::SourceId::Path {
1036 canonical: tree_path.clone(),
1037 subpath: None,
1038 },
1039 spec: SourceSpec::Path(tree_path),
1040 subpath: None,
1041 filter,
1042 rename: crate::types::RenameMap::new(),
1043 is_overridden: false,
1044 original_git: None,
1045 },
1046 );
1047 }
1048
1049 (
1050 ResolvedGraph {
1051 nodes,
1052 order,
1053 filters: std::collections::HashMap::new(),
1054 },
1055 EffectiveConfig {
1056 dependencies: config_dependencies,
1057 settings: Settings::default(),
1058 },
1059 )
1060 }
1061
1062 fn path_dependency_entry(path: &std::path::Path) -> DependencyEntry {
1063 DependencyEntry {
1064 url: None,
1065 path: Some(path.to_path_buf()),
1066 subpath: None,
1067 version: None,
1068 filter: FilterConfig::default(),
1069 }
1070 }
1071
1072 fn git_dependency_entry(url: &str, version: &str, filter: FilterConfig) -> DependencyEntry {
1073 DependencyEntry {
1074 url: Some(url.into()),
1075 path: None,
1076 subpath: None,
1077 version: Some(version.to_string()),
1078 filter,
1079 }
1080 }
1081
1082 fn create_sync_plan(
1083 sync_diff: &diff::SyncDiff,
1084 options: &SyncOptions,
1085 cache_bases_dir: &std::path::Path,
1086 ) -> plan::SyncPlan {
1087 let mut diag = DiagnosticCollector::new();
1088 plan::create(sync_diff, options, cache_bases_dir, &mut diag)
1089 }
1090
1091 fn graph_with_versions(entries: &[(&str, &str, &str)]) -> ResolvedGraph {
1092 let mut nodes = IndexMap::new();
1093 let mut order = Vec::new();
1094 for (name, url, tag) in entries {
1095 let version = semver::Version::parse(tag.trim_start_matches('v')).unwrap();
1096 nodes.insert(
1097 (*name).into(),
1098 ResolvedNode {
1099 source_name: (*name).into(),
1100 source_id: crate::types::SourceId::git(crate::types::SourceUrl::from(*url)),
1101 rooted_ref: crate::resolve::RootedSourceRef {
1102 checkout_root: PathBuf::from(format!("/tmp/{name}")),
1103 package_root: PathBuf::from(format!("/tmp/{name}")),
1104 },
1105 resolved_ref: crate::source::ResolvedRef {
1106 source_name: (*name).into(),
1107 version: Some(version),
1108 version_tag: Some((*tag).to_string()),
1109 commit: Some("abc123".into()),
1110 tree_path: PathBuf::from(format!("/tmp/{name}")),
1111 },
1112 latest_version: None,
1113 manifest: None,
1114 deps: vec![],
1115 },
1116 );
1117 order.push((*name).into());
1118 }
1119
1120 ResolvedGraph {
1121 nodes,
1122 order,
1123 filters: std::collections::HashMap::new(),
1124 }
1125 }
1126
1127 fn model_alias(model: &str) -> crate::models::ModelAlias {
1128 crate::models::ModelAlias {
1129 harness: None,
1130 description: None,
1131 default_effort: None,
1132 autocompact: None,
1133 spec: crate::models::ModelSpec::Pinned {
1134 model: model.to_string(),
1135 provider: None,
1136 },
1137 }
1138 }
1139
1140 fn manifest_with_models(name: &str) -> Manifest {
1141 let mut models = IndexMap::new();
1142 models.insert(
1143 format!("{name}-alias"),
1144 model_alias(&format!("{name}-model")),
1145 );
1146 Manifest {
1147 package: PackageInfo {
1148 name: name.to_string(),
1149 version: "1.0.0".to_string(),
1150 description: None,
1151 },
1152 dependencies: IndexMap::new(),
1153 models,
1154 }
1155 }
1156
1157 fn resolved_node(name: &str, deps: &[&str], with_models: bool) -> ResolvedNode {
1158 let canonical = PathBuf::from(format!("/tmp/{name}"));
1159 ResolvedNode {
1160 source_name: name.into(),
1161 source_id: crate::types::SourceId::Path {
1162 canonical: canonical.clone(),
1163 subpath: None,
1164 },
1165 rooted_ref: crate::resolve::RootedSourceRef {
1166 checkout_root: canonical.clone(),
1167 package_root: canonical.clone(),
1168 },
1169 resolved_ref: crate::source::ResolvedRef {
1170 source_name: name.into(),
1171 version: None,
1172 version_tag: None,
1173 commit: None,
1174 tree_path: canonical,
1175 },
1176 latest_version: None,
1177 manifest: with_models.then(|| manifest_with_models(name)),
1178 deps: deps.iter().map(|dep| (*dep).into()).collect(),
1179 }
1180 }
1181
1182 fn effective_config_with_decl_order(names: &[&str]) -> EffectiveConfig {
1183 let mut dependencies = IndexMap::new();
1184 for name in names {
1185 let canonical = PathBuf::from(format!("/tmp/dep-{name}"));
1186 dependencies.insert(
1187 (*name).into(),
1188 EffectiveDependency {
1189 name: (*name).into(),
1190 id: crate::types::SourceId::Path {
1191 canonical: canonical.clone(),
1192 subpath: None,
1193 },
1194 spec: SourceSpec::Path(canonical),
1195 subpath: None,
1196 filter: FilterMode::All,
1197 rename: crate::types::RenameMap::new(),
1198 is_overridden: false,
1199 original_git: None,
1200 },
1201 );
1202 }
1203 EffectiveConfig {
1204 dependencies,
1205 settings: Settings::default(),
1206 }
1207 }
1208
1209 fn dep_model_names(models: &[crate::models::ResolvedDepModels]) -> Vec<String> {
1210 models.iter().map(|m| m.source_name.clone()).collect()
1211 }
1212
1213 #[test]
1214 fn declaration_ordered_dep_models_sibling_order() {
1215 let mut nodes = IndexMap::new();
1216 nodes.insert("a".into(), resolved_node("a", &[], true));
1217 nodes.insert("b".into(), resolved_node("b", &[], true));
1218
1219 let graph = ResolvedGraph {
1220 nodes,
1221 order: vec!["a".into(), "b".into()],
1222 filters: std::collections::HashMap::new(),
1223 };
1224 let config = effective_config_with_decl_order(&["a", "b"]);
1225
1226 let dep_models = declaration_ordered_dep_models(&graph, &config);
1227 assert_eq!(dep_model_names(&dep_models), vec!["a", "b"]);
1228 }
1229
1230 #[test]
1231 fn declaration_ordered_dep_models_diamond_uses_minimum_sponsor_position() {
1232 let mut nodes = IndexMap::new();
1233 nodes.insert("a".into(), resolved_node("a", &["d"], true));
1234 nodes.insert("b".into(), resolved_node("b", &["d"], true));
1235 nodes.insert("d".into(), resolved_node("d", &[], true));
1236
1237 let graph = ResolvedGraph {
1238 nodes,
1239 order: vec!["d".into(), "a".into(), "b".into()],
1240 filters: std::collections::HashMap::new(),
1241 };
1242 let config = effective_config_with_decl_order(&["a", "b"]);
1243
1244 let dep_models = declaration_ordered_dep_models(&graph, &config);
1245 assert_eq!(dep_model_names(&dep_models), vec!["d", "a", "b"]);
1246 }
1247
1248 #[test]
1249 fn declaration_ordered_dep_models_transitives_follow_sponsor_declaration_order() {
1250 let mut nodes = IndexMap::new();
1251 nodes.insert("a".into(), resolved_node("a", &["d"], false));
1252 nodes.insert("b".into(), resolved_node("b", &["e"], false));
1253 nodes.insert("d".into(), resolved_node("d", &[], true));
1254 nodes.insert("e".into(), resolved_node("e", &[], true));
1255
1256 let graph = ResolvedGraph {
1257 nodes,
1258 order: vec!["d".into(), "e".into(), "a".into(), "b".into()],
1259 filters: std::collections::HashMap::new(),
1260 };
1261 let config = effective_config_with_decl_order(&["a", "b"]);
1262
1263 let dep_models = declaration_ordered_dep_models(&graph, &config);
1264 assert_eq!(dep_model_names(&dep_models), vec!["d", "e"]);
1265 }
1266
1267 #[test]
1268 fn declaration_ordered_dep_models_keeps_deps_before_dependents() {
1269 let mut nodes = IndexMap::new();
1270 nodes.insert("a".into(), resolved_node("a", &["d"], true));
1271 nodes.insert("d".into(), resolved_node("d", &[], true));
1272
1273 let graph = ResolvedGraph {
1274 nodes,
1275 order: vec!["d".into(), "a".into()],
1276 filters: std::collections::HashMap::new(),
1277 };
1278 let config = effective_config_with_decl_order(&["a", "d"]);
1280
1281 let dep_models = declaration_ordered_dep_models(&graph, &config);
1282 assert_eq!(dep_model_names(&dep_models), vec!["d", "a"]);
1283 }
1284
1285 #[test]
1286 fn declaration_ordered_dep_models_is_deterministic() {
1287 let mut nodes = IndexMap::new();
1288 nodes.insert("a".into(), resolved_node("a", &["d"], true));
1289 nodes.insert("b".into(), resolved_node("b", &["e"], true));
1290 nodes.insert("d".into(), resolved_node("d", &[], true));
1291 nodes.insert("e".into(), resolved_node("e", &[], true));
1292
1293 let graph = ResolvedGraph {
1294 nodes,
1295 order: vec!["d".into(), "e".into(), "a".into(), "b".into()],
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}