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