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