Skip to main content

mars_agents/sync/
mod.rs

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::{BinaryHeap, HashMap, HashSet, VecDeque};
13use std::path::Path;
14use std::path::PathBuf;
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::local_source;
22use crate::lock::LockFile;
23use crate::lock::{ItemId, ItemKind};
24use crate::resolve::{ResolveOptions, ResolvedGraph};
25use crate::source::GlobalCache;
26use crate::sync::apply::ApplyResult;
27pub use crate::sync::apply::SyncOptions;
28use crate::sync::target::{ExplicitSkillRename, TargetItem, TargetState};
29use crate::types::{ContentHash, DestPath, MarsContext, SourceId, SourceName, SourceOrigin};
30use crate::validate::ValidationWarning;
31
32// Re-export mutation types for public API compatibility.
33pub use crate::sync::mutation::{ConfigMutation, DependencyUpsertChange, apply_config_mutation};
34
35/// Report from a completed sync operation.
36#[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    /// Per-target sync outcomes from the target sync phase.
44    pub target_outcomes: Vec<crate::target_sync::TargetSyncOutcome>,
45    /// Whether this was a dry run (`--diff`). Affects output wording only.
46    pub dry_run: bool,
47}
48
49impl SyncReport {
50    /// Whether the sync produced any unresolved conflicts.
51    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/// What a CLI command requests from the sync pipeline.
60#[derive(Debug, Clone)]
61pub struct SyncRequest {
62    /// How to resolve versions.
63    pub resolution: ResolutionMode,
64    /// Config mutation to apply under flock.
65    pub mutation: Option<ConfigMutation>,
66    /// Behavior flags.
67    pub options: SyncOptions,
68}
69
70/// Resolution behavior for the resolver stage.
71#[derive(Debug, Clone)]
72pub enum ResolutionMode {
73    /// Normal sync behavior.
74    Normal,
75    /// Upgrade behavior (maximize versions), optionally scoped to specific
76    /// sources and optionally bumping direct constraints.
77    Maximize {
78        targets: HashSet<SourceName>,
79        bump: bool,
80    },
81}
82
83// ---------------------------------------------------------------------------
84// Pipeline phase structs — typed handoffs between pipeline stages.
85// Phase functions consume prior state by value (move semantics, no cloning).
86// ---------------------------------------------------------------------------
87
88/// Phase 1: Load and validate configuration under sync lock.
89pub 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    pub _sync_lock: FileLock,
96}
97
98/// Phase 2: Resolved dependency graph.
99pub struct ResolvedState {
100    pub loaded: LoadedConfig,
101    pub graph: ResolvedGraph,
102    pub model_aliases: indexmap::IndexMap<String, crate::models::ModelAlias>,
103}
104
105/// Phase 3: Desired target state after discovery + filtering.
106pub struct TargetedState {
107    pub resolved: ResolvedState,
108    pub target: TargetState,
109    pub renames: Vec<ExplicitSkillRename>,
110    pub warnings: Vec<ValidationWarning>,
111}
112
113/// Phase 4: Diff + plan ready for execution.
114pub struct PlannedState {
115    pub targeted: TargetedState,
116    pub plan: plan::SyncPlan,
117}
118
119/// Phase 5: Applied results.
120pub struct AppliedState {
121    pub planned: PlannedState,
122    pub applied: ApplyResult,
123}
124
125/// Phase 6: Target sync results.
126pub struct SyncedState {
127    pub applied: AppliedState,
128    pub target_outcomes: Vec<crate::target_sync::TargetSyncOutcome>,
129}
130
131/// Execute the unified sync pipeline.
132///
133/// Orchestrates phase functions, each consuming the prior phase's output struct.
134pub fn execute(ctx: &MarsContext, request: &SyncRequest) -> Result<SyncReport, MarsError> {
135    validate_request(request)?;
136    let mut diag = DiagnosticCollector::new();
137    let loaded = load_config(ctx, request, &mut diag)?;
138    let resolved = resolve_graph(ctx, loaded, request, &mut diag)?;
139    let targeted = build_target(ctx, resolved, request, &mut diag)?;
140    let planned = create_plan(ctx, targeted, request, &mut diag)?;
141    if request.options.frozen {
142        check_frozen_gate(&planned)?;
143    }
144    let applied = apply_plan(ctx, planned, request)?;
145    let synced = sync_targets(ctx, applied, request, &mut diag);
146    let report = finalize(ctx, synced, request, &mut diag)?;
147    Ok(report)
148}
149
150// ---------------------------------------------------------------------------
151// Phase functions
152// ---------------------------------------------------------------------------
153
154/// Phase 1: Acquire sync lock, load config, apply mutations, merge effective config,
155/// and load the existing lock file.
156fn load_config(
157    ctx: &MarsContext,
158    request: &SyncRequest,
159    diag: &mut DiagnosticCollector,
160) -> Result<LoadedConfig, MarsError> {
161    let project_root = &ctx.project_root;
162    let mars_dir = project_root.join(".mars");
163
164    std::fs::create_dir_all(mars_dir.join("cache"))?;
165
166    // Acquire sync lock before any config reads/mutations.
167    let lock_path = mars_dir.join("sync.lock");
168    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
169
170    // Load config under lock (auto-init when mutating and missing).
171    let mut config = match crate::config::load(project_root) {
172        Ok(config) => config,
173        Err(err) if mutation::is_config_not_found(&err) && request.mutation.is_some() => Config {
174            settings: Settings::default(),
175            ..Config::default()
176        },
177        Err(err) => return Err(err),
178    };
179
180    // Apply config mutation.
181    let dependency_changes = if let Some(m) = &request.mutation {
182        mutation::apply_mutation(&mut config, m)?
183    } else {
184        Vec::new()
185    };
186
187    // Load/mutate local overrides under the same lock.
188    let mut local = crate::config::load_local(project_root)?;
189    if let Some(m) = &request.mutation {
190        mutation::apply_local_mutation(&mut local, m);
191    }
192
193    // Build effective config.
194    let (effective, config_diagnostics) =
195        crate::config::merge_with_root(config.clone(), local.clone(), project_root)?;
196    diag.extend(config_diagnostics);
197
198    // Load existing lock file.
199    let old_lock = crate::lock::load(project_root)?;
200
201    Ok(LoadedConfig {
202        config,
203        local,
204        effective,
205        old_lock,
206        dependency_changes,
207        _sync_lock,
208    })
209}
210
211/// Phase 2: Validate upgrade targets, resolve the dependency graph.
212fn resolve_graph(
213    ctx: &MarsContext,
214    mut loaded: LoadedConfig,
215    request: &SyncRequest,
216    diag: &mut DiagnosticCollector,
217) -> Result<ResolvedState, MarsError> {
218    validate_targets(&request.resolution, &loaded.effective)?;
219
220    let cache = GlobalCache::new()?;
221    let source_provider = provider::RealSourceProvider {
222        cache: &cache,
223        project_root: &ctx.project_root,
224    };
225    let resolve_options = to_resolve_options(&request.resolution, request.options.frozen);
226    let graph = crate::resolve::resolve(
227        &loaded.effective,
228        &source_provider,
229        Some(&loaded.old_lock),
230        &resolve_options,
231        diag,
232    )?;
233
234    let bump_entries = planned_bump_entries(&loaded.config, &graph, &request.resolution);
235    if !bump_entries.is_empty() {
236        let bump_changes = mutation::apply_mutation(
237            &mut loaded.config,
238            &ConfigMutation::BatchUpsert(bump_entries),
239        )?;
240        loaded.dependency_changes.extend(bump_changes);
241    }
242
243    // Merge model config from dependency tree
244    let dep_models = declaration_ordered_dep_models(&graph, &loaded.effective);
245    let model_aliases = crate::models::merge_model_config(&loaded.config.models, &dep_models, diag);
246
247    Ok(ResolvedState {
248        loaded,
249        graph,
250        model_aliases,
251    })
252}
253
254/// Phase 3: Build target state, handle collisions, rewrite frontmatter refs, validate.
255fn build_target(
256    ctx: &MarsContext,
257    resolved: ResolvedState,
258    _request: &SyncRequest,
259    diag: &mut DiagnosticCollector,
260) -> Result<TargetedState, MarsError> {
261    // Use .mars/ as the canonical content root for diff/collision checks.
262    let mars_dir = ctx.project_root.join(".mars");
263    let managed_root = &mars_dir;
264
265    // Build target state from resolved graph.
266    let (mut target_state, renames) =
267        target::build_with_collisions(&resolved.graph, &resolved.loaded.effective)?;
268
269    let local_source_name: SourceName = SourceOrigin::LocalPackage.to_string().into();
270    let local_source_id = SourceId::Path {
271        canonical: ctx
272            .project_root
273            .canonicalize()
274            .unwrap_or_else(|_| ctx.project_root.clone()),
275        subpath: None,
276    };
277
278    let local_items = local_source::discover_local_items(
279        &ctx.project_root,
280        resolved.loaded.config.package.is_some(),
281        Some(local_source_name.as_str()),
282        diag,
283    )?;
284    for item in local_items {
285        let source_path = item.disk_path();
286        let is_flat_skill = item.discovered.id.kind == ItemKind::Skill
287            && item.discovered.source_path == Path::new(".");
288        let source_hash = if is_flat_skill {
289            ContentHash::from(hash::compute_skill_hash_filtered(
290                &source_path,
291                crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL,
292            )?)
293        } else {
294            ContentHash::from(hash::compute_hash(&source_path, item.discovered.id.kind)?)
295        };
296        let dest_path =
297            default_dest_path(item.discovered.id.kind, item.discovered.id.name.as_str());
298
299        if let Some(existing) = target_state.items.shift_remove(&dest_path) {
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 = managed_root.join(dest_path.as_path());
314        if !resolved.loaded.old_lock.items.contains_key(&dest_path)
315            && disk_path.symlink_metadata().is_ok()
316        {
317            diag.warn(
318                "unmanaged-collision",
319                format!(
320                    "local {} `{}` collides with unmanaged path `{}` — leaving existing content untouched",
321                    item.discovered.id.kind, item.discovered.id.name, dest_path
322                ),
323            );
324            continue;
325        }
326
327        target_state.items.insert(
328            dest_path.clone(),
329            TargetItem {
330                id: ItemId {
331                    kind: item.discovered.id.kind,
332                    name: item.discovered.id.name.clone(),
333                },
334                source_name: local_source_name.clone(),
335                origin: SourceOrigin::LocalPackage,
336                source_id: local_source_id.clone(),
337                source_path,
338                dest_path,
339                source_hash,
340                is_flat_skill,
341                rewritten_content: None,
342            },
343        );
344    }
345
346    // Handle collisions + rewrite frontmatter refs.
347    if !renames.is_empty() {
348        let rewrite_warnings =
349            target::rewrite_skill_refs(&mut target_state, &renames, &resolved.graph)?;
350        for w in &rewrite_warnings {
351            diag.warn("rewrite-warning", w.to_string());
352        }
353    }
354
355    // Validate skill references.
356    let warnings = validate_skill_refs(managed_root, &target_state);
357
358    // Prevent managed installs from overwriting unmanaged files.
359    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        renames,
376        warnings,
377    })
378}
379
380/// Phase 4: Compute diff, create plan.
381fn create_plan(
382    ctx: &MarsContext,
383    targeted: TargetedState,
384    request: &SyncRequest,
385    diag: &mut DiagnosticCollector,
386) -> Result<PlannedState, MarsError> {
387    // Diff against .mars/ canonical store.
388    let mars_dir = ctx.project_root.join(".mars");
389    let managed_root = &mars_dir;
390    let cache_bases_dir = mars_dir.join("cache").join("bases");
391
392    // Compute diff.
393    let sync_diff = diff::compute(
394        managed_root,
395        &targeted.resolved.loaded.old_lock,
396        &targeted.target,
397        request.options.force,
398    )?;
399
400    if !request.options.force {
401        for entry in &sync_diff.items {
402            if let diff::DiffEntry::LocalModified { target, .. } = entry {
403                diag.warn(
404                    "disk-lock-divergent",
405                    format!(
406                        "{} diverged from mars.lock checksum; preserving local content (run `mars sync --force` or `mars repair` to reset)",
407                        target.dest_path
408                    ),
409                );
410            }
411        }
412    }
413
414    // Create plan.
415    let sync_plan = plan::create(&sync_diff, &request.options, &cache_bases_dir, diag);
416
417    Ok(PlannedState {
418        targeted,
419        plan: sync_plan,
420    })
421}
422
423/// Check that a frozen sync has no pending changes.
424fn check_frozen_gate(planned: &PlannedState) -> Result<(), MarsError> {
425    let has_changes = planned.plan.actions.iter().any(|a| {
426        !matches!(
427            a,
428            plan::PlannedAction::Skip { .. } | plan::PlannedAction::KeepLocal { .. }
429        )
430    });
431    if has_changes {
432        return Err(MarsError::FrozenViolation {
433            message: "lock file would change but --frozen is set".into(),
434        });
435    }
436    Ok(())
437}
438
439/// Phase 5: Persist config if mutated, apply plan to .mars/ canonical store.
440fn apply_plan(
441    ctx: &MarsContext,
442    planned: PlannedState,
443    request: &SyncRequest,
444) -> Result<AppliedState, MarsError> {
445    let project_root = &ctx.project_root;
446    let mars_dir = project_root.join(".mars");
447    let cache_bases_dir = mars_dir.join("cache").join("bases");
448
449    let has_bump_version_changes =
450        has_version_changes(&planned.targeted.resolved.loaded.dependency_changes)
451            && matches!(
452                request.resolution,
453                ResolutionMode::Maximize { bump: true, .. }
454            );
455    let has_mutation = request.mutation.is_some() || has_bump_version_changes;
456
457    // Persist config/local only after validation gate and before apply.
458    if has_mutation && !request.options.dry_run {
459        match &request.mutation {
460            Some(ConfigMutation::SetOverride { .. } | ConfigMutation::ClearOverride { .. }) => {
461                crate::config::save_local(project_root, &planned.targeted.resolved.loaded.local)?;
462            }
463            Some(
464                ConfigMutation::UpsertDependency { .. }
465                | ConfigMutation::BatchUpsert(..)
466                | ConfigMutation::RemoveDependency { .. }
467                | ConfigMutation::SetRename { .. },
468            ) => {
469                crate::config::save(project_root, &planned.targeted.resolved.loaded.config)?;
470            }
471            None => {
472                if has_bump_version_changes {
473                    crate::config::save(project_root, &planned.targeted.resolved.loaded.config)?;
474                }
475            }
476        }
477    }
478
479    // Apply plan to .mars/ canonical store (D25).
480    // Content is written to .mars/agents/ and .mars/skills/, then
481    // sync_targets() copies to all managed target directories.
482    let applied = apply::execute(&mars_dir, &planned.plan, &request.options, &cache_bases_dir)?;
483
484    Ok(AppliedState { planned, applied })
485}
486
487/// Phase 6: Sync managed targets from .mars/ canonical store.
488///
489/// Copies content from .mars/ to all configured target directories.
490/// Non-fatal — target sync errors are recorded as diagnostics.
491/// Lock is written regardless of target sync outcome (D21).
492fn sync_targets(
493    ctx: &MarsContext,
494    applied: AppliedState,
495    request: &SyncRequest,
496    diag: &mut DiagnosticCollector,
497) -> SyncedState {
498    if request.options.dry_run {
499        return SyncedState {
500            applied,
501            target_outcomes: Vec::new(),
502        };
503    }
504
505    let mars_dir = ctx.project_root.join(".mars");
506    let targets = applied
507        .planned
508        .targeted
509        .resolved
510        .loaded
511        .effective
512        .settings
513        .managed_targets();
514    let previous_managed_paths = applied
515        .planned
516        .targeted
517        .resolved
518        .loaded
519        .old_lock
520        .items
521        .keys()
522        .map(|dest_path| dest_path.as_path().to_path_buf())
523        .collect::<HashSet<PathBuf>>();
524
525    let target_outcomes = crate::target_sync::sync_managed_targets(
526        &ctx.project_root,
527        &mars_dir,
528        &targets,
529        &applied.applied.outcomes,
530        &previous_managed_paths,
531        request.options.force,
532        diag,
533    );
534
535    SyncedState {
536        applied,
537        target_outcomes,
538    }
539}
540
541/// Phase 7: Write lock file, construct SyncReport.
542///
543/// Lock is written regardless of target sync outcome (D21).
544fn finalize(
545    ctx: &MarsContext,
546    state: SyncedState,
547    request: &SyncRequest,
548    diag: &mut DiagnosticCollector,
549) -> Result<SyncReport, MarsError> {
550    let project_root = &ctx.project_root;
551    let old_lock = &state.applied.planned.targeted.resolved.loaded.old_lock;
552    let graph = &state.applied.planned.targeted.resolved.graph;
553
554    // Write lock file (D21 — regardless of target sync outcome).
555    if !request.options.dry_run {
556        let new_lock = crate::lock::build(graph, &state.applied.applied, old_lock)?;
557        crate::lock::write(project_root, &new_lock)?;
558
559        // Persist dependency-only model aliases so `mars models list` can load
560        // deps from cache, then overlay current consumer config without keeping
561        // stale consumer aliases from prior syncs.
562        let dep_models = declaration_ordered_dep_models(
563            graph,
564            &state.applied.planned.targeted.resolved.loaded.effective,
565        );
566        let empty_consumer: indexmap::IndexMap<String, crate::models::ModelAlias> =
567            indexmap::IndexMap::new();
568        let mut ignored_diag = DiagnosticCollector::new();
569        let dep_model_aliases =
570            crate::models::merge_model_config(&empty_consumer, &dep_models, &mut ignored_diag);
571
572        // Best-effort models cache refresh: ensure the catalog covers any
573        // new aliases we're about to persist. Sync never aborts on refresh
574        // failure — warn and continue. Skip on dry_run (side-effect-free).
575        if !request.options.dry_run {
576            let mars_path = ctx.project_root.join(".mars");
577            let ttl = crate::models::load_models_cache_ttl(ctx);
578            let mode = crate::models::resolve_refresh_mode(request.options.no_refresh_models);
579            match crate::models::ensure_fresh(&mars_path, ttl, mode) {
580                Ok((_, crate::models::RefreshOutcome::StaleFallback { reason })) => {
581                    diag.warn(
582                        "models-cache-refresh",
583                        format!("using stale models cache: {reason}"),
584                    );
585                }
586                Ok((_, crate::models::RefreshOutcome::Offline)) => {}
587                Ok(_) => {}
588                Err(err) => {
589                    diag.warn(
590                        "models-cache-refresh",
591                        format!("failed to refresh models cache: {err}"),
592                    );
593                }
594            }
595        }
596
597        match serde_json::to_string_pretty(&dep_model_aliases) {
598            Ok(json) => {
599                let merged_path = ctx.project_root.join(".mars").join("models-merged.json");
600                if let Err(err) = crate::fs::atomic_write(&merged_path, json.as_bytes()) {
601                    diag.warn(
602                        "models-merge-write",
603                        format!("failed to write models-merged.json: {err}"),
604                    );
605                }
606            }
607            Err(err) => {
608                diag.warn(
609                    "models-merge-write",
610                    format!("failed to serialize merged model aliases: {err}"),
611                );
612            }
613        }
614    }
615
616    for w in &state.applied.planned.targeted.warnings {
617        match w {
618            ValidationWarning::MissingSkill {
619                agent,
620                skill_name,
621                suggestion,
622            } => {
623                let msg = match suggestion {
624                    Some(s) => format!(
625                        "agent `{}` references missing skill `{}` (did you mean `{}`?)",
626                        agent.name, skill_name, s
627                    ),
628                    None => {
629                        format!(
630                            "agent `{}` references missing skill `{}`",
631                            agent.name, skill_name
632                        )
633                    }
634                };
635                diag.warn("missing-skill", msg);
636            }
637        }
638    }
639    let dependency_changes = state
640        .applied
641        .planned
642        .targeted
643        .resolved
644        .loaded
645        .dependency_changes;
646    let upgrades_available = if request.options.frozen {
647        0
648    } else {
649        graph
650            .nodes
651            .values()
652            .filter(|node| {
653                matches!(
654                    (&node.resolved_ref.version, &node.latest_version),
655                    (Some(resolved), Some(latest)) if latest > resolved
656                )
657            })
658            .count()
659    };
660
661    Ok(SyncReport {
662        applied: state.applied.applied,
663        pruned: Vec::new(),
664        diagnostics: diag.drain(),
665        dependency_changes,
666        upgrades_available,
667        target_outcomes: state.target_outcomes,
668        dry_run: request.options.dry_run,
669    })
670}
671
672fn declaration_ordered_dep_models(
673    graph: &ResolvedGraph,
674    config: &EffectiveConfig,
675) -> Vec<crate::models::ResolvedDepModels> {
676    // Declaration positions for direct deps in consumer mars.toml.
677    let mut decl_pos: HashMap<SourceName, usize> = HashMap::new();
678    for (idx, name) in config.dependencies.keys().enumerate() {
679        decl_pos.insert(name.clone(), idx);
680    }
681
682    // Propagate declaration position to transitives: a transitive dependency
683    // takes the minimum position among all direct dependencies that reach it.
684    for (idx, sponsor) in config.dependencies.keys().enumerate() {
685        let Some(sponsor_node) = graph.nodes.get(sponsor) else {
686            continue;
687        };
688
689        let mut queue: VecDeque<SourceName> = sponsor_node.deps.iter().cloned().collect();
690        let mut visited: HashSet<SourceName> = HashSet::new();
691
692        while let Some(dep) = queue.pop_front() {
693            if !visited.insert(dep.clone()) {
694                continue;
695            }
696
697            decl_pos
698                .entry(dep.clone())
699                .and_modify(|pos| *pos = (*pos).min(idx))
700                .or_insert(idx);
701
702            if let Some(dep_node) = graph.nodes.get(&dep) {
703                queue.extend(dep_node.deps.iter().cloned());
704            }
705        }
706    }
707
708    // Build Kahn structures using dependency edges:
709    // dep -> dependent (name depends on dep).
710    let mut in_degree: HashMap<SourceName, usize> = HashMap::new();
711    let mut adjacency: HashMap<SourceName, Vec<SourceName>> = HashMap::new();
712
713    for name in graph.nodes.keys() {
714        in_degree.entry(name.clone()).or_insert(0);
715        adjacency.entry(name.clone()).or_default();
716    }
717
718    for (name, node) in &graph.nodes {
719        for dep in &node.deps {
720            if graph.nodes.contains_key(dep) {
721                *in_degree.entry(name.clone()).or_insert(0) += 1;
722                adjacency.entry(dep.clone()).or_default().push(name.clone());
723            }
724        }
725    }
726
727    let mut ready: BinaryHeap<Reverse<(usize, SourceName)>> = BinaryHeap::new();
728    for (name, degree) in &in_degree {
729        if *degree == 0 {
730            let position = decl_pos.get(name).copied().unwrap_or(usize::MAX);
731            ready.push(Reverse((position, name.clone())));
732        }
733    }
734
735    let mut ordered: Vec<SourceName> = Vec::with_capacity(graph.nodes.len());
736    while let Some(Reverse((_, current))) = ready.pop() {
737        ordered.push(current.clone());
738
739        if let Some(dependents) = adjacency.get(&current) {
740            for dependent in dependents {
741                if let Some(degree) = in_degree.get_mut(dependent) {
742                    *degree -= 1;
743                    if *degree == 0 {
744                        let position = decl_pos.get(dependent).copied().unwrap_or(usize::MAX);
745                        ready.push(Reverse((position, dependent.clone())));
746                    }
747                }
748            }
749        }
750    }
751
752    // Graph should already be acyclic from resolver; this keeps behavior
753    // deterministic if that invariant is ever violated.
754    let ordered_names: Vec<SourceName> = if ordered.len() == graph.nodes.len() {
755        ordered
756    } else {
757        graph.order.clone()
758    };
759
760    ordered_names
761        .iter()
762        .filter_map(|name| {
763            let node = graph.nodes.get(name)?;
764            let manifest = node.manifest.as_ref()?;
765            if manifest.models.is_empty() {
766                return None;
767            }
768            Some(crate::models::ResolvedDepModels {
769                source_name: name.to_string(),
770                models: manifest.models.clone(),
771            })
772        })
773        .collect()
774}
775
776fn default_dest_path(kind: ItemKind, name: &str) -> DestPath {
777    match kind {
778        ItemKind::Agent => DestPath::from(PathBuf::from("agents").join(format!("{name}.md"))),
779        ItemKind::Skill => DestPath::from(PathBuf::from("skills").join(name)),
780    }
781}
782
783fn validate_request(request: &SyncRequest) -> Result<(), MarsError> {
784    if request.options.frozen && matches!(request.resolution, ResolutionMode::Maximize { .. }) {
785        return Err(MarsError::InvalidRequest {
786            message:
787                "cannot use --frozen with upgrade (frozen locks versions; upgrade maximizes them)"
788                    .to_string(),
789        });
790    }
791
792    if request.options.frozen && request.mutation.is_some() {
793        return Err(MarsError::InvalidRequest {
794            message:
795                "cannot modify config in --frozen mode (config change would require lock update)"
796                    .to_string(),
797        });
798    }
799
800    Ok(())
801}
802
803fn validate_targets(
804    resolution: &ResolutionMode,
805    effective: &EffectiveConfig,
806) -> Result<(), MarsError> {
807    if let ResolutionMode::Maximize { targets, .. } = resolution {
808        for name in targets {
809            if !effective.dependencies.contains_key(name) {
810                return Err(MarsError::Source {
811                    source_name: name.to_string(),
812                    message: format!("dependency `{name}` not found in mars.toml"),
813                });
814            }
815        }
816    }
817
818    Ok(())
819}
820
821fn to_resolve_options(mode: &ResolutionMode, frozen: bool) -> ResolveOptions {
822    match mode {
823        ResolutionMode::Normal => ResolveOptions {
824            frozen,
825            ..ResolveOptions::default()
826        },
827        ResolutionMode::Maximize { targets, bump } => ResolveOptions {
828            maximize: true,
829            upgrade_targets: targets.clone(),
830            bump_direct_constraints: *bump,
831            frozen,
832        },
833    }
834}
835
836fn planned_bump_entries(
837    config: &Config,
838    graph: &ResolvedGraph,
839    mode: &ResolutionMode,
840) -> Vec<(SourceName, crate::config::DependencyEntry)> {
841    let ResolutionMode::Maximize {
842        targets,
843        bump: true,
844    } = mode
845    else {
846        return Vec::new();
847    };
848
849    config
850        .dependencies
851        .iter()
852        .filter_map(|(name, entry)| {
853            if !targets.is_empty() && !targets.contains(name) {
854                return None;
855            }
856            // Only git dependencies with semver-tagged resolution can be bumped.
857            entry.url.as_ref()?;
858            let node = graph.nodes.get(name)?;
859            let resolved_version = node.resolved_ref.version.as_ref()?;
860            let resolved_tag = node.resolved_ref.version_tag.as_ref()?;
861            if !constraint_needs_bump(entry.version.as_deref(), resolved_version) {
862                return None;
863            }
864            if entry.version.as_deref() == Some(resolved_tag.as_str()) {
865                return None;
866            }
867            let mut bumped = entry.clone();
868            bumped.version = Some(resolved_tag.clone());
869            Some((name.clone(), bumped))
870        })
871        .collect()
872}
873
874fn constraint_needs_bump(current: Option<&str>, resolved: &semver::Version) -> bool {
875    match crate::resolve::parse_version_constraint(current) {
876        crate::resolve::VersionConstraint::Semver(req) => !req.matches(resolved),
877        crate::resolve::VersionConstraint::Latest
878        | crate::resolve::VersionConstraint::RefPin(_) => false,
879    }
880}
881
882fn has_version_changes(changes: &[DependencyUpsertChange]) -> bool {
883    changes
884        .iter()
885        .any(|change| change.old_version != change.new_version)
886}
887
888/// Validate skill references: check that agents' `skills:` frontmatter entries
889/// reference skills that exist in the target state.
890fn validate_skill_refs(
891    install_target: &std::path::Path,
892    target: &target::TargetState,
893) -> Vec<ValidationWarning> {
894    use crate::lock::ItemKind;
895
896    // Collect available skill names
897    let available_skills: HashSet<String> = target
898        .items
899        .values()
900        .filter(|item| item.id.kind == ItemKind::Skill)
901        .map(|item| item.id.name.to_string())
902        .collect();
903
904    // Collect agents with their paths
905    let agents: Vec<(String, PathBuf)> = target
906        .items
907        .values()
908        .filter(|item| item.id.kind == ItemKind::Agent)
909        .map(|item| {
910            let disk_path = install_target.join(&item.dest_path);
911            // If the file exists on disk, use that (may have local edits).
912            // Otherwise, use the source path.
913            let path = if disk_path.exists() {
914                disk_path
915            } else {
916                item.source_path.clone()
917            };
918            (item.id.name.to_string(), path)
919        })
920        .collect();
921
922    crate::validate::check_deps(&agents, &available_skills).unwrap_or_default()
923}
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928    use crate::config::*;
929    use crate::lock::{ItemKind, LockFile};
930    use crate::resolve::{ResolvedGraph, ResolvedNode};
931    use indexmap::IndexMap;
932    use std::fs;
933    use tempfile::TempDir;
934
935    /// Helper to set up a complete sync context with temp dirs.
936    struct TestFixture {
937        project_root: TempDir,
938        managed_root: PathBuf,
939        source_trees: Vec<TempDir>,
940    }
941
942    impl TestFixture {
943        fn new() -> Self {
944            let project_root = TempDir::new().unwrap();
945            let managed_root = project_root.path().join(".agents");
946            // Create .mars/cache directories
947            fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
948            TestFixture {
949                project_root,
950                managed_root,
951                source_trees: Vec::new(),
952            }
953        }
954
955        fn add_source(&mut self, agents: &[(&str, &str)], skills: &[(&str, &str)]) -> usize {
956            let dir = TempDir::new().unwrap();
957            if !agents.is_empty() {
958                let agents_dir = dir.path().join("agents");
959                fs::create_dir_all(&agents_dir).unwrap();
960                for (name, content) in agents {
961                    fs::write(agents_dir.join(name), content).unwrap();
962                }
963            }
964            if !skills.is_empty() {
965                let skills_dir = dir.path().join("skills");
966                fs::create_dir_all(&skills_dir).unwrap();
967                for (name, content) in skills {
968                    let skill_dir = skills_dir.join(name);
969                    fs::create_dir_all(&skill_dir).unwrap();
970                    fs::write(skill_dir.join("SKILL.md"), content).unwrap();
971                }
972            }
973            self.source_trees.push(dir);
974            self.source_trees.len() - 1
975        }
976
977        fn project_root(&self) -> &std::path::Path {
978            self.project_root.path()
979        }
980
981        fn managed_root(&self) -> &std::path::Path {
982            &self.managed_root
983        }
984
985        fn tree_path(&self, idx: usize) -> PathBuf {
986            self.source_trees[idx].path().to_path_buf()
987        }
988    }
989
990    fn make_graph_config(
991        fixture: &TestFixture,
992        sources: Vec<(&str, usize, FilterMode)>,
993    ) -> (ResolvedGraph, EffectiveConfig) {
994        let mut nodes = IndexMap::new();
995        let mut order = Vec::new();
996        let mut config_dependencies = IndexMap::new();
997
998        for (name, tree_idx, filter) in sources {
999            let tree_path = fixture.tree_path(tree_idx);
1000            nodes.insert(
1001                name.into(),
1002                ResolvedNode {
1003                    source_name: name.into(),
1004                    source_id: crate::types::SourceId::Path {
1005                        canonical: tree_path.clone(),
1006                        subpath: None,
1007                    },
1008                    rooted_ref: crate::resolve::RootedSourceRef {
1009                        checkout_root: tree_path.clone(),
1010                        package_root: tree_path.clone(),
1011                    },
1012                    resolved_ref: crate::source::ResolvedRef {
1013                        source_name: name.into(),
1014                        version: None,
1015                        version_tag: None,
1016                        commit: None,
1017                        tree_path: tree_path.clone(),
1018                    },
1019                    latest_version: None,
1020                    manifest: None,
1021                    deps: vec![],
1022                },
1023            );
1024            order.push(name.into());
1025
1026            config_dependencies.insert(
1027                name.into(),
1028                EffectiveDependency {
1029                    name: name.into(),
1030                    id: crate::types::SourceId::Path {
1031                        canonical: tree_path.clone(),
1032                        subpath: None,
1033                    },
1034                    spec: SourceSpec::Path(tree_path),
1035                    subpath: None,
1036                    filter,
1037                    rename: crate::types::RenameMap::new(),
1038                    is_overridden: false,
1039                    original_git: None,
1040                },
1041            );
1042        }
1043
1044        (
1045            ResolvedGraph {
1046                nodes,
1047                order,
1048                id_index: std::collections::HashMap::new(),
1049                filters: std::collections::HashMap::new(),
1050            },
1051            EffectiveConfig {
1052                dependencies: config_dependencies,
1053                settings: Settings::default(),
1054            },
1055        )
1056    }
1057
1058    fn path_dependency_entry(path: &std::path::Path) -> DependencyEntry {
1059        DependencyEntry {
1060            url: None,
1061            path: Some(path.to_path_buf()),
1062            subpath: None,
1063            version: None,
1064            filter: FilterConfig::default(),
1065        }
1066    }
1067
1068    fn git_dependency_entry(url: &str, version: &str, filter: FilterConfig) -> DependencyEntry {
1069        DependencyEntry {
1070            url: Some(url.into()),
1071            path: None,
1072            subpath: None,
1073            version: Some(version.to_string()),
1074            filter,
1075        }
1076    }
1077
1078    fn create_sync_plan(
1079        sync_diff: &diff::SyncDiff,
1080        options: &SyncOptions,
1081        cache_bases_dir: &std::path::Path,
1082    ) -> plan::SyncPlan {
1083        let mut diag = DiagnosticCollector::new();
1084        plan::create(sync_diff, options, cache_bases_dir, &mut diag)
1085    }
1086
1087    fn graph_with_versions(entries: &[(&str, &str, &str)]) -> ResolvedGraph {
1088        let mut nodes = IndexMap::new();
1089        let mut order = Vec::new();
1090        for (name, url, tag) in entries {
1091            let version = semver::Version::parse(tag.trim_start_matches('v')).unwrap();
1092            nodes.insert(
1093                (*name).into(),
1094                ResolvedNode {
1095                    source_name: (*name).into(),
1096                    source_id: crate::types::SourceId::git(crate::types::SourceUrl::from(*url)),
1097                    rooted_ref: crate::resolve::RootedSourceRef {
1098                        checkout_root: PathBuf::from(format!("/tmp/{name}")),
1099                        package_root: PathBuf::from(format!("/tmp/{name}")),
1100                    },
1101                    resolved_ref: crate::source::ResolvedRef {
1102                        source_name: (*name).into(),
1103                        version: Some(version),
1104                        version_tag: Some((*tag).to_string()),
1105                        commit: Some("abc123".into()),
1106                        tree_path: PathBuf::from(format!("/tmp/{name}")),
1107                    },
1108                    latest_version: None,
1109                    manifest: None,
1110                    deps: vec![],
1111                },
1112            );
1113            order.push((*name).into());
1114        }
1115
1116        ResolvedGraph {
1117            nodes,
1118            order,
1119            id_index: std::collections::HashMap::new(),
1120            filters: std::collections::HashMap::new(),
1121        }
1122    }
1123
1124    fn model_alias(model: &str) -> crate::models::ModelAlias {
1125        crate::models::ModelAlias {
1126            harness: None,
1127            description: None,
1128            spec: crate::models::ModelSpec::Pinned {
1129                model: model.to_string(),
1130                provider: None,
1131            },
1132        }
1133    }
1134
1135    fn manifest_with_models(name: &str) -> Manifest {
1136        let mut models = IndexMap::new();
1137        models.insert(
1138            format!("{name}-alias"),
1139            model_alias(&format!("{name}-model")),
1140        );
1141        Manifest {
1142            package: PackageInfo {
1143                name: name.to_string(),
1144                version: "1.0.0".to_string(),
1145                description: None,
1146            },
1147            dependencies: IndexMap::new(),
1148            models,
1149        }
1150    }
1151
1152    fn resolved_node(name: &str, deps: &[&str], with_models: bool) -> ResolvedNode {
1153        let canonical = PathBuf::from(format!("/tmp/{name}"));
1154        ResolvedNode {
1155            source_name: name.into(),
1156            source_id: crate::types::SourceId::Path {
1157                canonical: canonical.clone(),
1158                subpath: None,
1159            },
1160            rooted_ref: crate::resolve::RootedSourceRef {
1161                checkout_root: canonical.clone(),
1162                package_root: canonical.clone(),
1163            },
1164            resolved_ref: crate::source::ResolvedRef {
1165                source_name: name.into(),
1166                version: None,
1167                version_tag: None,
1168                commit: None,
1169                tree_path: canonical,
1170            },
1171            latest_version: None,
1172            manifest: with_models.then(|| manifest_with_models(name)),
1173            deps: deps.iter().map(|dep| (*dep).into()).collect(),
1174        }
1175    }
1176
1177    fn effective_config_with_decl_order(names: &[&str]) -> EffectiveConfig {
1178        let mut dependencies = IndexMap::new();
1179        for name in names {
1180            let canonical = PathBuf::from(format!("/tmp/dep-{name}"));
1181            dependencies.insert(
1182                (*name).into(),
1183                EffectiveDependency {
1184                    name: (*name).into(),
1185                    id: crate::types::SourceId::Path {
1186                        canonical: canonical.clone(),
1187                        subpath: None,
1188                    },
1189                    spec: SourceSpec::Path(canonical),
1190                    subpath: None,
1191                    filter: FilterMode::All,
1192                    rename: crate::types::RenameMap::new(),
1193                    is_overridden: false,
1194                    original_git: None,
1195                },
1196            );
1197        }
1198        EffectiveConfig {
1199            dependencies,
1200            settings: Settings::default(),
1201        }
1202    }
1203
1204    fn dep_model_names(models: &[crate::models::ResolvedDepModels]) -> Vec<String> {
1205        models.iter().map(|m| m.source_name.clone()).collect()
1206    }
1207
1208    #[test]
1209    fn declaration_ordered_dep_models_sibling_order() {
1210        let mut nodes = IndexMap::new();
1211        nodes.insert("a".into(), resolved_node("a", &[], true));
1212        nodes.insert("b".into(), resolved_node("b", &[], true));
1213
1214        let graph = ResolvedGraph {
1215            nodes,
1216            order: vec!["a".into(), "b".into()],
1217            id_index: std::collections::HashMap::new(),
1218            filters: std::collections::HashMap::new(),
1219        };
1220        let config = effective_config_with_decl_order(&["a", "b"]);
1221
1222        let dep_models = declaration_ordered_dep_models(&graph, &config);
1223        assert_eq!(dep_model_names(&dep_models), vec!["a", "b"]);
1224    }
1225
1226    #[test]
1227    fn declaration_ordered_dep_models_diamond_uses_minimum_sponsor_position() {
1228        let mut nodes = IndexMap::new();
1229        nodes.insert("a".into(), resolved_node("a", &["d"], true));
1230        nodes.insert("b".into(), resolved_node("b", &["d"], true));
1231        nodes.insert("d".into(), resolved_node("d", &[], true));
1232
1233        let graph = ResolvedGraph {
1234            nodes,
1235            order: vec!["d".into(), "a".into(), "b".into()],
1236            id_index: std::collections::HashMap::new(),
1237            filters: std::collections::HashMap::new(),
1238        };
1239        let config = effective_config_with_decl_order(&["a", "b"]);
1240
1241        let dep_models = declaration_ordered_dep_models(&graph, &config);
1242        assert_eq!(dep_model_names(&dep_models), vec!["d", "a", "b"]);
1243    }
1244
1245    #[test]
1246    fn declaration_ordered_dep_models_transitives_follow_sponsor_declaration_order() {
1247        let mut nodes = IndexMap::new();
1248        nodes.insert("a".into(), resolved_node("a", &["d"], false));
1249        nodes.insert("b".into(), resolved_node("b", &["e"], false));
1250        nodes.insert("d".into(), resolved_node("d", &[], true));
1251        nodes.insert("e".into(), resolved_node("e", &[], true));
1252
1253        let graph = ResolvedGraph {
1254            nodes,
1255            order: vec!["d".into(), "e".into(), "a".into(), "b".into()],
1256            id_index: std::collections::HashMap::new(),
1257            filters: std::collections::HashMap::new(),
1258        };
1259        let config = effective_config_with_decl_order(&["a", "b"]);
1260
1261        let dep_models = declaration_ordered_dep_models(&graph, &config);
1262        assert_eq!(dep_model_names(&dep_models), vec!["d", "e"]);
1263    }
1264
1265    #[test]
1266    fn declaration_ordered_dep_models_keeps_deps_before_dependents() {
1267        let mut nodes = IndexMap::new();
1268        nodes.insert("a".into(), resolved_node("a", &["d"], true));
1269        nodes.insert("d".into(), resolved_node("d", &[], true));
1270
1271        let graph = ResolvedGraph {
1272            nodes,
1273            order: vec!["d".into(), "a".into()],
1274            id_index: std::collections::HashMap::new(),
1275            filters: std::collections::HashMap::new(),
1276        };
1277        // D is declared after A, but topological ordering must still emit D first.
1278        let config = effective_config_with_decl_order(&["a", "d"]);
1279
1280        let dep_models = declaration_ordered_dep_models(&graph, &config);
1281        assert_eq!(dep_model_names(&dep_models), vec!["d", "a"]);
1282    }
1283
1284    #[test]
1285    fn declaration_ordered_dep_models_is_deterministic() {
1286        let mut nodes = IndexMap::new();
1287        nodes.insert("a".into(), resolved_node("a", &["d"], true));
1288        nodes.insert("b".into(), resolved_node("b", &["e"], true));
1289        nodes.insert("d".into(), resolved_node("d", &[], true));
1290        nodes.insert("e".into(), resolved_node("e", &[], true));
1291
1292        let graph = ResolvedGraph {
1293            nodes,
1294            order: vec!["d".into(), "e".into(), "a".into(), "b".into()],
1295            id_index: std::collections::HashMap::new(),
1296            filters: std::collections::HashMap::new(),
1297        };
1298        let config = effective_config_with_decl_order(&["a", "b"]);
1299
1300        let first = dep_model_names(&declaration_ordered_dep_models(&graph, &config));
1301        for _ in 0..10 {
1302            let current = dep_model_names(&declaration_ordered_dep_models(&graph, &config));
1303            assert_eq!(current, first);
1304        }
1305    }
1306
1307    #[test]
1308    fn declaration_ordered_dep_models_is_used_by_resolve_graph_and_finalize() {
1309        let source = include_str!("mod.rs");
1310        assert!(source.contains("declaration_ordered_dep_models(&graph, &loaded.effective)"));
1311        assert!(source.contains("&state.applied.planned.targeted.resolved.loaded.effective"));
1312    }
1313
1314    #[test]
1315    fn validate_request_rejects_frozen_with_maximize() {
1316        let request = SyncRequest {
1317            resolution: ResolutionMode::Maximize {
1318                targets: HashSet::new(),
1319                bump: false,
1320            },
1321            mutation: None,
1322            options: SyncOptions {
1323                force: false,
1324                dry_run: false,
1325                frozen: true,
1326                no_refresh_models: false,
1327            },
1328        };
1329
1330        let err = validate_request(&request).unwrap_err();
1331        assert!(matches!(err, MarsError::InvalidRequest { .. }));
1332        assert!(err.to_string().contains("--frozen"));
1333    }
1334
1335    #[test]
1336    fn validate_request_rejects_frozen_with_mutation() {
1337        let request = SyncRequest {
1338            resolution: ResolutionMode::Normal,
1339            mutation: Some(ConfigMutation::RemoveDependency {
1340                name: "base".into(),
1341            }),
1342            options: SyncOptions {
1343                force: false,
1344                dry_run: false,
1345                frozen: true,
1346                no_refresh_models: false,
1347            },
1348        };
1349
1350        let err = validate_request(&request).unwrap_err();
1351        assert!(matches!(err, MarsError::InvalidRequest { .. }));
1352        assert!(err.to_string().contains("cannot modify config"));
1353    }
1354
1355    #[test]
1356    fn planned_bump_entries_bump_all_outdated_pins() {
1357        let mut config = Config::default();
1358        config.dependencies.insert(
1359            "base".into(),
1360            git_dependency_entry(
1361                "https://example.com/base.git",
1362                "v1.0.0",
1363                FilterConfig::default(),
1364            ),
1365        );
1366        config.dependencies.insert(
1367            "tools".into(),
1368            git_dependency_entry(
1369                "https://example.com/tools.git",
1370                "v2.0.0",
1371                FilterConfig::default(),
1372            ),
1373        );
1374        config.dependencies.insert(
1375            "floating".into(),
1376            DependencyEntry {
1377                url: Some("https://example.com/floating.git".into()),
1378                path: None,
1379                subpath: None,
1380                version: None,
1381                filter: FilterConfig::default(),
1382            },
1383        );
1384
1385        let graph = graph_with_versions(&[
1386            ("base", "https://example.com/base.git", "v1.2.0"),
1387            ("tools", "https://example.com/tools.git", "v2.0.0"),
1388            ("floating", "https://example.com/floating.git", "v3.0.0"),
1389        ]);
1390
1391        let mode = ResolutionMode::Maximize {
1392            targets: HashSet::new(),
1393            bump: true,
1394        };
1395        let entries = planned_bump_entries(&config, &graph, &mode);
1396        assert_eq!(entries.len(), 1);
1397        assert_eq!(entries[0].0, SourceName::from("base"));
1398        assert_eq!(entries[0].1.version.as_deref(), Some("v1.2.0"));
1399    }
1400
1401    #[test]
1402    fn planned_bump_entries_bump_specific_targets_only() {
1403        let mut config = Config::default();
1404        config.dependencies.insert(
1405            "base".into(),
1406            git_dependency_entry(
1407                "https://example.com/base.git",
1408                "v1.0.0",
1409                FilterConfig::default(),
1410            ),
1411        );
1412        config.dependencies.insert(
1413            "tools".into(),
1414            git_dependency_entry(
1415                "https://example.com/tools.git",
1416                "v1.0.0",
1417                FilterConfig::default(),
1418            ),
1419        );
1420
1421        let graph = graph_with_versions(&[
1422            ("base", "https://example.com/base.git", "v2.0.0"),
1423            ("tools", "https://example.com/tools.git", "v2.0.0"),
1424        ]);
1425
1426        let mode = ResolutionMode::Maximize {
1427            targets: HashSet::from([SourceName::from("tools")]),
1428            bump: true,
1429        };
1430        let entries = planned_bump_entries(&config, &graph, &mode);
1431        assert_eq!(entries.len(), 1);
1432        assert_eq!(entries[0].0, SourceName::from("tools"));
1433        assert_eq!(entries[0].1.version.as_deref(), Some("v2.0.0"));
1434    }
1435
1436    #[test]
1437    fn planned_bump_entries_noop_when_already_latest() {
1438        let mut config = Config::default();
1439        config.dependencies.insert(
1440            "base".into(),
1441            git_dependency_entry(
1442                "https://example.com/base.git",
1443                "v1.2.0",
1444                FilterConfig::default(),
1445            ),
1446        );
1447
1448        let graph = graph_with_versions(&[("base", "https://example.com/base.git", "v1.2.0")]);
1449
1450        let mode = ResolutionMode::Maximize {
1451            targets: HashSet::new(),
1452            bump: true,
1453        };
1454        let entries = planned_bump_entries(&config, &graph, &mode);
1455        assert!(entries.is_empty());
1456    }
1457
1458    #[test]
1459    fn planned_bump_entries_preserve_filters_and_renames() {
1460        let mut rename = crate::types::RenameMap::new();
1461        rename.insert("coder".into(), "coder-v2".into());
1462
1463        let mut config = Config::default();
1464        config.dependencies.insert(
1465            "base".into(),
1466            git_dependency_entry(
1467                "https://example.com/base.git",
1468                "v1.0.0",
1469                FilterConfig {
1470                    agents: Some(vec!["coder".into()]),
1471                    rename: Some(rename.clone()),
1472                    ..FilterConfig::default()
1473                },
1474            ),
1475        );
1476
1477        let graph = graph_with_versions(&[("base", "https://example.com/base.git", "v2.0.0")]);
1478        let mode = ResolutionMode::Maximize {
1479            targets: HashSet::new(),
1480            bump: true,
1481        };
1482        let entries = planned_bump_entries(&config, &graph, &mode);
1483        let mut mutated = config.clone();
1484        let changes =
1485            mutation::apply_mutation(&mut mutated, &ConfigMutation::BatchUpsert(entries)).unwrap();
1486
1487        assert_eq!(changes.len(), 1);
1488        assert_eq!(changes[0].old_version.as_deref(), Some("v1.0.0"));
1489        assert_eq!(changes[0].new_version.as_deref(), Some("v2.0.0"));
1490
1491        let dep = &mutated.dependencies["base"];
1492        assert_eq!(dep.version.as_deref(), Some("v2.0.0"));
1493        assert_eq!(dep.filter.agents.as_deref(), Some(&["coder".into()][..]));
1494        assert_eq!(dep.filter.rename.as_ref(), Some(&rename));
1495    }
1496
1497    #[test]
1498    fn execute_auto_inits_config_for_mutation() {
1499        let project_root = TempDir::new().unwrap();
1500        let managed_root = project_root.path().join(".agents");
1501        fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
1502        let source = TempDir::new().unwrap();
1503        fs::create_dir_all(source.path().join("agents")).unwrap();
1504        fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
1505
1506        let request = SyncRequest {
1507            resolution: ResolutionMode::Normal,
1508            mutation: Some(ConfigMutation::UpsertDependency {
1509                name: "base".into(),
1510                entry: path_dependency_entry(source.path()),
1511            }),
1512            options: SyncOptions::default(),
1513        };
1514
1515        let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
1516        let report = execute(&ctx, &request).unwrap();
1517        assert!(!report.applied.outcomes.is_empty());
1518        assert!(project_root.path().join("mars.toml").exists());
1519
1520        let saved = crate::config::load(project_root.path()).unwrap();
1521        assert!(saved.dependencies.contains_key("base"));
1522    }
1523
1524    #[test]
1525    fn execute_dry_run_with_mutation_does_not_write_config() {
1526        let project_root = TempDir::new().unwrap();
1527        let managed_root = project_root.path().join(".agents");
1528        fs::create_dir_all(project_root.path().join(".mars/cache/bases")).unwrap();
1529        crate::config::save(
1530            project_root.path(),
1531            &Config {
1532                dependencies: IndexMap::new(),
1533                settings: Settings::default(),
1534                ..Config::default()
1535            },
1536        )
1537        .unwrap();
1538
1539        let source = TempDir::new().unwrap();
1540        fs::create_dir_all(source.path().join("agents")).unwrap();
1541        fs::write(source.path().join("agents/coder.md"), "# Coder").unwrap();
1542
1543        let request = SyncRequest {
1544            resolution: ResolutionMode::Normal,
1545            mutation: Some(ConfigMutation::UpsertDependency {
1546                name: "base".into(),
1547                entry: path_dependency_entry(source.path()),
1548            }),
1549            options: SyncOptions {
1550                force: false,
1551                dry_run: true,
1552                frozen: false,
1553                no_refresh_models: false,
1554            },
1555        };
1556
1557        let ctx = MarsContext::for_test(project_root.path().to_path_buf(), managed_root.clone());
1558        let report = execute(&ctx, &request).unwrap();
1559        assert!(!report.applied.outcomes.is_empty());
1560
1561        let saved = crate::config::load(project_root.path()).unwrap();
1562        assert!(!saved.dependencies.contains_key("base"));
1563        assert!(!managed_root.join("agents/coder.md").exists());
1564        assert!(!project_root.path().join("mars.lock").exists());
1565    }
1566
1567    // === Integration tests for the pipeline stages ===
1568
1569    #[test]
1570    fn full_pipeline_fresh_sync() {
1571        let mut fixture = TestFixture::new();
1572        let src_idx = fixture.add_source(
1573            &[("coder.md", "# Coder agent")],
1574            &[("planning", "# Planning skill")],
1575        );
1576
1577        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1578
1579        // Build target
1580        let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1581        assert!(renames.is_empty());
1582        assert_eq!(target.items.len(), 2);
1583
1584        // Compute diff against empty lock
1585        let lock = LockFile::empty();
1586        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1587
1588        // All items should be Add
1589        assert_eq!(sync_diff.items.len(), 2);
1590        for entry in &sync_diff.items {
1591            assert!(matches!(entry, diff::DiffEntry::Add { .. }));
1592        }
1593
1594        // Create plan
1595        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1596        let options = SyncOptions {
1597            force: false,
1598            dry_run: false,
1599            frozen: false,
1600            no_refresh_models: false,
1601        };
1602        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1603        assert_eq!(sync_plan.actions.len(), 2);
1604        for action in &sync_plan.actions {
1605            assert!(matches!(action, plan::PlannedAction::Install { .. }));
1606        }
1607
1608        // Execute plan
1609        let result =
1610            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1611        assert_eq!(result.outcomes.len(), 2);
1612
1613        // Verify files were created
1614        assert!(fixture.managed_root().join("agents/coder.md").exists());
1615        assert!(
1616            fixture
1617                .managed_root()
1618                .join("skills/planning/SKILL.md")
1619                .exists()
1620        );
1621
1622        // Build lock
1623        let new_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1624        assert_eq!(new_lock.items.len(), 2);
1625        assert!(new_lock.items.contains_key("agents/coder.md"));
1626        assert!(new_lock.items.contains_key("skills/planning"));
1627    }
1628
1629    #[test]
1630    fn re_sync_no_changes() {
1631        let mut fixture = TestFixture::new();
1632        let content = "# Coder agent";
1633        let src_idx = fixture.add_source(&[("coder.md", content)], &[]);
1634
1635        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1636
1637        // First sync
1638        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1639        let lock = LockFile::empty();
1640        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1641        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1642        let options = SyncOptions {
1643            force: false,
1644            dry_run: false,
1645            frozen: false,
1646            no_refresh_models: false,
1647        };
1648        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1649        let result =
1650            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1651        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1652
1653        // Second sync with same content
1654        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1655        let sync_diff2 =
1656            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1657
1658        // All items should be Unchanged
1659        for entry in &sync_diff2.items {
1660            assert!(
1661                matches!(entry, diff::DiffEntry::Unchanged { .. }),
1662                "expected Unchanged, got {entry:?}"
1663            );
1664        }
1665
1666        let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1667        for action in &sync_plan2.actions {
1668            assert!(matches!(action, plan::PlannedAction::Skip { .. }));
1669        }
1670    }
1671
1672    #[test]
1673    fn source_update_detects_changes() {
1674        let mut fixture = TestFixture::new();
1675        let src_idx = fixture.add_source(&[("coder.md", "# Version 1")], &[]);
1676
1677        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1678
1679        // First sync
1680        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1681        let lock = LockFile::empty();
1682        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1683        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1684        let options = SyncOptions {
1685            force: false,
1686            dry_run: false,
1687            frozen: false,
1688            no_refresh_models: false,
1689        };
1690        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1691        let result =
1692            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1693        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1694
1695        // Update source content
1696        let agents_dir = fixture.tree_path(src_idx).join("agents");
1697        fs::write(agents_dir.join("coder.md"), "# Version 2").unwrap();
1698
1699        // Rebuild target with updated content
1700        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1701        let sync_diff2 =
1702            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1703
1704        // Should detect an Update
1705        assert_eq!(sync_diff2.items.len(), 1);
1706        assert!(matches!(
1707            &sync_diff2.items[0],
1708            diff::DiffEntry::Update { .. }
1709        ));
1710    }
1711
1712    #[test]
1713    fn local_modification_preserved() {
1714        let mut fixture = TestFixture::new();
1715        let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1716
1717        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1718
1719        // First sync
1720        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1721        let lock = LockFile::empty();
1722        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1723        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1724        let options = SyncOptions {
1725            force: false,
1726            dry_run: false,
1727            frozen: false,
1728            no_refresh_models: false,
1729        };
1730        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1731        let result =
1732            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1733        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1734
1735        // Locally modify the installed file
1736        fs::write(
1737            fixture.managed_root().join("agents/coder.md"),
1738            "# Locally modified",
1739        )
1740        .unwrap();
1741
1742        // Re-sync (source unchanged)
1743        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1744        let sync_diff2 =
1745            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1746
1747        // Should detect LocalModified
1748        assert_eq!(sync_diff2.items.len(), 1);
1749        assert!(matches!(
1750            &sync_diff2.items[0],
1751            diff::DiffEntry::LocalModified { .. }
1752        ));
1753
1754        // Plan should KeepLocal
1755        let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1756        assert!(matches!(
1757            &sync_plan2.actions[0],
1758            plan::PlannedAction::KeepLocal { .. }
1759        ));
1760    }
1761
1762    #[test]
1763    fn force_overwrites_local_modifications() {
1764        let mut fixture = TestFixture::new();
1765        let src_idx = fixture.add_source(&[("coder.md", "# Original")], &[]);
1766
1767        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1768
1769        // First sync
1770        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1771        let lock = LockFile::empty();
1772        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1773        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1774        let options = SyncOptions {
1775            force: false,
1776            dry_run: false,
1777            frozen: false,
1778            no_refresh_models: false,
1779        };
1780        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1781        let result =
1782            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1783        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1784
1785        // Locally modify the installed file
1786        fs::write(
1787            fixture.managed_root().join("agents/coder.md"),
1788            "# Locally modified",
1789        )
1790        .unwrap();
1791
1792        // Update source too (triggers conflict)
1793        let agents_dir = fixture.tree_path(src_idx).join("agents");
1794        fs::write(agents_dir.join("coder.md"), "# Upstream update").unwrap();
1795
1796        // Re-sync with --force
1797        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1798        let sync_diff2 =
1799            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1800
1801        let force_options = SyncOptions {
1802            force: true,
1803            dry_run: false,
1804            frozen: false,
1805            no_refresh_models: false,
1806        };
1807        let sync_plan2 = create_sync_plan(&sync_diff2, &force_options, &cache_dir);
1808        assert!(matches!(
1809            &sync_plan2.actions[0],
1810            plan::PlannedAction::Overwrite { .. }
1811        ));
1812
1813        let result2 = apply::execute(
1814            fixture.managed_root(),
1815            &sync_plan2,
1816            &force_options,
1817            &cache_dir,
1818        )
1819        .unwrap();
1820        assert!(matches!(
1821            result2.outcomes[0].action,
1822            apply::ActionTaken::Updated
1823        ));
1824
1825        // File should have upstream content
1826        let content = fs::read_to_string(fixture.managed_root().join("agents/coder.md")).unwrap();
1827        assert_eq!(content, "# Upstream update");
1828    }
1829
1830    #[test]
1831    fn orphan_removed_when_source_drops_item() {
1832        let mut fixture = TestFixture::new();
1833        let src_idx = fixture.add_source(
1834            &[("coder.md", "# Coder"), ("reviewer.md", "# Reviewer")],
1835            &[],
1836        );
1837
1838        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1839
1840        // First sync — install both
1841        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1842        let lock = LockFile::empty();
1843        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1844        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1845        let options = SyncOptions {
1846            force: false,
1847            dry_run: false,
1848            frozen: false,
1849            no_refresh_models: false,
1850        };
1851        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1852        let result =
1853            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1854        let first_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1855
1856        assert!(fixture.managed_root().join("agents/coder.md").exists());
1857        assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1858
1859        // Remove reviewer from source
1860        fs::remove_file(fixture.tree_path(src_idx).join("agents/reviewer.md")).unwrap();
1861
1862        // Re-sync
1863        let (target2, _) = target::build_with_collisions(&graph, &config).unwrap();
1864        let sync_diff2 =
1865            diff::compute(fixture.managed_root(), &first_lock, &target2, false).unwrap();
1866
1867        // Should have one Unchanged and one Orphan
1868        let orphan_count = sync_diff2
1869            .items
1870            .iter()
1871            .filter(|e| matches!(e, diff::DiffEntry::Orphan { .. }))
1872            .count();
1873        assert_eq!(orphan_count, 1);
1874
1875        let sync_plan2 = create_sync_plan(&sync_diff2, &options, &cache_dir);
1876        let result2 =
1877            apply::execute(fixture.managed_root(), &sync_plan2, &options, &cache_dir).unwrap();
1878
1879        // Reviewer should be removed
1880        assert!(!fixture.managed_root().join("agents/reviewer.md").exists());
1881        // Coder should still be there
1882        assert!(fixture.managed_root().join("agents/coder.md").exists());
1883
1884        // Check remove outcome
1885        let removed = result2
1886            .outcomes
1887            .iter()
1888            .any(|o| matches!(o.action, apply::ActionTaken::Removed));
1889        assert!(removed);
1890    }
1891
1892    #[test]
1893    fn dry_run_produces_plan_without_changes() {
1894        let mut fixture = TestFixture::new();
1895        let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1896
1897        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1898
1899        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1900        let lock = LockFile::empty();
1901        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1902
1903        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1904        let dry_options = SyncOptions {
1905            force: false,
1906            dry_run: true,
1907            frozen: false,
1908            no_refresh_models: false,
1909        };
1910
1911        let sync_plan = create_sync_plan(&sync_diff, &dry_options, &cache_dir);
1912        assert!(!sync_plan.actions.is_empty());
1913
1914        // Execute in dry-run mode
1915        let result =
1916            apply::execute(fixture.managed_root(), &sync_plan, &dry_options, &cache_dir).unwrap();
1917        assert!(!result.outcomes.is_empty());
1918
1919        // No files should have been created
1920        assert!(!fixture.managed_root().join("agents/coder.md").exists());
1921    }
1922
1923    #[test]
1924    fn lock_written_after_apply() {
1925        let mut fixture = TestFixture::new();
1926        let src_idx = fixture.add_source(&[("coder.md", "# Coder")], &[]);
1927
1928        let (graph, config) = make_graph_config(&fixture, vec![("base", src_idx, FilterMode::All)]);
1929
1930        // Full pipeline minus actual sync() (which needs real config files)
1931        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
1932        let lock = LockFile::empty();
1933        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1934        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1935        let options = SyncOptions {
1936            force: false,
1937            dry_run: false,
1938            frozen: false,
1939            no_refresh_models: false,
1940        };
1941        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1942        let result =
1943            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1944
1945        let new_lock = crate::lock::build(&graph, &result, &lock).unwrap();
1946        crate::lock::write(fixture.project_root(), &new_lock).unwrap();
1947
1948        // Verify lock file exists and is valid
1949        let reloaded = crate::lock::load(fixture.project_root()).unwrap();
1950        assert_eq!(reloaded.items.len(), 1);
1951        assert!(reloaded.items.contains_key("agents/coder.md"));
1952
1953        let item = &reloaded.items["agents/coder.md"];
1954        assert_eq!(item.kind, ItemKind::Agent);
1955        assert!(!item.source_checksum.is_empty());
1956        assert!(!item.installed_checksum.is_empty());
1957    }
1958
1959    #[test]
1960    fn two_sources_no_collision() {
1961        let mut fixture = TestFixture::new();
1962        let src_a = fixture.add_source(&[("coder.md", "# Coder from A")], &[]);
1963        let src_b = fixture.add_source(&[("reviewer.md", "# Reviewer from B")], &[]);
1964
1965        let (graph, config) = make_graph_config(
1966            &fixture,
1967            vec![
1968                ("source-a", src_a, FilterMode::All),
1969                ("source-b", src_b, FilterMode::All),
1970            ],
1971        );
1972
1973        let (target, renames) = target::build_with_collisions(&graph, &config).unwrap();
1974        assert!(renames.is_empty());
1975        assert_eq!(target.items.len(), 2);
1976
1977        let lock = LockFile::empty();
1978        let sync_diff = diff::compute(fixture.managed_root(), &lock, &target, false).unwrap();
1979        let cache_dir = fixture.project_root().join(".mars/cache/bases");
1980        let options = SyncOptions {
1981            force: false,
1982            dry_run: false,
1983            frozen: false,
1984            no_refresh_models: false,
1985        };
1986        let sync_plan = create_sync_plan(&sync_diff, &options, &cache_dir);
1987        let result =
1988            apply::execute(fixture.managed_root(), &sync_plan, &options, &cache_dir).unwrap();
1989
1990        assert!(fixture.managed_root().join("agents/coder.md").exists());
1991        assert!(fixture.managed_root().join("agents/reviewer.md").exists());
1992        assert_eq!(result.outcomes.len(), 2);
1993    }
1994
1995    // === Tests for OnlySkills / OnlyAgents filter in pipeline ===
1996
1997    #[test]
1998    fn pipeline_only_skills_filter() {
1999        let mut fixture = TestFixture::new();
2000        let src_idx = fixture.add_source(
2001            &[("coder.md", "# Coder agent")],
2002            &[("planning", "# Planning skill")],
2003        );
2004
2005        let (graph, config) =
2006            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlySkills)]);
2007
2008        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2009        // Should only have the skill, not the agent
2010        assert_eq!(target.items.len(), 1);
2011        assert!(target.items.contains_key("skills/planning"));
2012    }
2013
2014    #[test]
2015    fn pipeline_only_agents_filter() {
2016        let mut fixture = TestFixture::new();
2017        // Agent with a skill dependency in frontmatter
2018        let agent_content = "---\nskills:\n  - planning\n---\n# Coder agent";
2019        let src_idx = fixture.add_source(
2020            &[("coder.md", agent_content)],
2021            &[
2022                ("planning", "# Planning skill"),
2023                ("standalone", "# Standalone skill"),
2024            ],
2025        );
2026
2027        let (graph, config) =
2028            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
2029
2030        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2031        // Should have the agent + its transitive skill dep, but NOT standalone
2032        assert_eq!(target.items.len(), 2);
2033        assert!(target.items.contains_key("agents/coder.md"));
2034        assert!(target.items.contains_key("skills/planning"));
2035        assert!(!target.items.contains_key("skills/standalone"));
2036    }
2037
2038    #[test]
2039    fn pipeline_only_agents_no_agents_source() {
2040        let mut fixture = TestFixture::new();
2041        let src_idx = fixture.add_source(&[], &[("planning", "# Planning skill")]);
2042
2043        let (graph, config) =
2044            make_graph_config(&fixture, vec![("base", src_idx, FilterMode::OnlyAgents)]);
2045
2046        let (target, _) = target::build_with_collisions(&graph, &config).unwrap();
2047        // No agents means nothing gets installed
2048        assert_eq!(target.items.len(), 0);
2049    }
2050}