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    /// All version constraints collected for each source (direct + transitive).
27    pub version_constraints: HashMap<SourceName, Vec<(String, VersionConstraint)>>,
28}
29
30/// A single node in the resolved graph.
31#[derive(Debug, Clone)]
32pub struct ResolvedNode {
33    pub source_name: SourceName,
34    pub source_id: SourceId,
35    pub rooted_ref: RootedSourceRef,
36    pub resolved_ref: ResolvedRef,
37    pub latest_version: Option<Version>,
38    /// None if source has no mars.toml.
39    pub manifest: Option<Manifest>,
40    /// Source names this depends on.
41    pub deps: Vec<SourceName>,
42}
43
44/// Source checkout provenance and rooted package boundary.
45#[derive(Debug, Clone)]
46pub struct RootedSourceRef {
47    pub checkout_root: PathBuf,
48    pub package_root: PathBuf,
49}
50
51/// How a version constraint was specified.
52#[derive(Debug, Clone)]
53pub enum VersionConstraint {
54    /// Semver requirement (^1.0, >=0.5.0, ~2.1, exact version).
55    Semver(VersionReq),
56    /// Any version, prefer newest.
57    Latest,
58    /// Branch or commit pin — no semver resolution.
59    RefPin(String),
60}
61
62impl std::fmt::Display for VersionConstraint {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            VersionConstraint::Semver(req) => write!(f, "{req}"),
66            VersionConstraint::Latest => write!(f, "latest"),
67            VersionConstraint::RefPin(reference) => write!(f, "ref:{reference}"),
68        }
69    }
70}
71
72/// An item waiting to be processed in DFS traversal.
73#[derive(Debug, Clone)]
74pub struct PendingItem {
75    /// Package containing this item.
76    pub package: SourceName,
77    /// Item name.
78    pub item: ItemName,
79    /// Agent or Skill.
80    pub kind: ItemKind,
81    /// Version constraint from config.
82    pub constraint: VersionConstraint,
83    /// Who requested this item (for error context).
84    pub required_by: String,
85    /// True if from a local path dependency (skip version checks).
86    pub is_local: bool,
87    /// Source spec for fetching if not already in registry.
88    pub spec: SourceSpec,
89}
90
91/// Result of checking whether an item was seen already.
92#[derive(Debug)]
93pub enum VersionCheckResult {
94    /// Item has not been visited yet.
95    NotSeen,
96    /// Item was visited with a compatible version.
97    SameVersion,
98    /// Item was visited with a potentially conflicting version (latest vs pinned).
99    PotentiallyConflicting {
100        existing: VersionConstraint,
101        requested: VersionConstraint,
102    },
103    /// Item was visited with a conflicting version.
104    DifferentVersion {
105        existing: VersionConstraint,
106        requested: VersionConstraint,
107    },
108}
109
110/// Stable key for visited items.
111#[derive(Debug, Clone, Hash, Eq, PartialEq)]
112struct VisitedItem {
113    package: SourceName,
114    item: ItemName,
115}
116
117/// Stored version information for a visited item.
118#[derive(Debug, Clone)]
119pub struct ResolvedVersion {
120    pub constraint: VersionConstraint,
121    pub resolved_ref: ResolvedRef,
122}
123
124/// Tracks visited items with version-aware lookup for DFS traversal.
125pub struct VisitedSet {
126    /// Fast lookup by (package, item).
127    index: HashMap<(SourceName, ItemName), ResolvedVersion>,
128}
129
130impl Default for VisitedSet {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136impl VisitedSet {
137    pub fn new() -> Self {
138        Self {
139            index: HashMap::new(),
140        }
141    }
142
143    fn index_key(package: &SourceName, item: &ItemName) -> (SourceName, ItemName) {
144        let key = VisitedItem {
145            package: package.clone(),
146            item: item.clone(),
147        };
148        (key.package, key.item)
149    }
150
151    /// Check whether an item was visited and compare version constraints.
152    pub fn check_version(
153        &self,
154        package: &SourceName,
155        item: &ItemName,
156        constraint: &VersionConstraint,
157    ) -> VersionCheckResult {
158        match self.index.get(&Self::index_key(package, item)) {
159            None => VersionCheckResult::NotSeen,
160            Some(existing) => match existing
161                .constraint
162                .compatible_with_resolved(constraint, existing.resolved_ref.version.as_ref())
163            {
164                CompatibilityResult::Compatible => VersionCheckResult::SameVersion,
165                CompatibilityResult::PotentiallyConflicting => {
166                    VersionCheckResult::PotentiallyConflicting {
167                        existing: existing.constraint.clone(),
168                        requested: constraint.clone(),
169                    }
170                }
171                CompatibilityResult::Conflicting => VersionCheckResult::DifferentVersion {
172                    existing: existing.constraint.clone(),
173                    requested: constraint.clone(),
174                },
175            },
176        }
177    }
178
179    /// Insert an item as visited.
180    pub fn insert(
181        &mut self,
182        package: SourceName,
183        item: ItemName,
184        constraint: VersionConstraint,
185        resolved_ref: ResolvedRef,
186    ) {
187        self.index.insert(
188            Self::index_key(&package, &item),
189            ResolvedVersion {
190                constraint,
191                resolved_ref,
192            },
193        );
194    }
195
196    /// Iterate all visited items for graph/output assembly.
197    pub fn iter(&self) -> impl Iterator<Item = (&(SourceName, ItemName), &ResolvedVersion)> {
198        self.index.iter()
199    }
200}
201
202/// Tracks resolved version per package and rejects divergent refs.
203pub struct PackageVersions {
204    /// package -> (resolved_ref, first_constraint, first_required_by)
205    versions: HashMap<SourceName, (ResolvedRef, VersionConstraint, String)>,
206}
207
208impl Default for PackageVersions {
209    fn default() -> Self {
210        Self::new()
211    }
212}
213
214impl PackageVersions {
215    pub fn new() -> Self {
216        Self {
217            versions: HashMap::new(),
218        }
219    }
220
221    /// Check existing package version or insert if first time seen.
222    pub fn check_or_insert(
223        &mut self,
224        package: &SourceName,
225        resolved: &ResolvedRef,
226        requested: &VersionConstraint,
227        required_by: &str,
228        is_local: bool,
229    ) -> Result<(), ResolutionError> {
230        if is_local {
231            return Ok(());
232        }
233
234        match self.versions.entry(package.clone()) {
235            Entry::Vacant(entry) => {
236                entry.insert((resolved.clone(), requested.clone(), required_by.to_string()));
237                Ok(())
238            }
239            Entry::Occupied(entry) => {
240                let (existing_ref, existing_constraint, existing_by) = entry.get();
241                match existing_constraint.compatible_with_resolved(
242                    requested,
243                    existing_ref.version.as_ref().or(resolved.version.as_ref()),
244                ) {
245                    CompatibilityResult::Compatible
246                    | CompatibilityResult::PotentiallyConflicting => {
247                        if resolved_ref_matches(existing_ref, resolved) {
248                            Ok(())
249                        } else {
250                            Err(ResolutionError::PackageVersionConflict {
251                                package: package.to_string(),
252                                existing: format!("{existing_ref:?} (required by {existing_by})"),
253                                requested: format!("{resolved:?} (required by {required_by})"),
254                                chain: required_by.to_string(),
255                            })
256                        }
257                    }
258                    CompatibilityResult::Conflicting => {
259                        Err(ResolutionError::PackageVersionConflict {
260                            package: package.to_string(),
261                            existing: format!("{existing_constraint} (required by {existing_by})"),
262                            requested: format!("{requested} (required by {required_by})"),
263                            chain: required_by.to_string(),
264                        })
265                    }
266                }
267            }
268        }
269    }
270}
271
272fn resolved_ref_matches(existing: &ResolvedRef, incoming: &ResolvedRef) -> bool {
273    existing.source_name == incoming.source_name
274        && existing.version == incoming.version
275        && existing.version_tag == incoming.version_tag
276        && existing.commit == incoming.commit
277        && crate::target::paths_equivalent(
278            &existing.tree_path.to_string_lossy(),
279            &incoming.tree_path.to_string_lossy(),
280        )
281}
282
283/// High-level resolver mode shared by sync and upgrade.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub enum ResolveMode {
286    /// Normal sync: replay compatible lock entries, otherwise pick newest compatible.
287    Sync,
288    /// Frozen sync: require the lock to replay exactly.
289    Frozen,
290    /// Upgrade: bypass lock replay for targets, leave non-targets lock-preferred.
291    Upgrade {
292        /// Empty means every source is an upgrade target.
293        targets: HashSet<SourceName>,
294        /// Treat direct target constraints as unconstrained so the manifest can be bumped.
295        bump_direct_constraints: bool,
296    },
297}
298
299/// Options controlling resolution behavior.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct ResolveOptions {
302    pub mode: ResolveMode,
303}
304
305impl Default for ResolveOptions {
306    fn default() -> Self {
307        Self {
308            mode: ResolveMode::Sync,
309        }
310    }
311}
312
313/// Version-selection behavior for a single source in the current resolve mode.
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub(crate) enum VersionSelectionPolicy {
316    /// Use compatible locked version when available; otherwise newest compatible.
317    PreferLockThenLatest,
318    /// Upgrade mode: choose newest compatible version and bypass lock preference.
319    LatestOnly,
320    /// Lock must be honored exactly; fail when lock cannot be used.
321    LockOnly,
322}
323
324impl ResolveOptions {
325    pub fn sync() -> Self {
326        Self {
327            mode: ResolveMode::Sync,
328        }
329    }
330
331    pub fn frozen() -> Self {
332        Self {
333            mode: ResolveMode::Frozen,
334        }
335    }
336
337    pub fn upgrade(targets: HashSet<SourceName>, bump_direct_constraints: bool) -> Self {
338        Self {
339            mode: ResolveMode::Upgrade {
340                targets,
341                bump_direct_constraints,
342            },
343        }
344    }
345
346    pub(crate) fn direct_constraint_for(
347        &self,
348        source_name: &SourceName,
349        declared: VersionConstraint,
350    ) -> VersionConstraint {
351        if matches!(
352            &self.mode,
353            ResolveMode::Upgrade {
354                bump_direct_constraints: true,
355                ..
356            }
357        ) && self.is_upgrade_target(source_name)
358        {
359            VersionConstraint::Latest
360        } else {
361            declared
362        }
363    }
364
365    pub(crate) fn is_upgrade_target(&self, source_name: &SourceName) -> bool {
366        match &self.mode {
367            ResolveMode::Upgrade { targets, .. } => {
368                targets.is_empty() || targets.contains(source_name)
369            }
370            ResolveMode::Sync | ResolveMode::Frozen => false,
371        }
372    }
373
374    pub(crate) fn version_selection_policy(
375        &self,
376        source_name: &SourceName,
377    ) -> VersionSelectionPolicy {
378        match &self.mode {
379            ResolveMode::Frozen => VersionSelectionPolicy::LockOnly,
380            ResolveMode::Upgrade { .. } if self.is_upgrade_target(source_name) => {
381                VersionSelectionPolicy::LatestOnly
382            }
383            ResolveMode::Sync | ResolveMode::Upgrade { .. } => {
384                VersionSelectionPolicy::PreferLockThenLatest
385            }
386        }
387    }
388}