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