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/// High-level resolver mode shared by sync and upgrade.
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub enum ResolveMode {
284    /// Normal sync: replay compatible lock entries, otherwise pick newest compatible.
285    Sync,
286    /// Frozen sync: require the lock to replay exactly.
287    Frozen,
288    /// Upgrade: bypass lock replay for targets, leave non-targets lock-preferred.
289    Upgrade {
290        /// Empty means every source is an upgrade target.
291        targets: HashSet<SourceName>,
292        /// Treat direct target constraints as unconstrained so the manifest can be bumped.
293        bump_direct_constraints: bool,
294    },
295}
296
297/// Options controlling resolution behavior.
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct ResolveOptions {
300    pub mode: ResolveMode,
301}
302
303impl Default for ResolveOptions {
304    fn default() -> Self {
305        Self {
306            mode: ResolveMode::Sync,
307        }
308    }
309}
310
311/// Version-selection behavior for a single source in the current resolve mode.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub(crate) enum VersionSelectionPolicy {
314    /// Use compatible locked version when available; otherwise newest compatible.
315    PreferLockThenLatest,
316    /// Upgrade mode: choose newest compatible version and bypass lock preference.
317    LatestOnly,
318    /// Lock must be honored exactly; fail when lock cannot be used.
319    LockOnly,
320}
321
322impl ResolveOptions {
323    pub fn sync() -> Self {
324        Self {
325            mode: ResolveMode::Sync,
326        }
327    }
328
329    pub fn frozen() -> Self {
330        Self {
331            mode: ResolveMode::Frozen,
332        }
333    }
334
335    pub fn upgrade(targets: HashSet<SourceName>, bump_direct_constraints: bool) -> Self {
336        Self {
337            mode: ResolveMode::Upgrade {
338                targets,
339                bump_direct_constraints,
340            },
341        }
342    }
343
344    pub(crate) fn direct_constraint_for(
345        &self,
346        source_name: &SourceName,
347        declared: VersionConstraint,
348    ) -> VersionConstraint {
349        if matches!(
350            &self.mode,
351            ResolveMode::Upgrade {
352                bump_direct_constraints: true,
353                ..
354            }
355        ) && self.is_upgrade_target(source_name)
356        {
357            VersionConstraint::Latest
358        } else {
359            declared
360        }
361    }
362
363    pub(crate) fn is_upgrade_target(&self, source_name: &SourceName) -> bool {
364        match &self.mode {
365            ResolveMode::Upgrade { targets, .. } => {
366                targets.is_empty() || targets.contains(source_name)
367            }
368            ResolveMode::Sync | ResolveMode::Frozen => false,
369        }
370    }
371
372    pub(crate) fn version_selection_policy(
373        &self,
374        source_name: &SourceName,
375    ) -> VersionSelectionPolicy {
376        match &self.mode {
377            ResolveMode::Frozen => VersionSelectionPolicy::LockOnly,
378            ResolveMode::Upgrade { .. } if self.is_upgrade_target(source_name) => {
379                VersionSelectionPolicy::LatestOnly
380            }
381            ResolveMode::Sync | ResolveMode::Upgrade { .. } => {
382                VersionSelectionPolicy::PreferLockThenLatest
383            }
384        }
385    }
386}