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::VersionReq;
7
8use super::compat::CompatibilityResult;
9use crate::config::{FilterMode, Manifest};
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    /// Hook surfaces that cannot be compiled because they use a removed schema.
29    ///
30    /// This is classified during staging and consumed only by the recovery halt
31    /// gate at the reader/compiler boundary.
32    pub unreadable_hook_surfaces:
33        std::collections::BTreeMap<SourceName, std::collections::BTreeSet<String>>,
34}
35
36/// A single node in the resolved graph.
37#[derive(Debug, Clone)]
38pub struct ResolvedNode {
39    pub source_name: SourceName,
40    pub source_id: SourceId,
41    pub rooted_ref: RootedSourceRef,
42    pub resolved_ref: ResolvedRef,
43    /// None if source has no mars.toml.
44    pub manifest: Option<Manifest>,
45    /// Source names this depends on.
46    pub deps: Vec<SourceName>,
47}
48
49/// Source checkout provenance and rooted package boundary.
50#[derive(Debug, Clone)]
51pub struct RootedSourceRef {
52    pub checkout_root: PathBuf,
53    pub package_root: PathBuf,
54}
55
56/// How a version constraint was specified.
57#[derive(Debug, Clone)]
58pub enum VersionConstraint {
59    /// Semver requirement (^1.0, >=0.5.0, ~2.1, exact version).
60    Semver(VersionReq),
61    /// Any version, prefer newest.
62    Latest,
63    /// Branch or commit pin — no semver resolution.
64    RefPin(String),
65}
66
67impl std::fmt::Display for VersionConstraint {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            VersionConstraint::Semver(req) => write!(f, "{req}"),
71            VersionConstraint::Latest => write!(f, "latest"),
72            VersionConstraint::RefPin(reference) => write!(f, "ref:{reference}"),
73        }
74    }
75}
76
77/// An item waiting to be processed in DFS traversal.
78#[derive(Debug, Clone)]
79pub struct PendingItem {
80    /// Package containing this item.
81    pub package: SourceName,
82    /// Item name.
83    pub item: ItemName,
84    /// Agent or Skill.
85    pub kind: ItemKind,
86    /// Version constraint from config.
87    pub constraint: VersionConstraint,
88    /// Who requested this item (for error context).
89    pub required_by: String,
90    /// True if from a local path dependency (skip version checks).
91    pub is_local: bool,
92}
93
94/// Result of checking whether an item was seen already.
95#[derive(Debug)]
96pub enum VersionCheckResult {
97    /// Item has not been visited yet.
98    NotSeen,
99    /// Item was visited with a compatible version.
100    SameVersion,
101    /// Item was visited with a potentially conflicting version (latest vs pinned).
102    PotentiallyConflicting {
103        existing: VersionConstraint,
104        requested: VersionConstraint,
105    },
106    /// Item was visited with a conflicting version.
107    DifferentVersion {
108        existing: VersionConstraint,
109        requested: VersionConstraint,
110    },
111}
112
113/// Stable key for visited items.
114#[derive(Debug, Clone, Hash, Eq, PartialEq)]
115struct VisitedItem {
116    package: SourceName,
117    item: ItemName,
118}
119
120/// Stored version information for a visited item.
121#[derive(Debug, Clone)]
122pub struct ResolvedVersion {
123    pub constraint: VersionConstraint,
124    pub resolved_ref: ResolvedRef,
125}
126
127/// Tracks visited items with version-aware lookup for DFS traversal.
128pub struct VisitedSet {
129    /// Fast lookup by (package, item).
130    index: HashMap<(SourceName, ItemName), ResolvedVersion>,
131}
132
133impl Default for VisitedSet {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl VisitedSet {
140    pub fn new() -> Self {
141        Self {
142            index: HashMap::new(),
143        }
144    }
145
146    fn index_key(package: &SourceName, item: &ItemName) -> (SourceName, ItemName) {
147        let key = VisitedItem {
148            package: package.clone(),
149            item: item.clone(),
150        };
151        (key.package, key.item)
152    }
153
154    /// Check whether an item was visited and compare version constraints.
155    pub fn check_version(
156        &self,
157        package: &SourceName,
158        item: &ItemName,
159        constraint: &VersionConstraint,
160    ) -> VersionCheckResult {
161        match self.index.get(&Self::index_key(package, item)) {
162            None => VersionCheckResult::NotSeen,
163            Some(existing) => match existing
164                .constraint
165                .compatible_with_resolved(constraint, existing.resolved_ref.version.as_ref())
166            {
167                CompatibilityResult::Compatible => VersionCheckResult::SameVersion,
168                CompatibilityResult::PotentiallyConflicting => {
169                    VersionCheckResult::PotentiallyConflicting {
170                        existing: existing.constraint.clone(),
171                        requested: constraint.clone(),
172                    }
173                }
174                CompatibilityResult::Conflicting => VersionCheckResult::DifferentVersion {
175                    existing: existing.constraint.clone(),
176                    requested: constraint.clone(),
177                },
178            },
179        }
180    }
181
182    /// Insert an item as visited.
183    pub fn insert(
184        &mut self,
185        package: SourceName,
186        item: ItemName,
187        constraint: VersionConstraint,
188        resolved_ref: ResolvedRef,
189    ) {
190        self.index.insert(
191            Self::index_key(&package, &item),
192            ResolvedVersion {
193                constraint,
194                resolved_ref,
195            },
196        );
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    /// Per-project directory for dependency-scoped canonical source staging.
302    pub staging_root: Option<std::path::PathBuf>,
303    /// Local source substitutions, including names first introduced transitively.
304    pub(crate) source_overrides: indexmap::IndexMap<SourceName, std::path::PathBuf>,
305}
306
307impl Default for ResolveOptions {
308    fn default() -> Self {
309        Self {
310            mode: ResolveMode::Sync,
311            staging_root: None,
312            source_overrides: indexmap::IndexMap::new(),
313        }
314    }
315}
316
317/// Version-selection behavior for a single source in the current resolve mode.
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub(crate) enum VersionSelectionPolicy {
320    /// Use compatible locked version when available; otherwise newest compatible.
321    PreferLockThenLatest,
322    /// Upgrade mode: choose newest compatible version and bypass lock preference.
323    LatestOnly,
324    /// Lock must be honored exactly; fail when lock cannot be used.
325    LockOnly,
326}
327
328impl ResolveOptions {
329    pub fn sync() -> Self {
330        Self {
331            mode: ResolveMode::Sync,
332            staging_root: None,
333            source_overrides: indexmap::IndexMap::new(),
334        }
335    }
336
337    pub fn frozen() -> Self {
338        Self {
339            mode: ResolveMode::Frozen,
340            staging_root: None,
341            source_overrides: indexmap::IndexMap::new(),
342        }
343    }
344
345    pub fn upgrade(targets: HashSet<SourceName>, bump_direct_constraints: bool) -> Self {
346        Self {
347            mode: ResolveMode::Upgrade {
348                targets,
349                bump_direct_constraints,
350            },
351            staging_root: None,
352            source_overrides: indexmap::IndexMap::new(),
353        }
354    }
355
356    pub fn with_staging_root(mut self, staging_root: std::path::PathBuf) -> Self {
357        self.staging_root = Some(staging_root);
358        self
359    }
360
361    pub(crate) fn with_source_overrides(
362        mut self,
363        source_overrides: indexmap::IndexMap<SourceName, std::path::PathBuf>,
364    ) -> Self {
365        self.source_overrides = source_overrides;
366        self
367    }
368
369    pub(crate) fn direct_constraint_for(
370        &self,
371        source_name: &SourceName,
372        declared: VersionConstraint,
373    ) -> VersionConstraint {
374        if matches!(
375            &self.mode,
376            ResolveMode::Upgrade {
377                bump_direct_constraints: true,
378                ..
379            }
380        ) && self.is_upgrade_target(source_name)
381        {
382            VersionConstraint::Latest
383        } else {
384            declared
385        }
386    }
387
388    pub(crate) fn is_upgrade_target(&self, source_name: &SourceName) -> bool {
389        match &self.mode {
390            ResolveMode::Upgrade { targets, .. } => {
391                targets.is_empty() || targets.contains(source_name)
392            }
393            ResolveMode::Sync | ResolveMode::Frozen => false,
394        }
395    }
396
397    pub(crate) fn version_selection_policy(
398        &self,
399        source_name: &SourceName,
400    ) -> VersionSelectionPolicy {
401        match &self.mode {
402            ResolveMode::Frozen => VersionSelectionPolicy::LockOnly,
403            ResolveMode::Upgrade { .. } if self.is_upgrade_target(source_name) => {
404                VersionSelectionPolicy::LatestOnly
405            }
406            ResolveMode::Sync | ResolveMode::Upgrade { .. } => {
407                VersionSelectionPolicy::PreferLockThenLatest
408            }
409        }
410    }
411}