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