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 if frozen {
849 return ResolveOptions::frozen();
850 }
851
852 match mode {
853 ResolutionMode::Normal => ResolveOptions::sync(),
854 ResolutionMode::Maximize { targets, bump } => {
855 ResolveOptions::upgrade(targets.clone(), *bump)
856 }
857 }
858}
859
860fn planned_bump_entries(
861 config: &Config,
862 graph: &ResolvedGraph,
863 mode: &ResolutionMode,
864) -> Vec<(SourceName, crate::config::DependencyEntry)> {
865 let ResolutionMode::Maximize {
866 targets,
867 bump: true,
868 } = mode
869 else {
870 return Vec::new();
871 };
872
873 config
874 .dependencies
875 .iter()
876 .filter_map(|(name, entry)| {
877 if !targets.is_empty() && !targets.contains(name) {
878 return None;
879 }
880 entry.url.as_ref()?;
882 let node = graph.nodes.get(name)?;
883 let resolved_version = node.resolved_ref.version.as_ref()?;
884 let resolved_tag = node.resolved_ref.version_tag.as_ref()?;
885 if !constraint_needs_bump(entry.version.as_deref(), resolved_version) {
886 return None;
887 }
888 if entry.version.as_deref() == Some(resolved_tag.as_str()) {
889 return None;
890 }
891 let mut bumped = entry.clone();
892 bumped.version = Some(resolved_tag.clone());
893 Some((name.clone(), bumped))
894 })
895 .collect()
896}
897
898fn constraint_needs_bump(current: Option<&str>, resolved: &semver::Version) -> bool {
899 match crate::resolve::parse_version_constraint(current) {
900 crate::resolve::VersionConstraint::Semver(req) => !req.matches(resolved),
901 crate::resolve::VersionConstraint::Latest
902 | crate::resolve::VersionConstraint::RefPin(_) => false,
903 }
904}
905
906fn has_version_changes(changes: &[DependencyUpsertChange]) -> bool {
907 changes
908 .iter()
909 .any(|change| change.old_version != change.new_version)
910}
911
912fn validate_skill_refs(target: &target::TargetState) -> Vec<ValidationWarning> {
915 use crate::lock::ItemKind;
916 use crate::validate::{extract_skills_from_content, find_suggestion};
917
918 let available_skills: HashSet<String> = target
920 .items
921 .values()
922 .filter(|item| item.id.kind == ItemKind::Skill)
923 .map(|item| item.id.name.to_string())
924 .collect();
925
926 let mut warnings = Vec::new();
927
928 for item in target
929 .items
930 .values()
931 .filter(|item| item.id.kind == ItemKind::Agent)
932 {
933 let content = match &item.rewritten_content {
934 Some(content) => content.clone(),
935 None => std::fs::read_to_string(&item.source_path).unwrap_or_default(),
936 };
937 for skill_name in extract_skills_from_content(&content) {
938 if !available_skills.contains(&skill_name) {
939 let suggestion = find_suggestion(&skill_name, &available_skills);
940 warnings.push(ValidationWarning::MissingSkill {
941 agent: item.id.clone(),
942 skill_name,
943 suggestion,
944 });
945 }
946 }
947 }
948
949 warnings
950}
951
952fn validate_skill_frontmatter_in_target(
953 target: &target::TargetState,
954 diag: &mut DiagnosticCollector,
955) {
956 use crate::lock::ItemKind;
957
958 for item in target
959 .items
960 .values()
961 .filter(|item| item.id.kind == ItemKind::Skill)
962 {
963 validate_skill_frontmatter_at_source(&item.source_path, item.id.name.as_str(), diag);
964 }
965}
966
967fn validate_skill_frontmatter_at_source(
968 source_path: &Path,
969 skill_name: &str,
970 diag: &mut DiagnosticCollector,
971) {
972 let skill_md = if source_path.is_dir() {
973 source_path.join("SKILL.md")
974 } else {
975 source_path.to_path_buf()
976 };
977 let Ok(content) = std::fs::read_to_string(&skill_md) else {
978 return;
979 };
980 let mut skill_diags = Vec::new();
981 let _ = crate::compiler::skills::parse_skill_content(&content, &mut skill_diags);
982 for d in skill_diags {
983 if d.is_error() {
984 diag.error_with_category(
985 "skill-schema-error",
986 format!("skill `{skill_name}`: {}", d.message()),
987 crate::diagnostic::DiagnosticCategory::Validation,
988 );
989 } else {
990 diag.warn(
991 "skill-schema-warning",
992 format!("skill `{skill_name}`: {}", d.message()),
993 );
994 }
995 }
996}
997
998#[cfg(test)]
999mod tests {
1000 use super::*;
1001 use crate::config::*;
1002 use crate::lock::{ItemKind, LockFile};
1003 use crate::resolve::{ResolvedGraph, ResolvedNode};
1004 use indexmap::IndexMap;
1005 use std::fs;
1006 use std::path::PathBuf;
1007 use tempfile::TempDir;
1008
1009 struct TestFixture {
1011 project_root: TempDir,
1012 managed_root: PathBuf,
1013 source_trees: Vec<TempDir>,
1014 }
1015
1016 impl TestFixture {
1017 fn new() -> Self {
1018 let project_root = TempDir::new().unwrap();
1019 let managed_root = project_root.path().join(".agents");
1020 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
1022 TestFixture {
1023 project_root,
1024 managed_root,
1025 source_trees: Vec::new(),
1026 }
1027 }
1028
1029 fn add_source(&mut self, agents: &[(&str, &str)], skills: &[(&str, &str)]) -> usize {
1030 let dir = TempDir::new().unwrap();
1031 if !agents.is_empty() {
1032 let agents_dir = dir.path().join("agents");
1033 fs::create_dir_all(&agents_dir).unwrap();
1034 for (name, content) in agents {
1035 fs::write(agents_dir.join(name), content).unwrap();
1036 }
1037 }
1038 if !skills.is_empty() {
1039 let skills_dir = dir.path().join("skills");
1040 fs::create_dir_all(&skills_dir).unwrap();
1041 for (name, content) in skills {
1042 let skill_dir = skills_dir.join(name);
1043 fs::create_dir_all(&skill_dir).unwrap();
1044 fs::write(skill_dir.join("SKILL.md"), content).unwrap();
1045 }
1046 }
1047 self.source_trees.push(dir);
1048 self.source_trees.len() - 1
1049 }
1050
1051 fn project_root(&self) -> &std::path::Path {
1052 self.project_root.path()
1053 }
1054
1055 fn managed_root(&self) -> &std::path::Path {
1056 &self.managed_root
1057 }
1058
1059 fn tree_path(&self, idx: usize) -> PathBuf {
1060 self.source_trees[idx].path().to_path_buf()
1061 }
1062 }
1063
1064 fn make_graph_config(
1065 fixture: &TestFixture,
1066 sources: Vec<(&str, usize, FilterMode)>,
1067 ) -> (ResolvedGraph, EffectiveConfig) {
1068 let mut nodes = IndexMap::new();
1069 let mut order = Vec::new();
1070 let mut config_dependencies = IndexMap::new();
1071
1072 for (name, tree_idx, filter) in sources {
1073 let tree_path = fixture.tree_path(tree_idx);
1074 nodes.insert(
1075 name.into(),
1076 ResolvedNode {
1077 source_name: name.into(),
1078 source_id: crate::types::SourceId::Path {
1079 canonical: tree_path.clone(),
1080 subpath: None,
1081 },
1082 rooted_ref: crate::resolve::RootedSourceRef {
1083 checkout_root: tree_path.clone(),
1084 package_root: tree_path.clone(),
1085 },
1086 resolved_ref: crate::source::ResolvedRef {
1087 source_name: name.into(),
1088 version: None,
1089 version_tag: None,
1090 commit: None,
1091 tree_path: tree_path.clone(),
1092 },
1093 latest_version: None,
1094 manifest: None,
1095 deps: vec![],
1096 },
1097 );
1098 order.push(name.into());
1099
1100 config_dependencies.insert(
1101 name.into(),
1102 EffectiveDependency {
1103 name: name.into(),
1104 id: crate::types::SourceId::Path {
1105 canonical: tree_path.clone(),
1106 subpath: None,
1107 },
1108 spec: SourceSpec::Path(tree_path),
1109 subpath: None,
1110 filter,
1111 rename: crate::types::RenameMap::new(),
1112 is_overridden: false,
1113 original_git: None,
1114 },
1115 );
1116 }
1117
1118 (
1119 ResolvedGraph {
1120 nodes,
1121 order,
1122 filters: std::collections::HashMap::new(),
1123 },
1124 EffectiveConfig {
1125 dependencies: config_dependencies,
1126 settings: Settings::default(),
1127 },
1128 )
1129 }
1130
1131 fn path_dependency_entry(path: &std::path::Path) -> DependencyEntry {
1132 DependencyEntry {
1133 url: None,
1134 path: Some(path.to_path_buf()),
1135 subpath: None,
1136 version: None,
1137 filter: FilterConfig::default(),
1138 }
1139 }
1140
1141 fn git_dependency_entry(url: &str, version: &str, filter: FilterConfig) -> DependencyEntry {
1142 DependencyEntry {
1143 url: Some(url.into()),
1144 path: None,
1145 subpath: None,
1146 version: Some(version.to_string()),
1147 filter,
1148 }
1149 }
1150
1151 fn create_sync_plan(
1152 sync_diff: &diff::SyncDiff,
1153 options: &SyncOptions,
1154 cache_bases_dir: &std::path::Path,
1155 ) -> plan::SyncPlan {
1156 let mut diag = DiagnosticCollector::new();
1157 plan::create(sync_diff, options, cache_bases_dir, &mut diag)
1158 }
1159
1160 fn graph_with_versions(entries: &[(&str, &str, &str)]) -> ResolvedGraph {
1161 let mut nodes = IndexMap::new();
1162 let mut order = Vec::new();
1163 for (name, url, tag) in entries {
1164 let version = semver::Version::parse(tag.trim_start_matches('v')).unwrap();
1165 nodes.insert(
1166 (*name).into(),
1167 ResolvedNode {
1168 source_name: (*name).into(),
1169 source_id: crate::types::SourceId::git(crate::types::SourceUrl::from(*url)),
1170 rooted_ref: crate::resolve::RootedSourceRef {
1171 checkout_root: PathBuf::from(format!("/tmp/{name}")),
1172 package_root: PathBuf::from(format!("/tmp/{name}")),
1173 },
1174 resolved_ref: crate::source::ResolvedRef {
1175 source_name: (*name).into(),
1176 version: Some(version),
1177 version_tag: Some((*tag).to_string()),
1178 commit: Some("abc123".into()),
1179 tree_path: PathBuf::from(format!("/tmp/{name}")),
1180 },
1181 latest_version: None,
1182 manifest: None,
1183 deps: vec![],
1184 },
1185 );
1186 order.push((*name).into());
1187 }
1188
1189 ResolvedGraph {
1190 nodes,
1191 order,
1192 filters: std::collections::HashMap::new(),
1193 }
1194 }
1195
1196 fn model_alias(model: &str) -> crate::models::ModelAlias {
1197 crate::models::ModelAlias {
1198 harness: None,
1199 description: None,
1200 default_effort: None,
1201 autocompact: None,
1202 autocompact_pct: None,
1203 spec: crate::models::ModelSpec::Pinned {
1204 model: model.to_string(),
1205 provider: None,
1206 },
1207 }
1208 }
1209
1210 fn manifest_with_models(name: &str) -> Manifest {
1211 let mut models = IndexMap::new();
1212 models.insert(
1213 format!("{name}-alias"),
1214 model_alias(&format!("{name}-model")),
1215 );
1216 Manifest {
1217 package: PackageInfo {
1218 name: name.to_string(),
1219 version: "1.0.0".to_string(),
1220 description: None,
1221 },
1222 dependencies: IndexMap::new(),
1223 models,
1224 }
1225 }
1226
1227 fn resolved_node(name: &str, deps: &[&str], with_models: bool) -> ResolvedNode {
1228 let canonical = PathBuf::from(format!("/tmp/{name}"));
1229 ResolvedNode {
1230 source_name: name.into(),
1231 source_id: crate::types::SourceId::Path {
1232 canonical: canonical.clone(),
1233 subpath: None,
1234 },
1235 rooted_ref: crate::resolve::RootedSourceRef {
1236 checkout_root: canonical.clone(),
1237 package_root: canonical.clone(),
1238 },
1239 resolved_ref: crate::source::ResolvedRef {
1240 source_name: name.into(),
1241 version: None,
1242 version_tag: None,
1243 commit: None,
1244 tree_path: canonical,
1245 },
1246 latest_version: None,
1247 manifest: with_models.then(|| manifest_with_models(name)),
1248 deps: deps.iter().map(|dep| (*dep).into()).collect(),
1249 }
1250 }
1251
1252 fn effective_config_with_decl_order(names: &[&str]) -> EffectiveConfig {
1253 let mut dependencies = IndexMap::new();
1254 for name in names {
1255 let canonical = PathBuf::from(format!("/tmp/dep-{name}"));
1256 dependencies.insert(
1257 (*name).into(),
1258 EffectiveDependency {
1259 name: (*name).into(),
1260 id: crate::types::SourceId::Path {
1261 canonical: canonical.clone(),
1262 subpath: None,
1263 },
1264 spec: SourceSpec::Path(canonical),
1265 subpath: None,
1266 filter: FilterMode::All,
1267 rename: crate::types::RenameMap::new(),
1268 is_overridden: false,
1269 original_git: None,
1270 },
1271 );
1272 }
1273 EffectiveConfig {
1274 dependencies,
1275 settings: Settings::default(),
1276 }
1277 }
1278
1279 fn dep_model_names(models: &[crate::models::ResolvedDepModels]) -> Vec<String> {
1280 models.iter().map(|m| m.source_name.clone()).collect()
1281 }
1282
1283 #[test]
1284 fn declaration_ordered_dep_models_sibling_order() {
1285 let mut nodes = IndexMap::new();
1286 nodes.insert("a".into(), resolved_node("a", &[], true));
1287 nodes.insert("b".into(), resolved_node("b", &[], true));
1288
1289 let graph = ResolvedGraph {
1290 nodes,
1291 order: vec!["a".into(), "b".into()],
1292 filters: std::collections::HashMap::new(),
1293 };
1294 let config = effective_config_with_decl_order(&["a", "b"]);
1295
1296 let dep_models = declaration_ordered_dep_models(&graph, &config);
1297 assert_eq!(dep_model_names(&dep_models), vec!["a", "b"]);
1298 }
1299
1300 #[test]
1301 fn declaration_ordered_dep_models_diamond_uses_minimum_sponsor_position() {
1302 let mut nodes = IndexMap::new();
1303 nodes.insert("a".into(), resolved_node("a", &["d"], true));
1304 nodes.insert("b".into(), resolved_node("b", &["d"], true));
1305 nodes.insert("d".into(), resolved_node("d", &[], true));
1306
1307 let graph = ResolvedGraph {
1308 nodes,
1309 order: vec!["d".into(), "a".into(), "b".into()],
1310 filters: std::collections::HashMap::new(),
1311 };
1312 let config = effective_config_with_decl_order(&["a", "b"]);
1313
1314 let dep_models = declaration_ordered_dep_models(&graph, &config);
1315 assert_eq!(dep_model_names(&dep_models), vec!["d", "a", "b"]);
1316 }
1317
1318 #[test]
1319 fn declaration_ordered_dep_models_transitives_follow_sponsor_declaration_order() {
1320 let mut nodes = IndexMap::new();
1321 nodes.insert("a".into(), resolved_node("a", &["d"], false));
1322 nodes.insert("b".into(), resolved_node("b", &["e"], false));
1323 nodes.insert("d".into(), resolved_node("d", &[], true));
1324 nodes.insert("e".into(), resolved_node("e", &[], true));
1325
1326 let graph = ResolvedGraph {
1327 nodes,
1328 order: vec!["d".into(), "e".into(), "a".into(), "b".into()],
1329 filters: std::collections::HashMap::new(),
1330 };
1331 let config = effective_config_with_decl_order(&["a", "b"]);
1332
1333 let dep_models = declaration_ordered_dep_models(&graph, &config);
1334 assert_eq!(dep_model_names(&dep_models), vec!["d", "e"]);
1335 }
1336
1337 #[test]
1338 fn declaration_ordered_dep_models_keeps_deps_before_dependents() {
1339 let mut nodes = IndexMap::new();
1340 nodes.insert("a".into(), resolved_node("a", &["d"], true));
1341 nodes.insert("d".into(), resolved_node("d", &[], true));
1342
1343 let graph = ResolvedGraph {
1344 nodes,
1345 order: vec!["d".into(), "a".into()],
1346 filters: std::collections::HashMap::new(),
1347 };
1348 let config = effective_config_with_decl_order(&["a", "d"]);
1350
1351 let dep_models = declaration_ordered_dep_models(&graph, &config);
1352 assert_eq!(dep_model_names(&dep_models), vec!["d", "a"]);
1353 }
1354
1355 #[test]
1356 fn declaration_ordered_dep_models_is_deterministic() {
1357 let mut nodes = IndexMap::new();
1358 nodes.insert("a".into(), resolved_node("a", &["d"], true));
1359 nodes.insert("b".into(), resolved_node("b", &["e"], true));
1360 nodes.insert("d".into(), resolved_node("d", &[], true));
1361 nodes.insert("e".into(), resolved_node("e", &[], true));
1362
1363 let graph = ResolvedGraph {
1364 nodes,
1365 order: vec!["d".into(), "e".into(), "a".into(), "b".into()],
1366 filters: std::collections::HashMap::new(),
1367 };
1368 let config = effective_config_with_decl_order(&["a", "b"]);
1369
1370 let first = dep_model_names(&declaration_ordered_dep_models(&graph, &config));
1371 for _ in 0..10 {
1372 let current = dep_model_names(&declaration_ordered_dep_models(&graph, &config));
1373 assert_eq!(current, first);
1374 }
1375 }
1376
1377 #[test]
1378 fn declaration_ordered_dep_models_is_used_by_resolve_graph_and_finalize() {
1379 let source = include_str!("mod.rs");
1380 assert!(source.contains("declaration_ordered_dep_models(&graph, &loaded.effective)"));
1381 assert!(source.contains("&state.applied.planned.targeted.resolved.loaded.effective"));
1382 }
1383
1384 #[test]
1385 fn validate_request_rejects_frozen_with_maximize() {
1386 let request = SyncRequest {
1387 resolution: ResolutionMode::Maximize {
1388 targets: HashSet::new(),
1389 bump: false,
1390 },
1391 mutation: None,
1392 options: SyncOptions {
1393 force: false,
1394 dry_run: false,
1395 frozen: true,
1396 no_refresh_models: false,
1397 },
1398 };
1399
1400 let err = validate_request(&request).unwrap_err();
1401 assert!(matches!(err, MarsError::InvalidRequest { .. }));
1402 assert!(err.to_string().contains("--frozen"));
1403 }
1404
1405 #[test]
1406 fn validate_request_rejects_frozen_with_mutation() {
1407 let request = SyncRequest {
1408 resolution: ResolutionMode::Normal,
1409 mutation: Some(ConfigMutation::RemoveDependency {
1410 name: "base".into(),
1411 }),
1412 options: SyncOptions {
1413 force: false,
1414 dry_run: false,
1415 frozen: true,
1416 no_refresh_models: false,
1417 },
1418 };
1419
1420 let err = validate_request(&request).unwrap_err();
1421 assert!(matches!(err, MarsError::InvalidRequest { .. }));
1422 assert!(err.to_string().contains("cannot modify config"));
1423 }
1424
1425 #[test]
1426 fn planned_bump_entries_bump_all_outdated_pins() {
1427 let mut config = Config::default();
1428 config.dependencies.insert(
1429 "base".into(),
1430 git_dependency_entry(
1431 "https://example.com/base.git",
1432 "v1.0.0",
1433 FilterConfig::default(),
1434 ),
1435 );
1436 config.dependencies.insert(
1437 "tools".into(),
1438 git_dependency_entry(
1439 "https://example.com/tools.git",
1440 "v2.0.0",
1441 FilterConfig::default(),
1442 ),
1443 );
1444 config.dependencies.insert(
1445 "floating".into(),
1446 DependencyEntry {
1447 url: Some("https://example.com/floating.git".into()),
1448 path: None,
1449 subpath: None,
1450 version: None,
1451 filter: FilterConfig::default(),
1452 },
1453 );
1454
1455 let graph = graph_with_versions(&[
1456 ("base", "https://example.com/base.git", "v1.2.0"),
1457 ("tools", "https://example.com/tools.git", "v2.0.0"),
1458 ("floating", "https://example.com/floating.git", "v3.0.0"),
1459 ]);
1460
1461 let mode = ResolutionMode::Maximize {
1462 targets: HashSet::new(),
1463 bump: true,
1464 };
1465 let entries = planned_bump_entries(&config, &graph, &mode);
1466 assert_eq!(entries.len(), 1);
1467 assert_eq!(entries[0].0, SourceName::from("base"));
1468 assert_eq!(entries[0].1.version.as_deref(), Some("v1.2.0"));
1469 }
1470
1471 #[test]
1472 fn planned_bump_entries_bump_specific_targets_only() {
1473 let mut config = Config::default();
1474 config.dependencies.insert(
1475 "base".into(),
1476 git_dependency_entry(
1477 "https://example.com/base.git",
1478 "v1.0.0",
1479 FilterConfig::default(),
1480 ),
1481 );
1482 config.dependencies.insert(
1483 "tools".into(),
1484 git_dependency_entry(
1485 "https://example.com/tools.git",
1486 "v1.0.0",
1487 FilterConfig::default(),
1488 ),
1489 );
1490
1491 let graph = graph_with_versions(&[
1492 ("base", "https://example.com/base.git", "v2.0.0"),
1493 ("tools", "https://example.com/tools.git", "v2.0.0"),
1494 ]);
1495
1496 let mode = ResolutionMode::Maximize {
1497 targets: HashSet::from([SourceName::from("tools")]),
1498 bump: true,
1499 };
1500 let entries = planned_bump_entries(&config, &graph, &mode);
1501 assert_eq!(entries.len(), 1);
1502 assert_eq!(entries[0].0, SourceName::from("tools"));
1503 assert_eq!(entries[0].1.version.as_deref(), Some("v2.0.0"));
1504 }
1505
1506 #[test]
1507 fn planned_bump_entries_noop_when_already_latest() {
1508 let mut config = Config::default();
1509 config.dependencies.insert(
1510 "base".into(),
1511 git_dependency_entry(
1512 "https://example.com/base.git",
1513 "v1.2.0",
1514 FilterConfig::default(),
1515 ),
1516 );
1517
1518 let graph = graph_with_versions(&[("base", "https://example.com/base.git", "v1.2.0")]);
1519
1520 let mode = ResolutionMode::Maximize {
1521 targets: HashSet::new(),
1522 bump: true,
1523 };
1524 let entries = planned_bump_entries(&config, &graph, &mode);
1525 assert!(entries.is_empty());
1526 }
1527
1528 #[test]
1529 fn planned_bump_entries_preserve_filters_and_renames() {
1530 let mut rename = crate::types::RenameMap::new();
1531 rename.insert("coder".into(), "coder-v2".into());
1532
1533 let mut config = Config::default();
1534 config.dependencies.insert(
1535 "base".into(),
1536 git_dependency_entry(
1537 "https://example.com/base.git",
1538 "v1.0.0",
1539 FilterConfig {
1540 agents: Some(vec!["coder".into()]),
1541 rename: Some(rename.clone()),
1542 ..FilterConfig::default()
1543 },
1544 ),
1545 );
1546
1547 let graph = graph_with_versions(&[("base", "https://example.com/base.git", "v2.0.0")]);
1548 let mode = ResolutionMode::Maximize {
1549 targets: HashSet::new(),
1550 bump: true,
1551 };
1552 let entries = planned_bump_entries(&config, &graph, &mode);
1553 let mut mutated = config.clone();
1554 let changes =
1555 mutation::apply_mutation(&mut mutated, &ConfigMutation::BatchUpsert(entries)).unwrap();
1556
1557 assert_eq!(changes.len(), 1);
1558 assert_eq!(changes[0].old_version.as_deref(), Some("v1.0.0"));
1559 assert_eq!(changes[0].new_version.as_deref(), Some("v2.0.0"));
1560
1561 let dep = &mutated.dependencies["base"];
1562 assert_eq!(dep.version.as_deref(), Some("v2.0.0"));
1563 assert_eq!(dep.filter.agents.as_deref(), Some(&["coder".into()][..]));
1564 assert_eq!(dep.filter.rename.as_ref(), Some(&rename));
1565 }
1566
1567 #[test]
1568 fn execute_auto_inits_config_for_mutation() {
1569 let project_root = TempDir::new().unwrap();
1570 let managed_root = project_root.path().join(".agents");
1571 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
1572 let source = TempDir::new().unwrap();
1573 fs::create_dir_all(source.path().join("agents")).unwrap();
1574 fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
1575
1576 let request = SyncRequest {
1577 resolution: ResolutionMode::Normal,
1578 mutation: Some(ConfigMutation::UpsertDependency {
1579 name: "base".into(),
1580 entry: path_dependency_entry(source.path()),
1581 }),
1582 options: SyncOptions::default(),
1583 };
1584
1585 let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
1586 let report = execute(&ctx, &request).unwrap();
1587 assert!(!report.applied.outcomes.is_empty());
1588 assert!(project_root.path().join("mars.toml").exists());
1589
1590 let saved = crate::config::load(project_root.path()).unwrap();
1591 assert!(saved.dependencies.contains_key("base"));
1592 }
1593
1594 #[test]
1595 fn execute_dry_run_with_mutation_does_not_write_config() {
1596 let project_root = TempDir::new().unwrap();
1597 let managed_root = project_root.path().join(".agents");
1598 fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
1599 crate::config::save(
1600 project_root.path(),
1601 &Config {
1602 dependencies: IndexMap::new(),
1603 settings: Settings::default(),
1604 ..Config::default()
1605 },
1606 )
1607 .unwrap();
1608
1609 let source = TempDir::new().unwrap();
1610 fs::create_dir_all(source.path().join("agents")).unwrap();
1611 fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
1612
1613 let request = SyncRequest {
1614 resolution: ResolutionMode::Normal,
1615 mutation: Some(ConfigMutation::UpsertDependency {
1616 name: "base".into(),
1617 entry: path_dependency_entry(source.path()),
1618 }),
1619 options: SyncOptions {
1620 force: false,
1621 dry_run: true,
1622 frozen: false,
1623 no_refresh_models: false,
1624 },
1625 };
1626
1627 let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
1628 let report = execute(&ctx, &request).unwrap();
1629 assert!(!report.applied.outcomes.is_empty());
1630
1631 let saved = crate::config::load(project_root.path()).unwrap();
1632 assert!(!saved.dependencies.contains_key("base"));
1633 assert!(!managed_root.join("agents/coder.md").exists());
1634 assert!(!project_root.path().join("mars.lock").exists());
1635 }
1636
1637 #[test]
1640 fn full_pipeline_fresh_sync() {
1641 let mut fixture = TestFixture::new();
1642 let src_idx = fixture.add_source(
1643 &[("coder.md", "# Coder agent")],
1644 &[("planning", "# Planning skill")],
1645 );
1646
1647 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1648
1649 let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1651 assert!(renames.is_empty());
1652 assert_eq!(target.items.len(), 2);
1653
1654 let lock = LockFile::empty();
1656 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1657
1658 assert_eq!(sync_diff.items.len(), 2);
1660 for entry in &sync_diff.items {
1661 assert!(matches!(entry, diff::DiffEntry::Add { .. }));
1662 }
1663
1664 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1666 let options = SyncOptions {
1667 force: false,
1668 dry_run: false,
1669 frozen: false,
1670 no_refresh_models: false,
1671 };
1672 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1673 assert_eq!(sync_plan.actions.len(), 2);
1674 for action in &sync_plan.actions {
1675 assert!(matches!(action, plan::PlannedAction::Install { .. }));
1676 }
1677
1678 let result =
1680 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1681 assert_eq!(result.outcomes.len(), 2);
1682
1683 assert!(fixture.managed_root().join("agents/coder.md").exists());
1685 assert!(
1686 fixture
1687 .managed_root()
1688 .join("skills/planning/SKILL.md")
1689 .exists()
1690 );
1691
1692 let new_lock =
1694 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1695 assert_eq!(new_lock.items.len(), 2);
1696 assert!(new_lock.items.contains_key("agent/coder"));
1697 assert!(new_lock.items.contains_key("skill/planning"));
1698 }
1699
1700 #[test]
1701 fn re_sync_no_changes() {
1702 let mut fixture = TestFixture::new();
1703 let content = "# Coder agent";
1704 let src_idx = fixture.add_source(&[("coder.md", content)], &[]);
1705
1706 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1707
1708 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1710 let lock = LockFile::empty();
1711 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1712 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1713 let options = SyncOptions {
1714 force: false,
1715 dry_run: false,
1716 frozen: false,
1717 no_refresh_models: false,
1718 };
1719 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1720 let result =
1721 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1722 let first_lock =
1723 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1724
1725 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1727 let sync_diff2 =
1728 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1729
1730 for entry in &sync_diff2.items {
1732 assert!(
1733 matches!(entry, diff::DiffEntry::Unchanged { .. }),
1734 "expected Unchanged, got {entry:?}"
1735 );
1736 }
1737
1738 let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1739 for action in &sync_plan2.actions {
1740 assert!(matches!(action, plan::PlannedAction::Skip { .. }));
1741 }
1742 }
1743
1744 #[test]
1745 fn validate_skill_refs_ignores_stale_installed_agent_content() {
1746 let mut fixture = TestFixture::new();
1747 let src_idx = fixture.add_source(&[("design-lead.md", "# Design Lead\n")], &[]);
1748 fs::create_dir_all(fixture.managed_root().join("agents")).unwrap();
1749 fs::write(
1750 fixture.managed_root().join("agents/design-lead.md"),
1751 "---\nskills: [handoff]\n---\n# Stale Design Lead\n",
1752 )
1753 .unwrap();
1754
1755 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1756 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1757
1758 let warnings = validate_skill_refs(&target);
1759
1760 assert!(
1761 warnings.is_empty(),
1762 "target source removed the missing ref, but stale installed content produced {warnings:?}"
1763 );
1764 }
1765
1766 #[test]
1767 fn validate_skill_refs_warns_for_missing_target_source_ref() {
1768 let mut fixture = TestFixture::new();
1769 let src_idx = fixture.add_source(
1770 &[("coder.md", "---\nskills: [missing-skill]\n---\n# Coder\n")],
1771 &[],
1772 );
1773
1774 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1775 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1776
1777 let warnings = validate_skill_refs(&target);
1778
1779 assert_eq!(warnings.len(), 1);
1780 match &warnings[0] {
1781 ValidationWarning::MissingSkill {
1782 agent,
1783 skill_name,
1784 suggestion,
1785 } => {
1786 assert_eq!(agent.name, "coder");
1787 assert_eq!(skill_name, "missing-skill");
1788 assert_eq!(suggestion, &None);
1789 }
1790 }
1791 }
1792
1793 #[test]
1794 fn validate_skill_refs_uses_rewritten_content() {
1795 let fixture = TestFixture::new();
1796 let source_path = fixture.project_root().join("source-agent.md");
1797 fs::write(
1798 &source_path,
1799 "---\nskills: [old-skill]\n---\n# Source content before rewrite\n",
1800 )
1801 .unwrap();
1802 let skill_path = fixture.project_root().join("skills").join("new-skill");
1803 fs::create_dir_all(&skill_path).unwrap();
1804 fs::write(skill_path.join("SKILL.md"), "# New Skill\n").unwrap();
1805
1806 let source_name = SourceName::from("base");
1807 let source_id = SourceId::Path {
1808 canonical: fixture.project_root().to_path_buf(),
1809 subpath: None,
1810 };
1811 let mut items = IndexMap::new();
1812 items.insert(
1813 DestPath::new("agents/coder.md").unwrap(),
1814 TargetItem {
1815 id: ItemId {
1816 kind: ItemKind::Agent,
1817 name: "coder".into(),
1818 },
1819 source_name: source_name.clone(),
1820 origin: SourceOrigin::Dependency(source_name.clone()),
1821 source_id: source_id.clone(),
1822 source_path,
1823 dest_path: DestPath::new("agents/coder.md").unwrap(),
1824 source_hash: ContentHash::from("sha256:source"),
1825 is_flat_skill: false,
1826 rewritten_content: Some(
1827 "---\nskills: [new-skill]\n---\n# Rewritten content\n".to_string(),
1828 ),
1829 },
1830 );
1831 items.insert(
1832 DestPath::new("skills/new-skill").unwrap(),
1833 TargetItem {
1834 id: ItemId {
1835 kind: ItemKind::Skill,
1836 name: "new-skill".into(),
1837 },
1838 source_name: source_name.clone(),
1839 origin: SourceOrigin::Dependency(source_name),
1840 source_id,
1841 source_path: skill_path,
1842 dest_path: DestPath::new("skills/new-skill").unwrap(),
1843 source_hash: ContentHash::from("sha256:skill"),
1844 is_flat_skill: false,
1845 rewritten_content: None,
1846 },
1847 );
1848 let target = TargetState { items };
1849
1850 let warnings = validate_skill_refs(&target);
1851
1852 assert!(
1853 warnings.is_empty(),
1854 "validation should use rewritten content instead of stale source content: {warnings:?}"
1855 );
1856 }
1857
1858 #[test]
1859 fn source_update_detects_changes() {
1860 let mut fixture = TestFixture::new();
1861 let src_idx = fixture.add_source(&[("coder.md", "# Version 1")], &[]);
1862
1863 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1864
1865 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1867 let lock = LockFile::empty();
1868 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1869 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1870 let options = SyncOptions {
1871 force: false,
1872 dry_run: false,
1873 frozen: false,
1874 no_refresh_models: false,
1875 };
1876 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1877 let result =
1878 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1879 let first_lock =
1880 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1881
1882 let agents_dir = fixture.tree_path(src_idx).join("agents");
1884 fs::write(agents_dir.join("coder.md"), "# Version 2").unwrap();
1885
1886 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1888 let sync_diff2 =
1889 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1890
1891 assert_eq!(sync_diff2.items.len(), 1);
1893 assert!(matches!(
1894 &sync_diff2.items[0],
1895 diff::DiffEntry::Update { .. }
1896 ));
1897 }
1898
1899 #[test]
1900 fn local_modification_preserved() {
1901 let mut fixture = TestFixture::new();
1902 let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1903
1904 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1905
1906 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1908 let lock = LockFile::empty();
1909 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1910 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1911 let options = SyncOptions {
1912 force: false,
1913 dry_run: false,
1914 frozen: false,
1915 no_refresh_models: false,
1916 };
1917 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1918 let result =
1919 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1920 let first_lock =
1921 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1922
1923 fs::write(
1925 fixture.managed_root().join("agents/coder.md"),
1926 "# Locally modified",
1927 )
1928 .unwrap();
1929
1930 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1932 let sync_diff2 =
1933 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1934
1935 assert_eq!(sync_diff2.items.len(), 1);
1937 assert!(matches!(
1938 &sync_diff2.items[0],
1939 diff::DiffEntry::LocalModified { .. }
1940 ));
1941
1942 let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1944 assert!(matches!(
1945 &sync_plan2.actions[0],
1946 plan::PlannedAction::KeepLocal { .. }
1947 ));
1948 }
1949
1950 #[test]
1951 fn force_overwrites_local_modifications() {
1952 let mut fixture = TestFixture::new();
1953 let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1954
1955 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1956
1957 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1959 let lock = LockFile::empty();
1960 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1961 let cache_dir = fixture.project_root().join(".mars/cache/bases");
1962 let options = SyncOptions {
1963 force: false,
1964 dry_run: false,
1965 frozen: false,
1966 no_refresh_models: false,
1967 };
1968 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1969 let result =
1970 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1971 let first_lock =
1972 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
1973
1974 fs::write(
1976 fixture.managed_root().join("agents/coder.md"),
1977 "# Locally modified",
1978 )
1979 .unwrap();
1980
1981 let agents_dir = fixture.tree_path(src_idx).join("agents");
1983 fs::write(agents_dir.join("coder.md"), "# Upstream update").unwrap();
1984
1985 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1987 let sync_diff2 =
1988 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1989
1990 let force_options = SyncOptions {
1991 force: true,
1992 dry_run: false,
1993 frozen: false,
1994 no_refresh_models: false,
1995 };
1996 let sync_plan2 = create_sync_plan(&sync_diff2, &force_options, &cache_dir);
1997 assert!(matches!(
1998 &sync_plan2.actions[0],
1999 plan::PlannedAction::Overwrite { .. }
2000 ));
2001
2002 let result2 = apply::execute(
2003 fixture.managed_root(),
2004 &sync_plan2,
2005 &force_options,
2006 &cache_dir,
2007 )
2008 .unwrap();
2009 assert!(matches!(
2010 result2.outcomes[0].action,
2011 apply::ActionTaken::Updated
2012 ));
2013
2014 let content = fs::read_to_string(fixture.managed_root().join("agents/coder.md")).unwrap();
2016 assert_eq!(content, "# Upstream update");
2017 }
2018
2019 #[test]
2020 fn orphan_removed_when_source_drops_item() {
2021 let mut fixture = TestFixture::new();
2022 let src_idx = fixture.add_source(
2023 &[("coder.md", "# Coder"), ("reviewer.md", "# Reviewer")],
2024 &[],
2025 );
2026
2027 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
2028
2029 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2031 let lock = LockFile::empty();
2032 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
2033 let cache_dir = fixture.project_root().join(".mars/cache/bases");
2034 let options = SyncOptions {
2035 force: false,
2036 dry_run: false,
2037 frozen: false,
2038 no_refresh_models: false,
2039 };
2040 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
2041 let result =
2042 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
2043 let first_lock =
2044 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
2045
2046 assert!(fixture.managed_root().join("agents/coder.md").exists());
2047 assert!(fixture.managed_root().join("agents/reviewer.md").exists());
2048
2049 fs::remove_file(fixture.tree_path(src_idx).join("agents/reviewer.md")).unwrap();
2051
2052 let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
2054 let sync_diff2 =
2055 diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
2056
2057 let orphan_count = sync_diff2
2059 .items
2060 .iter()
2061 .filter(|e| matches!(e, diff::DiffEntry::Orphan { .. }))
2062 .count();
2063 assert_eq!(orphan_count, 1);
2064
2065 let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
2066 let result2 =
2067 apply::execute(fixture.managed_root(), &sync_plan2, &options, &cache_dir).unwrap();
2068
2069 assert!(!fixture.managed_root().join("agents/reviewer.md").exists());
2071 assert!(fixture.managed_root().join("agents/coder.md").exists());
2073
2074 let removed = result2
2076 .outcomes
2077 .iter()
2078 .any(|o| matches!(o.action, apply::ActionTaken::Removed));
2079 assert!(removed);
2080 }
2081
2082 #[test]
2083 fn dry_run_produces_plan_without_changes() {
2084 let mut fixture = TestFixture::new();
2085 let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
2086
2087 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
2088
2089 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2090 let lock = LockFile::empty();
2091 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
2092
2093 let cache_dir = fixture.project_root().join(".mars/cache/bases");
2094 let dry_options = SyncOptions {
2095 force: false,
2096 dry_run: true,
2097 frozen: false,
2098 no_refresh_models: false,
2099 };
2100
2101 let sync_plan = create_sync_plan(&sync_diff, &dry_options, &cache_dir);
2102 assert!(!sync_plan.actions.is_empty());
2103
2104 let result =
2106 apply::execute(fixture.managed_root(), &sync_plan, &dry_options, &cache_dir).unwrap();
2107 assert!(!result.outcomes.is_empty());
2108
2109 assert!(!fixture.managed_root().join("agents/coder.md").exists());
2111 }
2112
2113 #[test]
2114 fn lock_written_after_apply() {
2115 let mut fixture = TestFixture::new();
2116 let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
2117
2118 let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
2119
2120 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2122 let lock = LockFile::empty();
2123 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
2124 let cache_dir = fixture.project_root().join(".mars/cache/bases");
2125 let options = SyncOptions {
2126 force: false,
2127 dry_run: false,
2128 frozen: false,
2129 no_refresh_models: false,
2130 };
2131 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
2132 let result =
2133 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
2134
2135 let new_lock =
2136 crate::lock::build(&graph, &result, &lock, std::collections::BTreeMap::new()).unwrap();
2137 crate::lock::write(fixture.project_root(), &new_lock).unwrap();
2138
2139 let reloaded = crate::lock::load(fixture.project_root()).unwrap();
2141 assert_eq!(reloaded.items.len(), 1);
2142 assert!(reloaded.items.contains_key("agent/coder"));
2143
2144 let item = &reloaded.items["agent/coder"];
2145 assert_eq!(item.kind, ItemKind::Agent);
2146 assert!(!item.source_checksum.is_empty());
2147 assert!(!item.outputs[0].installed_checksum.is_empty());
2148 }
2149
2150 #[test]
2151 fn two_sources_no_collision() {
2152 let mut fixture = TestFixture::new();
2153 let src_a = fixture.add_source(&[("coder.md", "# Coder from A")], &[]);
2154 let src_b = fixture.add_source(&[("reviewer.md", "# Reviewer from B")], &[]);
2155
2156 let (graph, config) = make_graph_config(
2157 &fixture,
2158 vec![
2159 ("source-a", src_a, FilterMode::All),
2160 ("source-b", src_b, FilterMode::All),
2161 ],
2162 );
2163
2164 let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
2165 assert!(renames.is_empty());
2166 assert_eq!(target.items.len(), 2);
2167
2168 let lock = LockFile::empty();
2169 let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
2170 let cache_dir = fixture.project_root().join(".mars/cache/bases");
2171 let options = SyncOptions {
2172 force: false,
2173 dry_run: false,
2174 frozen: false,
2175 no_refresh_models: false,
2176 };
2177 let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
2178 let result =
2179 apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
2180
2181 assert!(fixture.managed_root().join("agents/coder.md").exists());
2182 assert!(fixture.managed_root().join("agents/reviewer.md").exists());
2183 assert_eq!(result.outcomes.len(), 2);
2184 }
2185
2186 #[test]
2189 fn pipeline_only_skills_filter() {
2190 let mut fixture = TestFixture::new();
2191 let src_idx = fixture.add_source(
2192 &[("coder.md", "# Coder agent")],
2193 &[("planning", "# Planning skill")],
2194 );
2195
2196 let (graph, config) =
2197 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlySkills)]);
2198
2199 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2200 assert_eq!(target.items.len(), 1);
2202 assert!(target.items.contains_key("skills/planning"));
2203 }
2204
2205 #[test]
2206 fn pipeline_only_agents_filter() {
2207 let mut fixture = TestFixture::new();
2208 let agent_content = "---\nskills:\n - planning\n---\n# Coder agent";
2210 let src_idx = fixture.add_source(
2211 &[("coder.md", agent_content)],
2212 &[
2213 ("planning", "# Planning skill"),
2214 ("standalone", "# Standalone skill"),
2215 ],
2216 );
2217
2218 let (graph, config) =
2219 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
2220
2221 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2222 assert_eq!(target.items.len(), 2);
2224 assert!(target.items.contains_key("agents/coder.md"));
2225 assert!(target.items.contains_key("skills/planning"));
2226 assert!(!target.items.contains_key("skills/standalone"));
2227 }
2228
2229 #[test]
2230 fn pipeline_only_agents_no_agents_source() {
2231 let mut fixture = TestFixture::new();
2232 let src_idx = fixture.add_source(&[], &[("planning", "# Planning skill")]);
2233
2234 let (graph, config) =
2235 make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
2236
2237 let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2238 assert_eq!(target.items.len(), 0);
2240 }
2241}