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