Skip to main content

mars_agents/resolve/
types.rs

1use std::collections::hash_map::Entry;
2use std::collections::{HashMap, HashSet};
3use std::path::PathBuf;
4
5use indexmap::IndexMap;
6use semver::{Version, VersionReq};
7
8use super::compat::CompatibilityResult;
9use crate::config::{FilterMode, Manifest, SourceSpec};
10use crate::error::ResolutionError;
11use crate::lock::ItemKind;
12use crate::source::ResolvedRef;
13use crate::types::{ItemName, SourceId, SourceName};
14
15/// The resolved dependency graph — all sources with concrete versions.
16///
17/// Produced by the resolver after fetching sources, reading manifests,
18/// intersecting version constraints, and deterministic ordering.
19#[derive(Debug, Clone)]
20pub struct ResolvedGraph {
21    pub nodes: IndexMap<SourceName, ResolvedNode>,
22    /// Deterministic alphabetical order (prompt packages don't require dependency ordering).
23    pub order: Vec<SourceName>,
24    /// All filter constraints collected for each source (direct + transitive).
25    pub filters: HashMap<SourceName, Vec<FilterMode>>,
26}
27
28/// A single node in the resolved graph.
29#[derive(Debug, Clone)]
30pub struct ResolvedNode {
31    pub source_name: SourceName,
32    pub source_id: SourceId,
33    pub rooted_ref: RootedSourceRef,
34    pub resolved_ref: ResolvedRef,
35    pub latest_version: Option<Version>,
36    /// None if source has no mars.toml.
37    pub manifest: Option<Manifest>,
38    /// Source names this depends on.
39    pub deps: Vec<SourceName>,
40}
41
42/// Source checkout provenance and rooted package boundary.
43#[derive(Debug, Clone)]
44pub struct RootedSourceRef {
45    pub checkout_root: PathBuf,
46    pub package_root: PathBuf,
47}
48
49/// How a version constraint was specified.
50#[derive(Debug, Clone)]
51pub enum VersionConstraint {
52    /// Semver requirement (^1.0, >=0.5.0, ~2.1, exact version).
53    Semver(VersionReq),
54    /// Any version, prefer newest.
55    Latest,
56    /// Branch or commit pin — no semver resolution.
57    RefPin(String),
58}
59
60impl std::fmt::Display for VersionConstraint {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            VersionConstraint::Semver(req) => write!(f, "{req}"),
64            VersionConstraint::Latest => write!(f, "latest"),
65            VersionConstraint::RefPin(reference) => write!(f, "ref:{reference}"),
66        }
67    }
68}
69
70/// An item waiting to be processed in DFS traversal.
71#[derive(Debug, Clone)]
72pub struct PendingItem {
73    /// Package containing this item.
74    pub package: SourceName,
75    /// Item name.
76    pub item: ItemName,
77    /// Agent or Skill.
78    pub kind: ItemKind,
79    /// Version constraint from config.
80    pub constraint: VersionConstraint,
81    /// Who requested this item (for error context).
82    pub required_by: String,
83    /// True if from a local path dependency (skip version checks).
84    pub is_local: bool,
85    /// Source spec for fetching if not already in registry.
86    pub spec: SourceSpec,
87}
88
89/// Result of checking whether an item was seen already.
90#[derive(Debug)]
91pub enum VersionCheckResult {
92    /// Item has not been visited yet.
93    NotSeen,
94    /// Item was visited with a compatible version.
95    SameVersion,
96    /// Item was visited with a potentially conflicting version (latest vs pinned).
97    PotentiallyConflicting {
98        existing: VersionConstraint,
99        requested: VersionConstraint,
100    },
101    /// Item was visited with a conflicting version.
102    DifferentVersion {
103        existing: VersionConstraint,
104        requested: VersionConstraint,
105    },
106}
107
108/// Stable key for visited items.
109#[derive(Debug, Clone, Hash, Eq, PartialEq)]
110struct VisitedItem {
111    package: SourceName,
112    item: ItemName,
113}
114
115/// Stored version information for a visited item.
116#[derive(Debug, Clone)]
117pub struct ResolvedVersion {
118    pub constraint: VersionConstraint,
119    pub resolved_ref: ResolvedRef,
120}
121
122/// Tracks visited items with version-aware lookup for DFS traversal.
123pub struct VisitedSet {
124    /// Fast lookup by (package, item).
125    index: HashMap<(SourceName, ItemName), ResolvedVersion>,
126}
127
128impl Default for VisitedSet {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134impl VisitedSet {
135    pub fn new() -> Self {
136        Self {
137            index: HashMap::new(),
138        }
139    }
140
141    fn index_key(package: &SourceName, item: &ItemName) -> (SourceName, ItemName) {
142        let key = VisitedItem {
143            package: package.clone(),
144            item: item.clone(),
145        };
146        (key.package, key.item)
147    }
148
149    /// Check whether an item was visited and compare version constraints.
150    pub fn check_version(
151        &self,
152        package: &SourceName,
153        item: &ItemName,
154        constraint: &VersionConstraint,
155    ) -> VersionCheckResult {
156        match self.index.get(&Self::index_key(package, item)) {
157            None => VersionCheckResult::NotSeen,
158            Some(existing) => match existing
159                .constraint
160                .compatible_with_resolved(constraint, existing.resolved_ref.version.as_ref())
161            {
162                CompatibilityResult::Compatible => VersionCheckResult::SameVersion,
163                CompatibilityResult::PotentiallyConflicting => {
164                    VersionCheckResult::PotentiallyConflicting {
165                        existing: existing.constraint.clone(),
166                        requested: constraint.clone(),
167                    }
168                }
169                CompatibilityResult::Conflicting => VersionCheckResult::DifferentVersion {
170                    existing: existing.constraint.clone(),
171                    requested: constraint.clone(),
172                },
173            },
174        }
175    }
176
177    /// Insert an item as visited.
178    pub fn insert(
179        &mut self,
180        package: SourceName,
181        item: ItemName,
182        constraint: VersionConstraint,
183        resolved_ref: ResolvedRef,
184    ) {
185        self.index.insert(
186            Self::index_key(&package, &item),
187            ResolvedVersion {
188                constraint,
189                resolved_ref,
190            },
191        );
192    }
193
194    /// Iterate all visited items for graph/output assembly.
195    pub fn iter(&self) -> impl Iterator<Item = (&(SourceName, ItemName), &ResolvedVersion)> {
196        self.index.iter()
197    }
198}
199
200/// Tracks resolved version per package and rejects divergent refs.
201pub struct PackageVersions {
202    /// package -> (resolved_ref, first_constraint, first_required_by)
203    versions: HashMap<SourceName, (ResolvedRef, VersionConstraint, String)>,
204}
205
206impl Default for PackageVersions {
207    fn default() -> Self {
208        Self::new()
209    }
210}
211
212impl PackageVersions {
213    pub fn new() -> Self {
214        Self {
215            versions: HashMap::new(),
216        }
217    }
218
219    /// Check existing package version or insert if first time seen.
220    pub fn check_or_insert(
221        &mut self,
222        package: &SourceName,
223        resolved: &ResolvedRef,
224        requested: &VersionConstraint,
225        required_by: &str,
226        is_local: bool,
227    ) -> Result<(), ResolutionError> {
228        if is_local {
229            return Ok(());
230        }
231
232        match self.versions.entry(package.clone()) {
233            Entry::Vacant(entry) => {
234                entry.insert((resolved.clone(), requested.clone(), required_by.to_string()));
235                Ok(())
236            }
237            Entry::Occupied(entry) => {
238                let (existing_ref, existing_constraint, existing_by) = entry.get();
239                match existing_constraint.compatible_with_resolved(
240                    requested,
241                    existing_ref.version.as_ref().or(resolved.version.as_ref()),
242                ) {
243                    CompatibilityResult::Compatible
244                    | CompatibilityResult::PotentiallyConflicting => {
245                        if resolved_ref_matches(existing_ref, resolved) {
246                            Ok(())
247                        } else {
248                            Err(ResolutionError::PackageVersionConflict {
249                                package: package.to_string(),
250                                existing: format!("{existing_ref:?} (required by {existing_by})"),
251                                requested: format!("{resolved:?} (required by {required_by})"),
252                                chain: required_by.to_string(),
253                            })
254                        }
255                    }
256                    CompatibilityResult::Conflicting => {
257                        Err(ResolutionError::PackageVersionConflict {
258                            package: package.to_string(),
259                            existing: format!("{existing_constraint} (required by {existing_by})"),
260                            requested: format!("{requested} (required by {required_by})"),
261                            chain: required_by.to_string(),
262                        })
263                    }
264                }
265            }
266        }
267    }
268}
269
270fn resolved_ref_matches(existing: &ResolvedRef, incoming: &ResolvedRef) -> bool {
271    existing.source_name == incoming.source_name
272        && existing.version == incoming.version
273        && existing.version_tag == incoming.version_tag
274        && existing.commit == incoming.commit
275        && crate::target::paths_equivalent(
276            &existing.tree_path.to_string_lossy(),
277            &incoming.tree_path.to_string_lossy(),
278        )
279}
280
281/// Options controlling resolution behavior.
282#[derive(Debug, Clone, Default)]
283pub struct ResolveOptions {
284    /// If true, prefer newest version instead of minimum (for `mars upgrade`).
285    pub maximize: bool,
286    /// Source names to upgrade (empty = all, when maximize=true).
287    pub upgrade_targets: HashSet<SourceName>,
288    /// If true, treat direct dependency constraints for upgrade targets as
289    /// unconstrained during resolution (used by `mars upgrade --bump`).
290    pub bump_direct_constraints: bool,
291    /// If true, locked commit replay failures become hard errors.
292    pub frozen: bool,
293}