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