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