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