Skip to main content

mars_agents/resolve/
mod.rs

1//! Dependency resolution with semver constraints.
2//!
3//! Algorithm:
4//! 1. Resolve package refs/versions (lock-preferred latest-compatible for git sources)
5//! 2. Resolve package manifests bottom-up (deps before item seeds)
6//! 3. Traverse items with DFS from seeded requests and frontmatter skill deps
7//! 4. Emit deterministic alphabetical package order
8//!
9//! Uses `semver` crate for all version parsing. No custom version logic.
10
11pub mod compat;
12mod constraint;
13mod context;
14mod filter;
15mod package;
16mod path;
17mod requires;
18mod skill;
19mod types;
20mod version;
21
22use std::collections::HashMap;
23use std::path::Path;
24
25#[cfg(test)]
26use indexmap::IndexMap;
27
28pub use constraint::parse_version_constraint;
29pub use context::ResolverContext;
30pub(crate) use requires::{
31    check_consumer_package_requirements, validate_package_requirement_syntax,
32};
33pub use types::*;
34
35pub(crate) use package::{PackageResolutionState, PendingSource, RegisteredPackage};
36#[cfg(test)]
37pub(crate) use path::apply_subpath;
38
39use crate::config::{EffectiveConfig, Manifest, SourceSpec};
40use crate::diagnostic::DiagnosticCollector;
41use crate::error::{MarsError, ResolutionError};
42use crate::lock::LockFile;
43use crate::source::{AvailableVersion, ResolvedRef};
44use crate::types::SourceName;
45use crate::types::SourceUrl;
46use filter::is_item_excluded;
47use package::resolve_package_bottom_up;
48use skill::{parse_pending_item_skill_deps, resolve_skill_ref};
49use version::validate_all_constraints;
50
51type EngineExclusions =
52    HashMap<(SourceName, semver::Version), Vec<requires::EngineRequirementFailure>>;
53
54#[derive(Debug)]
55enum VersionAction {
56    Process,
57    Skip,
58}
59
60fn apply_item_version_policy(
61    pending_item: &PendingItem,
62    check: VersionCheckResult,
63    diag: &mut DiagnosticCollector,
64) -> Result<VersionAction, ResolutionError> {
65    match check {
66        VersionCheckResult::NotSeen => Ok(VersionAction::Process),
67        VersionCheckResult::SameVersion => Ok(VersionAction::Skip),
68        VersionCheckResult::PotentiallyConflicting {
69            existing,
70            requested,
71        } => {
72            diag.warn(
73                "potential-version-drift",
74                format!(
75                    "potential version drift: item '{}' from '{}' requested as {} but already seen as {}",
76                    pending_item.item, pending_item.package, requested, existing
77                ),
78            );
79            Ok(VersionAction::Skip)
80        }
81        VersionCheckResult::DifferentVersion {
82            existing,
83            requested,
84        } => {
85            if pending_item.is_local {
86                return Ok(VersionAction::Skip);
87            }
88            Err(ResolutionError::ItemVersionConflict {
89                item: pending_item.item.to_string(),
90                package: pending_item.package.to_string(),
91                existing: existing.to_string(),
92                requested: requested.to_string(),
93                chain: pending_item.required_by.clone(),
94            })
95        }
96    }
97}
98
99fn same_resolved_ref(a: &ResolvedRef, b: &ResolvedRef) -> bool {
100    a.version == b.version
101        && a.version_tag == b.version_tag
102        && a.commit == b.commit
103        && a.tree_path == b.tree_path
104}
105
106fn describe_resolved_ref(resolved: &ResolvedRef) -> String {
107    let version = resolved
108        .version_tag
109        .clone()
110        .or_else(|| resolved.version.as_ref().map(ToString::to_string))
111        .unwrap_or_else(|| "no-version".to_string());
112    let commit = resolved.commit.as_deref().unwrap_or("no-commit");
113    format!("{version}@{commit}")
114}
115
116/// Lists semver-tagged versions available for a git source.
117pub trait VersionLister {
118    fn list_versions(&self, url: &SourceUrl) -> Result<Vec<AvailableVersion>, MarsError>;
119}
120
121/// Fetches concrete source trees after the resolver has picked a strategy.
122pub trait SourceFetcher {
123    /// Fetch a git source at a specific version tag.
124    fn fetch_git_version(
125        &self,
126        url: &SourceUrl,
127        version: &AvailableVersion,
128        source_name: &str,
129        preferred_commit: Option<&str>,
130        diag: &mut DiagnosticCollector,
131    ) -> Result<ResolvedRef, MarsError>;
132
133    /// Fetch a git source at a branch/commit ref (non-semver path).
134    fn fetch_git_ref(
135        &self,
136        url: &SourceUrl,
137        ref_name: &str,
138        source_name: &str,
139        preferred_commit: Option<&str>,
140        diag: &mut DiagnosticCollector,
141    ) -> Result<ResolvedRef, MarsError>;
142
143    /// Fetch a git source at an exact commit without resolving a live ref first.
144    fn fetch_git_commit(
145        &self,
146        url: &SourceUrl,
147        commit: &str,
148        source_name: &str,
149        diag: &mut DiagnosticCollector,
150    ) -> Result<ResolvedRef, MarsError>;
151
152    /// Resolve a local path source into a concrete tree reference.
153    fn fetch_path(
154        &self,
155        path: &Path,
156        source_name: &str,
157        diag: &mut DiagnosticCollector,
158    ) -> Result<ResolvedRef, MarsError>;
159}
160
161/// Reads source manifests for transitive dependency discovery.
162pub trait ManifestReader {
163    fn read_manifest(
164        &self,
165        source_tree: &Path,
166        diag: &mut DiagnosticCollector,
167    ) -> Result<Option<Manifest>, MarsError>;
168}
169
170/// Composite trait used by `resolve()`.
171pub trait SourceProvider: VersionLister + SourceFetcher + ManifestReader {}
172
173impl<T> SourceProvider for T where T: VersionLister + SourceFetcher + ManifestReader {}
174
175/// Resolve the full dependency graph from config.
176///
177/// Uses lock-preferred latest-compatible selection by default: if the lock has
178/// a compatible version, replay it; otherwise pick the newest satisfying version.
179/// Users who want lock-agnostic maximization use `mars upgrade`.
180///
181/// When `locked` is provided, prefer locked versions when constraints allow
182/// (reproducible builds).
183///
184/// ## Fresh-context restart algorithm
185///
186/// The bottom-up traversal can discover that an already-resolved package would
187/// select a different version under the full accumulated constraint set (e.g.
188/// a `Latest` constraint from a later-processed package changes the optimum).
189/// When this happens `resolve_package_bottom_up` emits `ResolutionRestartNeeded`.
190///
191/// The driver handles this by:
192///   1. Reading the "correct" (new) ref from the context.
193///   2. Carrying it as an override into a fresh `ResolverContext`.
194///   3. Restarting the bottom-up phase from scratch.
195///
196/// On the next pass the override is used at first-resolution time — the package
197/// starts at the right version, so the same constraint pattern does NOT re-trigger
198/// a restart. B1 (stale manifest-derived constraints) and B2 (new deps not
199/// materialized) are avoided by construction because the fresh context has no stale
200/// state and the override falls through to the normal first-resolution code path.
201///
202/// Convergence is guaranteed in practice because versions only move in one direction
203/// (upward under maximize, toward the lock-preferred/latest-compatible optimum).
204/// If a package starts bouncing between previously-seen refs, the driver reports
205/// a true per-package oscillation with the observed ref cycle.
206pub fn resolve(
207    config: &EffectiveConfig,
208    provider: &dyn SourceProvider,
209    locked: Option<&LockFile>,
210    options: &ResolveOptions,
211    diag: &mut DiagnosticCollector,
212) -> Result<ResolvedGraph, MarsError> {
213    // Build direct requests (stable across restarts — determined by config + options).
214    let direct_requests: Vec<PendingSource> = {
215        let mut reqs = Vec::new();
216        for (name, source) in &config.dependencies {
217            let constraint = match &source.spec {
218                SourceSpec::Git(git) => options
219                    .direct_constraint_for(name, parse_version_constraint(git.version.as_deref())),
220                SourceSpec::Path(_) => VersionConstraint::Latest,
221            };
222            reqs.push(PendingSource {
223                name: name.clone(),
224                declared_source_id: source.declared_source_id.clone(),
225                source_id: source.source_id.clone(),
226                spec: source.spec.clone(),
227                subpath: source.subpath.clone(),
228                constraint,
229                filter: source.filter.clone(),
230                required_by: "mars.toml".to_string(),
231            });
232        }
233        reqs
234    };
235
236    // Version overrides carried across restarts:
237    // package → (correct ref, correct rooted package).
238    let mut version_overrides: HashMap<
239        SourceName,
240        (
241            ResolvedRef,
242            RootedSourceRef,
243            crate::staging::HookSurfaceState,
244        ),
245    > = HashMap::new();
246    // Per-package restart history used for true oscillation detection.
247    let mut restart_history: HashMap<SourceName, Vec<ResolvedRef>> = HashMap::new();
248    let mut exclusions = EngineExclusions::new();
249
250    // Restart loop: normally executes once. Restarts only when a package would
251    // resolve differently under the full constraint set than it did at first-resolution
252    // time (order-dependent constraint accumulation bug).
253    let ctx = loop {
254        let mut ctx = ResolverContext::new();
255        ctx.set_version_overrides(version_overrides.clone());
256
257        // Bottom-up phase: resolve all packages (with version selection) and seed items.
258        let bottom_up_result = (|| -> Result<(), MarsError> {
259            for request in direct_requests
260                .iter()
261                .filter(|request| filter::is_unfiltered_request(&request.filter))
262            {
263                resolve_package_bottom_up(
264                    request,
265                    true,
266                    provider,
267                    locked,
268                    options,
269                    config,
270                    diag,
271                    &mut ctx,
272                    &mut exclusions,
273                )?;
274            }
275            for request in direct_requests
276                .iter()
277                .filter(|request| !filter::is_unfiltered_request(&request.filter))
278            {
279                resolve_package_bottom_up(
280                    request,
281                    true,
282                    provider,
283                    locked,
284                    options,
285                    config,
286                    diag,
287                    &mut ctx,
288                    &mut exclusions,
289                )?;
290            }
291            Ok(())
292        })();
293
294        match bottom_up_result {
295            Err(MarsError::ResolutionRestartNeeded { package }) => {
296                version_overrides = ctx.version_overrides().clone();
297                // Read the override info before discarding ctx.
298                let Some((pkg_name, new_ref, new_rooted, hook_surface)) =
299                    ctx.take_pending_restart()
300                else {
301                    return Err(MarsError::Internal(format!(
302                        "missing pending restart payload for `{package}`"
303                    )));
304                };
305                let history = restart_history.entry(pkg_name.clone()).or_default();
306                if let Some(cycle_start) = history
307                    .iter()
308                    .position(|seen| same_resolved_ref(seen, &new_ref))
309                {
310                    let mut cycle: Vec<String> = history[cycle_start..]
311                        .iter()
312                        .map(describe_resolved_ref)
313                        .collect();
314                    cycle.push(describe_resolved_ref(&new_ref));
315                    return Err(MarsError::Resolution(ResolutionError::VersionConflict {
316                        name: pkg_name.to_string(),
317                        message: format!(
318                            "resolution oscillation detected for `{pkg_name}`: {}",
319                            cycle.join(" -> ")
320                        ),
321                    }));
322                }
323                history.push(new_ref.clone());
324                version_overrides.insert(pkg_name, (new_ref, new_rooted, hook_surface));
325                // Discard ctx and retry with updated overrides.
326                continue;
327            }
328            Err(other) => return Err(other),
329            Ok(()) => break ctx,
330        }
331    };
332
333    // Item DFS phase: traverse seeded items, resolve skill deps.
334    let mut ctx = ctx;
335    while let Some(pending_item) = ctx.pop_pending() {
336        let (resolved_ref, skill_deps) = {
337            let Some(package) = ctx.registry().get(&pending_item.package) else {
338                return Err(ResolutionError::SourceNotFound {
339                    name: pending_item.package.to_string(),
340                }
341                .into());
342            };
343
344            if package
345                .item(pending_item.kind, &pending_item.item)
346                .is_none()
347            {
348                continue;
349            }
350
351            let skill_deps = parse_pending_item_skill_deps(&pending_item, package)?;
352            (package.node.resolved_ref.clone(), skill_deps)
353        };
354
355        match apply_item_version_policy(
356            &pending_item,
357            ctx.visited().check_version(
358                &pending_item.package,
359                &pending_item.item,
360                &pending_item.constraint,
361            ),
362            diag,
363        )
364        .map_err(MarsError::from)?
365        {
366            VersionAction::Process => {}
367            VersionAction::Skip => continue,
368        }
369
370        ctx.package_versions_mut()
371            .check_or_insert(
372                &pending_item.package,
373                &resolved_ref,
374                &pending_item.constraint,
375                &pending_item.required_by,
376                pending_item.is_local,
377            )
378            .map_err(MarsError::from)?;
379
380        ctx.visited_mut().insert(
381            pending_item.package.clone(),
382            pending_item.item.clone(),
383            pending_item.constraint.clone(),
384            resolved_ref,
385        );
386
387        for skill_dep in skill_deps {
388            let resolved_skill = resolve_skill_ref(
389                &skill_dep,
390                &pending_item,
391                ctx.registry(),
392                ctx.version_constraints(),
393            )?;
394            if is_item_excluded(
395                ctx.materialization_filters(),
396                ctx.registry(),
397                &resolved_skill.package,
398                resolved_skill.kind,
399                &resolved_skill.item,
400            ) {
401                continue;
402            }
403            ctx.add_filter(
404                &resolved_skill.package,
405                crate::config::FilterMode::Include {
406                    agents: Vec::new(),
407                    skills: vec![resolved_skill.item.clone()],
408                },
409            );
410            ctx.push_pending(resolved_skill);
411        }
412    }
413
414    let version_constraints = ctx.version_constraints().clone();
415    let graph = ctx.into_graph();
416
417    validate_all_constraints(&graph.nodes, &version_constraints)?;
418    diag.reconcile_engine_fallbacks(&graph);
419
420    Ok(graph)
421}
422
423#[cfg(test)]
424fn alphabetical_order(nodes: &IndexMap<SourceName, ResolvedNode>) -> Vec<SourceName> {
425    let mut order: Vec<SourceName> = nodes.keys().cloned().collect();
426    order.sort();
427    order
428}
429
430#[cfg(test)]
431mod tests;