Skip to main content

mars_agents/resolve/
mod.rs

1//! Dependency resolution with semver constraints.
2//!
3//! Algorithm:
4//! 1. Resolve package refs/versions (MVS 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    /// Resolve a local path source into a concrete tree reference.
137    fn fetch_path(
138        &self,
139        path: &Path,
140        source_name: &str,
141        diag: &mut DiagnosticCollector,
142    ) -> Result<ResolvedRef, MarsError>;
143}
144
145/// Reads source manifests for transitive dependency discovery.
146pub trait ManifestReader {
147    fn read_manifest(
148        &self,
149        source_tree: &Path,
150        diag: &mut DiagnosticCollector,
151    ) -> Result<Option<Manifest>, MarsError>;
152}
153
154/// Composite trait used by `resolve()`.
155pub trait SourceProvider: VersionLister + SourceFetcher + ManifestReader {}
156
157impl<T> SourceProvider for T where T: VersionLister + SourceFetcher + ManifestReader {}
158
159/// Resolve the full dependency graph from config.
160///
161/// Uses Minimum Version Selection (MVS) by default: selects the lowest
162/// version satisfying all constraints. This is conservative and reproducible —
163/// the same constraint always resolves to the same version. Users who want
164/// the latest use `@latest` explicitly, or `mars upgrade`.
165///
166/// When `locked` is provided, prefer locked versions when constraints allow
167/// (reproducible builds).
168///
169/// ## Fresh-context restart algorithm
170///
171/// The bottom-up traversal can discover that an already-resolved package would
172/// select a different version under the full accumulated constraint set (e.g.
173/// a `Latest` constraint from a later-processed package changes the optimum).
174/// When this happens `resolve_package_bottom_up` emits `ResolutionRestartNeeded`.
175///
176/// The driver handles this by:
177///   1. Reading the "correct" (new) ref from the context.
178///   2. Carrying it as an override into a fresh `ResolverContext`.
179///   3. Restarting the bottom-up phase from scratch.
180///
181/// On the next pass the override is used at first-resolution time — the package
182/// starts at the right version, so the same constraint pattern does NOT re-trigger
183/// a restart. B1 (stale manifest-derived constraints) and B2 (new deps not
184/// materialized) are avoided by construction because the fresh context has no stale
185/// state and the override falls through to the normal first-resolution code path.
186///
187/// Convergence is guaranteed in practice because versions only move in one direction
188/// (upward under maximize, toward the minimum satisfying intersection under MVS).
189/// If a package starts bouncing between previously-seen refs, the driver reports
190/// a true per-package oscillation with the observed ref cycle.
191pub fn resolve(
192    config: &EffectiveConfig,
193    provider: &dyn SourceProvider,
194    locked: Option<&LockFile>,
195    options: &ResolveOptions,
196    diag: &mut DiagnosticCollector,
197) -> Result<ResolvedGraph, MarsError> {
198    // Pre-compute direct source names (stable across restarts — determined by config).
199    let direct_source_names: std::collections::HashSet<SourceName> =
200        config.dependencies.keys().cloned().collect();
201
202    // Build direct requests (stable across restarts — determined by config + options).
203    let direct_requests: Vec<PendingSource> = {
204        let mut reqs = Vec::new();
205        for (name, source) in &config.dependencies {
206            let is_upgrade_target = options.maximize
207                && (options.upgrade_targets.is_empty() || options.upgrade_targets.contains(name));
208            let constraint = match &source.spec {
209                SourceSpec::Git(git) => {
210                    if options.bump_direct_constraints && is_upgrade_target {
211                        VersionConstraint::Latest
212                    } else {
213                        parse_version_constraint(git.version.as_deref())
214                    }
215                }
216                SourceSpec::Path(_) => VersionConstraint::Latest,
217            };
218            reqs.push(PendingSource {
219                name: name.clone(),
220                source_id: source.id.clone(),
221                spec: source.spec.clone(),
222                subpath: source.subpath.clone(),
223                constraint,
224                filter: source.filter.clone(),
225                required_by: "mars.toml".to_string(),
226            });
227        }
228        reqs
229    };
230
231    // Version overrides carried across restarts:
232    // package → (correct ref, correct rooted, latest_version metadata).
233    let mut version_overrides: HashMap<
234        SourceName,
235        (ResolvedRef, RootedSourceRef, Option<semver::Version>),
236    > = HashMap::new();
237    // Per-package restart history used for true oscillation detection.
238    let mut restart_history: HashMap<SourceName, Vec<ResolvedRef>> = HashMap::new();
239
240    // Restart loop: normally executes once. Restarts only when a package would
241    // resolve differently under the full constraint set than it did at first-resolution
242    // time (order-dependent constraint accumulation bug).
243    let ctx = loop {
244        let mut ctx = ResolverContext::new();
245        ctx.set_direct_sources(direct_source_names.clone());
246        ctx.set_version_overrides(version_overrides.clone());
247
248        // Bottom-up phase: resolve all packages (with version selection) and seed items.
249        let bottom_up_result = (|| -> Result<(), MarsError> {
250            for request in direct_requests
251                .iter()
252                .filter(|request| filter::is_unfiltered_request(&request.filter))
253            {
254                resolve_package_bottom_up(
255                    request, true, provider, locked, options, diag, &mut ctx,
256                )?;
257            }
258            for request in direct_requests
259                .iter()
260                .filter(|request| !filter::is_unfiltered_request(&request.filter))
261            {
262                resolve_package_bottom_up(
263                    request, true, provider, locked, options, diag, &mut ctx,
264                )?;
265            }
266            Ok(())
267        })();
268
269        match bottom_up_result {
270            Err(MarsError::ResolutionRestartNeeded { package }) => {
271                // Read the override info before discarding ctx.
272                let Some((pkg_name, new_ref, new_rooted, latest_version)) =
273                    ctx.take_pending_restart()
274                else {
275                    return Err(MarsError::Internal(format!(
276                        "missing pending restart payload for `{package}`"
277                    )));
278                };
279                let history = restart_history.entry(pkg_name.clone()).or_default();
280                if let Some(cycle_start) = history
281                    .iter()
282                    .position(|seen| same_resolved_ref(seen, &new_ref))
283                {
284                    let mut cycle: Vec<String> = history[cycle_start..]
285                        .iter()
286                        .map(describe_resolved_ref)
287                        .collect();
288                    cycle.push(describe_resolved_ref(&new_ref));
289                    return Err(MarsError::Resolution(ResolutionError::VersionConflict {
290                        name: pkg_name.to_string(),
291                        message: format!(
292                            "resolution oscillation detected for `{pkg_name}`: {}",
293                            cycle.join(" -> ")
294                        ),
295                    }));
296                }
297                history.push(new_ref.clone());
298                version_overrides.insert(pkg_name, (new_ref, new_rooted, latest_version));
299                // Discard ctx and retry with updated overrides.
300                continue;
301            }
302            Err(other) => return Err(other),
303            Ok(()) => break ctx,
304        }
305    };
306
307    // Item DFS phase: traverse seeded items, resolve skill deps.
308    let mut ctx = ctx;
309    while let Some(pending_item) = ctx.pop_pending() {
310        let (resolved_ref, skill_deps) = {
311            let Some(package) = ctx.registry().get(&pending_item.package) else {
312                return Err(ResolutionError::SourceNotFound {
313                    name: pending_item.package.to_string(),
314                }
315                .into());
316            };
317
318            if package
319                .item(pending_item.kind, &pending_item.item)
320                .is_none()
321            {
322                continue;
323            }
324
325            let skill_deps = parse_pending_item_skill_deps(&pending_item, package)?;
326            (package.node.resolved_ref.clone(), skill_deps)
327        };
328
329        match apply_item_version_policy(
330            &pending_item,
331            ctx.visited().check_version(
332                &pending_item.package,
333                &pending_item.item,
334                &pending_item.constraint,
335            ),
336            diag,
337        )
338        .map_err(MarsError::from)?
339        {
340            VersionAction::Process => {}
341            VersionAction::Skip => continue,
342        }
343
344        ctx.package_versions_mut()
345            .check_or_insert(
346                &pending_item.package,
347                &resolved_ref,
348                &pending_item.constraint,
349                &pending_item.required_by,
350                pending_item.is_local,
351            )
352            .map_err(MarsError::from)?;
353
354        ctx.visited_mut().insert(
355            pending_item.package.clone(),
356            pending_item.item.clone(),
357            pending_item.constraint.clone(),
358            resolved_ref,
359        );
360
361        for skill_dep in skill_deps {
362            let resolved_skill = resolve_skill_ref(
363                &skill_dep,
364                &pending_item,
365                ctx.registry(),
366                ctx.version_constraints(),
367            )?;
368            if is_item_excluded(
369                ctx.materialization_filters(),
370                ctx.registry(),
371                &resolved_skill.package,
372                resolved_skill.kind,
373                &resolved_skill.item,
374            ) {
375                continue;
376            }
377            ctx.add_filter(
378                &resolved_skill.package,
379                crate::config::FilterMode::Include {
380                    agents: Vec::new(),
381                    skills: vec![resolved_skill.item.clone()],
382                },
383            );
384            ctx.push_pending(resolved_skill);
385        }
386    }
387
388    let version_constraints = ctx.version_constraints().clone();
389    let graph = ctx.into_graph();
390
391    validate_all_constraints(&graph.nodes, &version_constraints)?;
392
393    Ok(graph)
394}
395
396#[cfg(test)]
397fn alphabetical_order(nodes: &IndexMap<SourceName, ResolvedNode>) -> Vec<SourceName> {
398    let mut order: Vec<SourceName> = nodes.keys().cloned().collect();
399    order.sort();
400    order
401}
402
403#[cfg(test)]
404mod tests;