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