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::path::Path;
22
23#[cfg(test)]
24use indexmap::IndexMap;
25
26pub use constraint::parse_version_constraint;
27pub use context::ResolverContext;
28pub use types::*;
29
30pub(crate) use package::{PackageResolutionState, PendingSource, RegisteredPackage};
31#[cfg(test)]
32pub(crate) use path::apply_subpath;
33
34use crate::config::{EffectiveConfig, Manifest, SourceSpec};
35use crate::diagnostic::DiagnosticCollector;
36use crate::error::{MarsError, ResolutionError};
37use crate::lock::LockFile;
38use crate::source::{AvailableVersion, ResolvedRef};
39#[cfg(test)]
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
92/// Lists semver-tagged versions available for a git source.
93pub trait VersionLister {
94    fn list_versions(&self, url: &SourceUrl) -> Result<Vec<AvailableVersion>, MarsError>;
95}
96
97/// Fetches concrete source trees after the resolver has picked a strategy.
98pub trait SourceFetcher {
99    /// Fetch a git source at a specific version tag.
100    fn fetch_git_version(
101        &self,
102        url: &SourceUrl,
103        version: &AvailableVersion,
104        source_name: &str,
105        preferred_commit: Option<&str>,
106        diag: &mut DiagnosticCollector,
107    ) -> Result<ResolvedRef, MarsError>;
108
109    /// Fetch a git source at a branch/commit ref (non-semver path).
110    fn fetch_git_ref(
111        &self,
112        url: &SourceUrl,
113        ref_name: &str,
114        source_name: &str,
115        preferred_commit: Option<&str>,
116        diag: &mut DiagnosticCollector,
117    ) -> Result<ResolvedRef, MarsError>;
118
119    /// Resolve a local path source into a concrete tree reference.
120    fn fetch_path(
121        &self,
122        path: &Path,
123        source_name: &str,
124        diag: &mut DiagnosticCollector,
125    ) -> Result<ResolvedRef, MarsError>;
126}
127
128/// Reads source manifests for transitive dependency discovery.
129pub trait ManifestReader {
130    fn read_manifest(
131        &self,
132        source_tree: &Path,
133        diag: &mut DiagnosticCollector,
134    ) -> Result<Option<Manifest>, MarsError>;
135}
136
137/// Composite trait used by `resolve()`.
138pub trait SourceProvider: VersionLister + SourceFetcher + ManifestReader {}
139
140impl<T> SourceProvider for T where T: VersionLister + SourceFetcher + ManifestReader {}
141
142/// Resolve the full dependency graph from config.
143///
144/// Uses Minimum Version Selection (MVS) by default: selects the lowest
145/// version satisfying all constraints. This is conservative and reproducible —
146/// the same constraint always resolves to the same version. Users who want
147/// the latest use `@latest` explicitly, or `mars upgrade`.
148///
149/// When `locked` is provided, prefer locked versions when constraints allow
150/// (reproducible builds).
151pub fn resolve(
152    config: &EffectiveConfig,
153    provider: &dyn SourceProvider,
154    locked: Option<&LockFile>,
155    options: &ResolveOptions,
156    diag: &mut DiagnosticCollector,
157) -> Result<ResolvedGraph, MarsError> {
158    let mut ctx = ResolverContext::new();
159
160    let mut direct_requests: Vec<PendingSource> = Vec::new();
161    for (name, source) in &config.dependencies {
162        let is_upgrade_target = options.maximize
163            && (options.upgrade_targets.is_empty() || options.upgrade_targets.contains(name));
164        let constraint = match &source.spec {
165            SourceSpec::Git(git) => {
166                if options.bump_direct_constraints && is_upgrade_target {
167                    VersionConstraint::Latest
168                } else {
169                    parse_version_constraint(git.version.as_deref())
170                }
171            }
172            SourceSpec::Path(_) => VersionConstraint::Latest,
173        };
174        direct_requests.push(PendingSource {
175            name: name.clone(),
176            source_id: source.id.clone(),
177            spec: source.spec.clone(),
178            subpath: source.subpath.clone(),
179            constraint,
180            filter: source.filter.clone(),
181            required_by: "mars.toml".to_string(),
182        });
183    }
184
185    for request in direct_requests
186        .iter()
187        .filter(|request| filter::is_unfiltered_request(&request.filter))
188    {
189        resolve_package_bottom_up(request, true, provider, locked, options, diag, &mut ctx)?;
190    }
191    for request in direct_requests
192        .iter()
193        .filter(|request| !filter::is_unfiltered_request(&request.filter))
194    {
195        resolve_package_bottom_up(request, true, provider, locked, options, diag, &mut ctx)?;
196    }
197
198    while let Some(pending_item) = ctx.pop_pending() {
199        let (resolved_ref, skill_deps) = {
200            let Some(package) = ctx.registry().get(&pending_item.package) else {
201                return Err(ResolutionError::SourceNotFound {
202                    name: pending_item.package.to_string(),
203                }
204                .into());
205            };
206
207            if package
208                .item(pending_item.kind, &pending_item.item)
209                .is_none()
210            {
211                continue;
212            }
213
214            let skill_deps = parse_pending_item_skill_deps(&pending_item, package)?;
215            (package.node.resolved_ref.clone(), skill_deps)
216        };
217
218        match apply_item_version_policy(
219            &pending_item,
220            ctx.visited().check_version(
221                &pending_item.package,
222                &pending_item.item,
223                &pending_item.constraint,
224            ),
225            diag,
226        )
227        .map_err(MarsError::from)?
228        {
229            VersionAction::Process => {}
230            VersionAction::Skip => continue,
231        }
232
233        ctx.package_versions_mut()
234            .check_or_insert(
235                &pending_item.package,
236                &resolved_ref,
237                &pending_item.constraint,
238                &pending_item.required_by,
239                pending_item.is_local,
240            )
241            .map_err(MarsError::from)?;
242
243        ctx.visited_mut().insert(
244            pending_item.package.clone(),
245            pending_item.item.clone(),
246            pending_item.constraint.clone(),
247            resolved_ref,
248        );
249
250        for skill_dep in skill_deps {
251            let resolved_skill = resolve_skill_ref(
252                &skill_dep,
253                &pending_item,
254                ctx.registry(),
255                ctx.version_constraints(),
256            )?;
257            if is_item_excluded(
258                ctx.materialization_filters(),
259                ctx.registry(),
260                &resolved_skill.package,
261                resolved_skill.kind,
262                &resolved_skill.item,
263            ) {
264                continue;
265            }
266            ctx.add_filter(
267                &resolved_skill.package,
268                crate::config::FilterMode::Include {
269                    agents: Vec::new(),
270                    skills: vec![resolved_skill.item.clone()],
271                },
272            );
273            ctx.push_pending(resolved_skill);
274        }
275    }
276
277    let version_constraints = ctx.version_constraints().clone();
278    let graph = ctx.into_graph();
279
280    validate_all_constraints(&graph.nodes, &version_constraints)?;
281
282    Ok(graph)
283}
284
285#[cfg(test)]
286fn alphabetical_order(nodes: &IndexMap<SourceName, ResolvedNode>) -> Vec<SourceName> {
287    let mut order: Vec<SourceName> = nodes.keys().cloned().collect();
288    order.sort();
289    order
290}
291
292#[cfg(test)]
293mod tests;