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                declared_source_id: source.declared_source_id.clone(),
218                source_id: source.source_id.clone(),
219                spec: source.spec.clone(),
220                subpath: source.subpath.clone(),
221                constraint,
222                filter: source.filter.clone(),
223                required_by: "mars.toml".to_string(),
224            });
225        }
226        reqs
227    };
228
229    // Version overrides carried across restarts:
230    // package → (correct ref, correct rooted package).
231    let mut version_overrides: HashMap<
232        SourceName,
233        (
234            ResolvedRef,
235            RootedSourceRef,
236            crate::staging::HookSurfaceState,
237        ),
238    > = HashMap::new();
239    // Per-package restart history used for true oscillation detection.
240    let mut restart_history: HashMap<SourceName, Vec<ResolvedRef>> = HashMap::new();
241
242    // Restart loop: normally executes once. Restarts only when a package would
243    // resolve differently under the full constraint set than it did at first-resolution
244    // time (order-dependent constraint accumulation bug).
245    let ctx = loop {
246        let mut ctx = ResolverContext::new();
247        ctx.set_version_overrides(version_overrides.clone());
248
249        // Bottom-up phase: resolve all packages (with version selection) and seed items.
250        let bottom_up_result = (|| -> Result<(), MarsError> {
251            for request in direct_requests
252                .iter()
253                .filter(|request| filter::is_unfiltered_request(&request.filter))
254            {
255                resolve_package_bottom_up(
256                    request, true, provider, locked, options, config, diag, &mut ctx,
257                )?;
258            }
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, true, provider, locked, options, config, diag, &mut ctx,
265                )?;
266            }
267            Ok(())
268        })();
269
270        match bottom_up_result {
271            Err(MarsError::ResolutionRestartNeeded { package }) => {
272                // Read the override info before discarding ctx.
273                let Some((pkg_name, new_ref, new_rooted, hook_surface)) =
274                    ctx.take_pending_restart()
275                else {
276                    return Err(MarsError::Internal(format!(
277                        "missing pending restart payload for `{package}`"
278                    )));
279                };
280                let history = restart_history.entry(pkg_name.clone()).or_default();
281                if let Some(cycle_start) = history
282                    .iter()
283                    .position(|seen| same_resolved_ref(seen, &new_ref))
284                {
285                    let mut cycle: Vec<String> = history[cycle_start..]
286                        .iter()
287                        .map(describe_resolved_ref)
288                        .collect();
289                    cycle.push(describe_resolved_ref(&new_ref));
290                    return Err(MarsError::Resolution(ResolutionError::VersionConflict {
291                        name: pkg_name.to_string(),
292                        message: format!(
293                            "resolution oscillation detected for `{pkg_name}`: {}",
294                            cycle.join(" -> ")
295                        ),
296                    }));
297                }
298                history.push(new_ref.clone());
299                version_overrides.insert(pkg_name, (new_ref, new_rooted, hook_surface));
300                // Discard ctx and retry with updated overrides.
301                continue;
302            }
303            Err(other) => return Err(other),
304            Ok(()) => break ctx,
305        }
306    };
307
308    // Item DFS phase: traverse seeded items, resolve skill deps.
309    let mut ctx = ctx;
310    while let Some(pending_item) = ctx.pop_pending() {
311        let (resolved_ref, skill_deps) = {
312            let Some(package) = ctx.registry().get(&pending_item.package) else {
313                return Err(ResolutionError::SourceNotFound {
314                    name: pending_item.package.to_string(),
315                }
316                .into());
317            };
318
319            if package
320                .item(pending_item.kind, &pending_item.item)
321                .is_none()
322            {
323                continue;
324            }
325
326            let skill_deps = parse_pending_item_skill_deps(&pending_item, package)?;
327            (package.node.resolved_ref.clone(), skill_deps)
328        };
329
330        match apply_item_version_policy(
331            &pending_item,
332            ctx.visited().check_version(
333                &pending_item.package,
334                &pending_item.item,
335                &pending_item.constraint,
336            ),
337            diag,
338        )
339        .map_err(MarsError::from)?
340        {
341            VersionAction::Process => {}
342            VersionAction::Skip => continue,
343        }
344
345        ctx.package_versions_mut()
346            .check_or_insert(
347                &pending_item.package,
348                &resolved_ref,
349                &pending_item.constraint,
350                &pending_item.required_by,
351                pending_item.is_local,
352            )
353            .map_err(MarsError::from)?;
354
355        ctx.visited_mut().insert(
356            pending_item.package.clone(),
357            pending_item.item.clone(),
358            pending_item.constraint.clone(),
359            resolved_ref,
360        );
361
362        for skill_dep in skill_deps {
363            let resolved_skill = resolve_skill_ref(
364                &skill_dep,
365                &pending_item,
366                ctx.registry(),
367                ctx.version_constraints(),
368            )?;
369            if is_item_excluded(
370                ctx.materialization_filters(),
371                ctx.registry(),
372                &resolved_skill.package,
373                resolved_skill.kind,
374                &resolved_skill.item,
375            ) {
376                continue;
377            }
378            ctx.add_filter(
379                &resolved_skill.package,
380                crate::config::FilterMode::Include {
381                    agents: Vec::new(),
382                    skills: vec![resolved_skill.item.clone()],
383                },
384            );
385            ctx.push_pending(resolved_skill);
386        }
387    }
388
389    let version_constraints = ctx.version_constraints().clone();
390    let graph = ctx.into_graph();
391
392    validate_all_constraints(&graph.nodes, &version_constraints)?;
393
394    Ok(graph)
395}
396
397#[cfg(test)]
398fn alphabetical_order(nodes: &IndexMap<SourceName, ResolvedNode>) -> Vec<SourceName> {
399    let mut order: Vec<SourceName> = nodes.keys().cloned().collect();
400    order.sort();
401    order
402}
403
404#[cfg(test)]
405mod tests;