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