Skip to main content

mars_agents/resolve/
mod.rs

1//! Dependency resolution with semver constraints.
2//!
3//! Algorithm:
4//! 1. Fetch sources from `EffectiveConfig`
5//! 2. Read `mars.toml` manifests → discover transitive deps
6//! 3. Intersect version constraints across dependents
7//! 4. Select concrete versions (MVS: minimum version selection)
8//! 5. Topological sort (Kahn's algorithm)
9//!
10//! Uses `semver` crate for all version parsing. No custom version logic.
11
12use std::collections::{HashMap, HashSet, VecDeque};
13use std::path::Path;
14
15use indexmap::IndexMap;
16use semver::{Version, VersionReq};
17
18use crate::config::{EffectiveConfig, GitSpec, SourceSpec};
19use crate::error::{MarsError, ResolutionError};
20use crate::lock::LockFile;
21use crate::manifest::Manifest;
22use crate::source::{AvailableVersion, ResolvedRef};
23use crate::types::{SourceId, SourceName, SourceUrl};
24
25/// The resolved dependency graph — all sources with concrete versions.
26///
27/// Produced by the resolver after fetching sources, reading manifests,
28/// intersecting version constraints, and topological sorting.
29#[derive(Debug, Clone)]
30pub struct ResolvedGraph {
31    pub nodes: IndexMap<SourceName, ResolvedNode>,
32    /// Topological order (deps before dependents).
33    pub order: Vec<SourceName>,
34    pub id_index: HashMap<SourceId, SourceName>,
35}
36
37/// A single node in the resolved graph.
38#[derive(Debug, Clone)]
39pub struct ResolvedNode {
40    pub source_name: SourceName,
41    pub source_id: SourceId,
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/// 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
60/// Options controlling resolution behavior.
61#[derive(Debug, Clone, Default)]
62pub struct ResolveOptions {
63    /// If true, prefer newest version instead of minimum (for `mars upgrade`).
64    pub maximize: bool,
65    /// Source names to upgrade (empty = all, when maximize=true).
66    pub upgrade_targets: HashSet<SourceName>,
67    /// If true, locked commit replay failures become hard errors.
68    pub frozen: bool,
69}
70
71/// Lists semver-tagged versions available for a git source.
72pub trait VersionLister {
73    fn list_versions(&self, url: &SourceUrl) -> Result<Vec<AvailableVersion>, MarsError>;
74}
75
76/// Fetches concrete source trees after the resolver has picked a strategy.
77pub trait SourceFetcher {
78    /// Fetch a git source at a specific version tag.
79    fn fetch_git_version(
80        &self,
81        url: &SourceUrl,
82        version: &AvailableVersion,
83        source_name: &str,
84        preferred_commit: Option<&str>,
85    ) -> Result<ResolvedRef, MarsError>;
86
87    /// Fetch a git source at a branch/commit ref (non-semver path).
88    fn fetch_git_ref(
89        &self,
90        url: &SourceUrl,
91        ref_name: &str,
92        source_name: &str,
93        preferred_commit: Option<&str>,
94    ) -> Result<ResolvedRef, MarsError>;
95
96    /// Resolve a local path source into a concrete tree reference.
97    fn fetch_path(&self, path: &Path, source_name: &str) -> Result<ResolvedRef, MarsError>;
98}
99
100/// Reads source manifests for transitive dependency discovery.
101pub trait ManifestReader {
102    fn read_manifest(&self, source_tree: &Path) -> Result<Option<Manifest>, MarsError>;
103}
104
105/// Composite trait used by `resolve()`.
106pub trait SourceProvider: VersionLister + SourceFetcher + ManifestReader {}
107
108impl<T> SourceProvider for T where T: VersionLister + SourceFetcher + ManifestReader {}
109
110/// Parse a version string into a constraint.
111///
112/// - `None` / `"latest"` → Latest (any version, newest wins)
113/// - `"v1.2.3"` → exact match
114/// - `"v2"` → `>=2.0.0, <3.0.0` (major range)
115/// - `"v2.1"` → `>=2.1.0, <2.2.0` (minor range)
116/// - `">=0.5.0"`, `"^2.0"`, `"~1.2"` → semver requirement
117/// - anything else → branch/commit ref pin
118pub fn parse_version_constraint(version: Option<&str>) -> VersionConstraint {
119    let version = match version {
120        None => return VersionConstraint::Latest,
121        Some(v) => v.trim(),
122    };
123
124    if version.is_empty() || version.eq_ignore_ascii_case("latest") {
125        return VersionConstraint::Latest;
126    }
127
128    // Try "v"-prefixed versions: v1.2.3, v2, v2.1
129    if let Some(stripped) = version.strip_prefix('v') {
130        // Try exact semver: v1.2.3
131        if let Ok(ver) = Version::parse(stripped) {
132            let req = VersionReq::parse(&format!("={ver}")).expect("valid exact req");
133            return VersionConstraint::Semver(req);
134        }
135
136        // Try major-only: v2 → >=2.0.0, <3.0.0
137        if let Ok(major) = stripped.parse::<u64>() {
138            let req = VersionReq::parse(&format!(">={major}.0.0, <{}.0.0", major + 1))
139                .expect("valid major range req");
140            return VersionConstraint::Semver(req);
141        }
142
143        // Try major.minor: v2.1 → >=2.1.0, <2.2.0
144        let parts: Vec<&str> = stripped.split('.').collect();
145        if parts.len() == 2
146            && let (Ok(major), Ok(minor)) = (parts[0].parse::<u64>(), parts[1].parse::<u64>())
147        {
148            let req = VersionReq::parse(&format!(">={major}.{minor}.0, <{major}.{}.0", minor + 1))
149                .expect("valid minor range req");
150            return VersionConstraint::Semver(req);
151        }
152    }
153
154    // Try as semver requirement directly (>=0.5.0, ^2.0, ~1.2, =1.0.0, etc.)
155    if let Ok(req) = VersionReq::parse(version) {
156        return VersionConstraint::Semver(req);
157    }
158
159    // Otherwise it's a branch or commit ref pin
160    VersionConstraint::RefPin(version.to_string())
161}
162
163/// Resolve the full dependency graph from config.
164///
165/// Uses Minimum Version Selection (MVS) by default: selects the lowest
166/// version satisfying all constraints. This is conservative and reproducible —
167/// the same constraint always resolves to the same version. Users who want
168/// the latest use `@latest` explicitly, or `mars upgrade`.
169///
170/// When `locked` is provided, prefer locked versions when constraints allow
171/// (reproducible builds).
172pub fn resolve(
173    config: &EffectiveConfig,
174    provider: &dyn SourceProvider,
175    locked: Option<&LockFile>,
176    options: &ResolveOptions,
177) -> Result<ResolvedGraph, MarsError> {
178    let mut nodes: IndexMap<SourceName, ResolvedNode> = IndexMap::new();
179    let mut id_index: HashMap<SourceId, SourceName> = HashMap::new();
180
181    // Pending sources to process: (name, url_or_path, version_constraint, required_by)
182    let mut pending: VecDeque<PendingSource> = VecDeque::new();
183
184    // Track constraints per source name for intersection
185    let mut constraints: HashMap<SourceName, Vec<(String, VersionConstraint)>> = HashMap::new();
186
187    // Seed with direct dependencies from config
188    for (name, source) in &config.sources {
189        let constraint = match &source.spec {
190            SourceSpec::Git(git) => parse_version_constraint(git.version.as_deref()),
191            SourceSpec::Path(_) => VersionConstraint::Latest, // Path sources: no version
192        };
193        pending.push_back(PendingSource {
194            name: name.clone(),
195            source_id: source.id.clone(),
196            spec: source.spec.clone(),
197            constraint,
198            required_by: "mars.toml".into(),
199        });
200    }
201
202    // BFS: resolve each source, discover transitive deps
203    while let Some(pending_src) = pending.pop_front() {
204        if let Some(existing_name) = id_index.get(&pending_src.source_id)
205            && existing_name != &pending_src.name
206        {
207            return Err(ResolutionError::DuplicateSourceIdentity {
208                existing_name: existing_name.to_string(),
209                duplicate_name: pending_src.name.to_string(),
210                source_id: pending_src.source_id.to_string(),
211            }
212            .into());
213        }
214
215        // If already resolved, just record the additional constraint
216        if let Some(existing) = nodes.get(&pending_src.name) {
217            if existing.source_id != pending_src.source_id {
218                return Err(ResolutionError::SourceIdentityMismatch {
219                    name: pending_src.name.to_string(),
220                    existing: existing.source_id.to_string(),
221                    incoming: pending_src.source_id.to_string(),
222                }
223                .into());
224            }
225            constraints
226                .entry(pending_src.name.clone())
227                .or_default()
228                .push((pending_src.required_by.clone(), pending_src.constraint));
229            continue;
230        }
231
232        // Record constraint
233        constraints
234            .entry(pending_src.name.clone())
235            .or_default()
236            .push((
237                pending_src.required_by.clone(),
238                pending_src.constraint.clone(),
239            ));
240
241        // Resolve and fetch the source
242        let resolved_ref =
243            resolve_single_source(&pending_src, provider, locked, options, &constraints)?;
244
245        // Read manifest for transitive deps
246        let manifest = provider.read_manifest(&resolved_ref.tree_path)?;
247
248        // Discover transitive dependencies
249        let mut deps = Vec::new();
250        if let Some(ref manifest) = manifest {
251            for (dep_name, dep_spec) in &manifest.dependencies {
252                deps.push(SourceName::from(dep_name.clone()));
253
254                // Only add as pending if not already resolved
255                if !nodes.contains_key(dep_name.as_str()) {
256                    let dep_constraint = parse_version_constraint(dep_spec.version.as_deref());
257                    let dep_name_typed = SourceName::from(dep_name.clone());
258                    pending.push_back(PendingSource {
259                        name: dep_name_typed,
260                        source_id: SourceId::git(dep_spec.url.clone()),
261                        spec: SourceSpec::Git(GitSpec {
262                            url: dep_spec.url.clone(),
263                            version: dep_spec.version.clone(),
264                        }),
265                        constraint: dep_constraint,
266                        required_by: pending_src.name.to_string(),
267                    });
268                } else {
269                    // Already resolved — record additional constraint for later validation
270                    let dep_constraint = parse_version_constraint(dep_spec.version.as_deref());
271                    constraints
272                        .entry(SourceName::from(dep_name.clone()))
273                        .or_default()
274                        .push((pending_src.name.to_string(), dep_constraint));
275                }
276            }
277        }
278
279        nodes.insert(
280            pending_src.name.clone(),
281            ResolvedNode {
282                source_name: pending_src.name.clone(),
283                source_id: pending_src.source_id.clone(),
284                resolved_ref,
285                manifest,
286                deps,
287            },
288        );
289        id_index.insert(pending_src.source_id, pending_src.name);
290    }
291
292    // Validate that all constraints are satisfied by resolved versions
293    validate_all_constraints(&nodes, &constraints)?;
294
295    // Topological sort
296    let order = topological_sort(&nodes)?;
297
298    Ok(ResolvedGraph {
299        nodes,
300        order,
301        id_index,
302    })
303}
304
305/// Internal: a source waiting to be resolved.
306struct PendingSource {
307    name: SourceName,
308    source_id: SourceId,
309    spec: SourceSpec,
310    constraint: VersionConstraint,
311    required_by: String,
312}
313
314/// Resolve a single source to a concrete version/ref.
315fn resolve_single_source(
316    pending: &PendingSource,
317    provider: &dyn SourceProvider,
318    locked: Option<&LockFile>,
319    options: &ResolveOptions,
320    constraints: &HashMap<SourceName, Vec<(String, VersionConstraint)>>,
321) -> Result<ResolvedRef, MarsError> {
322    match &pending.spec {
323        SourceSpec::Path(path) => {
324            // Path sources: no version resolution, just use the path
325            provider.fetch_path(path, pending.name.as_ref())
326        }
327        SourceSpec::Git(git) => resolve_git_source(
328            &pending.name,
329            &git.url,
330            constraints
331                .get(&pending.name)
332                .map(|c| c.as_slice())
333                .unwrap_or(&[]),
334            provider,
335            locked,
336            options,
337        ),
338    }
339}
340
341/// Resolve a git source: list versions, intersect constraints, select version.
342fn resolve_git_source(
343    name: &SourceName,
344    url: &SourceUrl,
345    constraints: &[(String, VersionConstraint)],
346    provider: &dyn SourceProvider,
347    locked: Option<&LockFile>,
348    options: &ResolveOptions,
349) -> Result<ResolvedRef, MarsError> {
350    // If all constraints are ref pins, use the first one
351    // (multiple ref pins for the same source is likely an error, but we'll use first)
352    let has_ref_pin = constraints
353        .iter()
354        .any(|(_, c)| matches!(c, VersionConstraint::RefPin(_)));
355    if has_ref_pin {
356        for (_, constraint) in constraints {
357            if let VersionConstraint::RefPin(ref_name) = constraint {
358                return provider.fetch_git_ref(url, ref_name, name.as_ref(), None);
359            }
360        }
361    }
362
363    // Check if any constraint is "Latest" — if so, pick newest (not MVS)
364    let has_latest = constraints
365        .iter()
366        .any(|(_, c)| matches!(c, VersionConstraint::Latest));
367
368    let locked_source = locked.and_then(|lf| lf.sources.get(name));
369    let locked_commit = locked_source.and_then(|ls| ls.commit.as_deref());
370
371    let upgrade_maximize = options.maximize
372        && (options.upgrade_targets.is_empty() || options.upgrade_targets.contains(name));
373
374    // Determine whether to maximize this source:
375    // - explicit maximize mode (mars upgrade)
376    // - "latest" constraint means "newest available"
377    let maximize = has_latest || upgrade_maximize;
378
379    // List available versions
380    let available = provider.list_versions(url)?;
381
382    if available.is_empty() {
383        // No semver tags → treat as "latest commit", with locked-commit replay.
384        // For untagged sources, replay lock by default unless explicitly upgrading.
385        let preferred_commit = if !upgrade_maximize {
386            locked_commit
387        } else {
388            None
389        };
390        match provider.fetch_git_ref(url, "HEAD", name.as_ref(), preferred_commit) {
391            Ok(resolved) => return Ok(resolved),
392            Err(err @ MarsError::LockedCommitUnreachable { .. }) if options.frozen => {
393                return Err(err);
394            }
395            Err(MarsError::LockedCommitUnreachable {
396                commit,
397                url: source_url,
398            }) => {
399                eprintln!(
400                    "warning: locked commit {commit} for {source_url} is unreachable; re-resolving from HEAD"
401                );
402                return provider.fetch_git_ref(url, "HEAD", name.as_ref(), None);
403            }
404            Err(err) => return Err(err),
405        }
406    }
407
408    // Collect all semver constraints
409    let semver_reqs: Vec<(&str, &VersionReq)> = constraints
410        .iter()
411        .filter_map(|(requester, c)| match c {
412            VersionConstraint::Semver(req) => Some((requester.as_str(), req)),
413            _ => None,
414        })
415        .collect();
416
417    // Get locked version for this source (if any)
418    let locked_version = locked_source
419        .and_then(|ls| ls.version.as_ref())
420        .and_then(|v| {
421            let v = v.strip_prefix('v').unwrap_or(v);
422            Version::parse(v).ok()
423        });
424
425    // Select version
426    let selected = select_version(
427        name,
428        &available,
429        &semver_reqs,
430        locked_version.as_ref(),
431        maximize,
432    )?;
433
434    let should_try_locked_commit = !maximize
435        && locked_commit.is_some()
436        && match locked_version.as_ref() {
437            Some(version) => selected.version == *version,
438            None => true,
439        };
440
441    let preferred_commit = if should_try_locked_commit {
442        locked_commit
443    } else {
444        None
445    };
446
447    match provider.fetch_git_version(url, selected, name.as_ref(), preferred_commit) {
448        Ok(resolved) => Ok(resolved),
449        Err(err @ MarsError::LockedCommitUnreachable { .. }) if options.frozen => Err(err),
450        Err(MarsError::LockedCommitUnreachable {
451            commit,
452            url: source_url,
453        }) => {
454            eprintln!(
455                "warning: locked commit {commit} for {source_url} is unreachable; re-resolving from tag"
456            );
457            provider.fetch_git_version(url, selected, name.as_ref(), None)
458        }
459        Err(err) => Err(err),
460    }
461}
462
463/// Select a concrete version from available versions, respecting constraints.
464///
465/// - MVS (default): pick the minimum version satisfying all constraints.
466/// - Maximize mode: pick the newest version satisfying all constraints.
467/// - Locked version preference: if a locked version satisfies all constraints, use it.
468fn select_version<'a>(
469    source_name: &SourceName,
470    available: &'a [AvailableVersion],
471    constraints: &[(&str, &VersionReq)],
472    locked: Option<&Version>,
473    maximize: bool,
474) -> Result<&'a AvailableVersion, MarsError> {
475    // Find all versions satisfying all constraints
476    let satisfying: Vec<&AvailableVersion> = available
477        .iter()
478        .filter(|av| {
479            if constraints.is_empty() {
480                return true;
481            }
482            constraints.iter().all(|(_, req)| req.matches(&av.version))
483        })
484        .collect();
485
486    if satisfying.is_empty() {
487        // Build helpful error message listing all constraints
488        let constraint_desc: Vec<String> = constraints
489            .iter()
490            .map(|(requester, req)| format!("  `{requester}` requires {req}"))
491            .collect();
492
493        let available_desc: Vec<String> =
494            available.iter().map(|av| av.version.to_string()).collect();
495
496        return Err(ResolutionError::VersionConflict {
497            name: source_name.to_string(),
498            message: format!(
499                "no version satisfies all constraints:\n{}\navailable versions: [{}]",
500                constraint_desc.join("\n"),
501                available_desc.join(", ")
502            ),
503        }
504        .into());
505    }
506
507    // If we have a locked version and it satisfies constraints, prefer it
508    if !maximize
509        && let Some(locked_ver) = locked
510        && let Some(av) = satisfying.iter().find(|av| av.version == *locked_ver)
511    {
512        return Ok(av);
513    }
514
515    // MVS: pick minimum. Maximize: pick maximum.
516    // Available versions from list_versions are sorted ascending by semver.
517    if maximize {
518        Ok(satisfying.last().expect("satisfying is non-empty"))
519    } else {
520        Ok(satisfying.first().expect("satisfying is non-empty"))
521    }
522}
523
524/// Validate that all constraints are satisfied by the resolved versions.
525///
526/// This catches cases where a source was resolved before all constraints
527/// were known (e.g., a later transitive dep adds a new constraint on an
528/// already-resolved source).
529fn validate_all_constraints(
530    nodes: &IndexMap<SourceName, ResolvedNode>,
531    constraints: &HashMap<SourceName, Vec<(String, VersionConstraint)>>,
532) -> Result<(), MarsError> {
533    for (name, constraint_list) in constraints {
534        let node = match nodes.get(name) {
535            Some(n) => n,
536            None => continue, // Should not happen, but be safe
537        };
538
539        // Only validate semver constraints against resolved versions
540        if let Some(ref resolved_ver) = node.resolved_ref.version {
541            for (requester, constraint) in constraint_list {
542                if let VersionConstraint::Semver(req) = constraint
543                    && !req.matches(resolved_ver)
544                {
545                    return Err(ResolutionError::VersionConflict {
546                        name: name.to_string(),
547                        message: format!(
548                            "resolved version {resolved_ver} does not satisfy \
549                             constraint {req} (required by `{requester}`)"
550                        ),
551                    }
552                    .into());
553                }
554            }
555        }
556    }
557    Ok(())
558}
559
560/// Topological sort using Kahn's algorithm (BFS-based).
561///
562/// Returns source names in dependency order (deps before dependents).
563/// Errors if a cycle is detected.
564fn topological_sort(
565    nodes: &IndexMap<SourceName, ResolvedNode>,
566) -> Result<Vec<SourceName>, MarsError> {
567    // Build in-degree map
568    let mut in_degree: HashMap<SourceName, usize> = HashMap::new();
569    let mut adjacency: HashMap<SourceName, Vec<SourceName>> = HashMap::new();
570
571    for (name, _) in nodes {
572        in_degree.entry(name.clone()).or_insert(0);
573        adjacency.entry(name.clone()).or_default();
574    }
575
576    for (name, node) in nodes {
577        for dep in &node.deps {
578            if nodes.contains_key(dep) {
579                adjacency.entry(name.clone()).or_default();
580                *in_degree.entry(dep.clone()).or_insert(0) += 0; // ensure dep exists
581                // dep → name edge means name depends on dep
582                // In Kahn's: in_degree[name] += 1 (name has an incoming dep edge)
583                *in_degree.entry(name.clone()).or_insert(0) += 1;
584                adjacency.entry(dep.clone()).or_default().push(name.clone());
585            }
586        }
587    }
588
589    // Start with nodes that have no dependencies (in_degree == 0)
590    let mut queue: VecDeque<SourceName> = VecDeque::new();
591    for (name, &degree) in &in_degree {
592        if degree == 0 {
593            queue.push_back(name.clone());
594        }
595    }
596
597    // Sort the initial queue for deterministic output
598    let mut sorted_queue: Vec<SourceName> = queue.drain(..).collect();
599    sorted_queue.sort();
600    queue.extend(sorted_queue);
601
602    let mut order: Vec<SourceName> = Vec::new();
603
604    while let Some(current) = queue.pop_front() {
605        order.push(current.clone());
606
607        // Collect and sort dependents for determinism
608        if let Some(dependents) = adjacency.get(&current) {
609            let mut sorted_dependents: Vec<SourceName> = dependents.clone();
610            sorted_dependents.sort();
611
612            for dependent in sorted_dependents {
613                if let Some(degree) = in_degree.get_mut(&dependent) {
614                    *degree -= 1;
615                    if *degree == 0 {
616                        queue.push_back(dependent);
617                    }
618                }
619            }
620        }
621    }
622
623    // If we haven't visited all nodes, there's a cycle
624    if order.len() != nodes.len() {
625        let unvisited: Vec<&str> = nodes
626            .keys()
627            .filter(|name| !order.contains(name))
628            .map(|s| s.as_str())
629            .collect();
630        let chain = unvisited.join(" → ");
631        return Err(ResolutionError::Cycle { chain }.into());
632    }
633
634    Ok(order)
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use crate::config::{
641        EffectiveConfig, EffectiveSource, FilterMode, GitSpec, Settings, SourceSpec,
642    };
643    use crate::manifest::{DepSpec, Manifest, PackageInfo};
644    use crate::types::{RenameMap, SourceId, SourceUrl};
645    use indexmap::IndexMap;
646    use std::cell::RefCell;
647    use std::collections::{HashMap, HashSet};
648    use std::path::PathBuf;
649    use tempfile::TempDir;
650
651    // ========== Mock SourceProvider ==========
652
653    /// Mock provider for testing the resolver without real git repos.
654    struct MockProvider {
655        /// url → sorted available versions
656        versions: HashMap<String, Vec<AvailableVersion>>,
657        /// source tree paths keyed by source name (pre-created temp dirs)
658        trees: HashMap<String, PathBuf>,
659        /// Manifests to return for specific source trees
660        manifests: HashMap<PathBuf, Option<Manifest>>,
661        /// Preferred commits that should simulate an unreachable lock replay.
662        unreachable_preferred_commits: HashSet<String>,
663        /// Captures preferred-commit hints passed by the resolver.
664        seen_preferred_commits: RefCell<Vec<Option<String>>>,
665    }
666
667    impl MockProvider {
668        fn new() -> Self {
669            MockProvider {
670                versions: HashMap::new(),
671                trees: HashMap::new(),
672                manifests: HashMap::new(),
673                unreachable_preferred_commits: HashSet::new(),
674                seen_preferred_commits: RefCell::new(Vec::new()),
675            }
676        }
677
678        /// Register available versions for a URL.
679        fn add_versions(&mut self, url: &str, versions: Vec<(u64, u64, u64)>) {
680            let avs: Vec<AvailableVersion> = versions
681                .into_iter()
682                .map(|(major, minor, patch)| AvailableVersion {
683                    tag: format!("v{major}.{minor}.{patch}"),
684                    version: Version::new(major, minor, patch),
685                    commit_id: "0000000000000000000000000000000000000000".to_string(),
686                })
687                .collect();
688            self.versions.insert(url.to_string(), avs);
689        }
690
691        /// Register a source tree for a source name, with optional manifest.
692        fn add_source(&mut self, name: &str, tree_path: PathBuf, manifest: Option<Manifest>) {
693            if let Some(ref m) = manifest {
694                self.manifests.insert(tree_path.clone(), Some(m.clone()));
695            } else {
696                self.manifests.insert(tree_path.clone(), None);
697            }
698            self.trees.insert(name.to_string(), tree_path);
699        }
700
701        fn mark_unreachable_preferred_commit(&mut self, commit: &str) {
702            self.unreachable_preferred_commits
703                .insert(commit.to_string());
704        }
705
706        fn seen_preferred_commits(&self) -> Vec<Option<String>> {
707            self.seen_preferred_commits.borrow().clone()
708        }
709    }
710
711    impl VersionLister for MockProvider {
712        fn list_versions(&self, url: &SourceUrl) -> Result<Vec<AvailableVersion>, MarsError> {
713            Ok(self.versions.get(url.as_ref()).cloned().unwrap_or_default())
714        }
715    }
716
717    impl SourceFetcher for MockProvider {
718        fn fetch_git_version(
719            &self,
720            url: &SourceUrl,
721            version: &AvailableVersion,
722            source_name: &str,
723            preferred_commit: Option<&str>,
724        ) -> Result<ResolvedRef, MarsError> {
725            self.seen_preferred_commits
726                .borrow_mut()
727                .push(preferred_commit.map(str::to_string));
728
729            if let Some(commit) = preferred_commit
730                && self.unreachable_preferred_commits.contains(commit)
731            {
732                return Err(MarsError::LockedCommitUnreachable {
733                    commit: commit.to_string(),
734                    url: url.to_string(),
735                });
736            }
737
738            let tree_path = self.trees.get(source_name).cloned().unwrap_or_default();
739            Ok(ResolvedRef {
740                source_name: source_name.into(),
741                version: Some(version.version.clone()),
742                version_tag: Some(version.tag.clone()),
743                commit: Some(
744                    preferred_commit
745                        .map(|c| c.into())
746                        .unwrap_or_else(|| "mock-commit".into()),
747                ),
748                tree_path,
749            })
750        }
751
752        fn fetch_git_ref(
753            &self,
754            url: &SourceUrl,
755            ref_name: &str,
756            source_name: &str,
757            preferred_commit: Option<&str>,
758        ) -> Result<ResolvedRef, MarsError> {
759            self.seen_preferred_commits
760                .borrow_mut()
761                .push(preferred_commit.map(str::to_string));
762
763            if let Some(commit) = preferred_commit
764                && self.unreachable_preferred_commits.contains(commit)
765            {
766                return Err(MarsError::LockedCommitUnreachable {
767                    commit: commit.to_string(),
768                    url: url.to_string(),
769                });
770            }
771
772            let tree_path = self.trees.get(source_name).cloned().unwrap_or_default();
773            Ok(ResolvedRef {
774                source_name: source_name.into(),
775                version: None,
776                version_tag: None,
777                commit: Some(
778                    preferred_commit
779                        .map(|c| c.into())
780                        .unwrap_or_else(|| format!("ref:{ref_name}").into()),
781                ),
782                tree_path,
783            })
784        }
785
786        fn fetch_path(&self, path: &Path, source_name: &str) -> Result<ResolvedRef, MarsError> {
787            Ok(ResolvedRef {
788                source_name: source_name.into(),
789                version: None,
790                version_tag: None,
791                commit: None,
792                tree_path: path.to_path_buf(),
793            })
794        }
795    }
796
797    impl ManifestReader for MockProvider {
798        fn read_manifest(&self, source_tree: &Path) -> Result<Option<Manifest>, MarsError> {
799            Ok(self.manifests.get(source_tree).cloned().unwrap_or(None))
800        }
801    }
802
803    // ========== Helper functions ==========
804
805    fn make_config(sources: Vec<(&str, SourceSpec)>) -> EffectiveConfig {
806        let mut map = IndexMap::new();
807        for (name, spec) in sources {
808            map.insert(
809                name.into(),
810                EffectiveSource {
811                    name: name.into(),
812                    id: source_id_for_spec(&spec),
813                    spec,
814                    filter: FilterMode::All,
815                    rename: RenameMap::new(),
816                    is_overridden: false,
817                    original_git: None,
818                },
819            );
820        }
821        EffectiveConfig {
822            sources: map,
823            settings: Settings::default(),
824        }
825    }
826
827    fn git_spec(url: &str, version: Option<&str>) -> SourceSpec {
828        SourceSpec::Git(GitSpec {
829            url: SourceUrl::from(url),
830            version: version.map(|s| s.to_string()),
831        })
832    }
833
834    fn make_manifest(name: &str, version: &str, deps: Vec<(&str, &str, &str)>) -> Manifest {
835        let mut dependencies = IndexMap::new();
836        for (dep_name, dep_url, dep_ver) in deps {
837            dependencies.insert(
838                dep_name.to_string(),
839                DepSpec {
840                    url: SourceUrl::from(dep_url),
841                    version: Some(dep_ver.to_string()),
842                    agents: vec![],
843                    skills: vec![],
844                },
845            );
846        }
847        Manifest {
848            package: PackageInfo {
849                name: name.to_string(),
850                version: version.to_string(),
851                description: None,
852            },
853            dependencies,
854        }
855    }
856
857    fn default_options() -> ResolveOptions {
858        ResolveOptions::default()
859    }
860
861    fn source_id_for_spec(spec: &SourceSpec) -> SourceId {
862        match spec {
863            SourceSpec::Git(g) => SourceId::git(g.url.clone()),
864            SourceSpec::Path(path) => SourceId::Path {
865                canonical: path.clone(),
866            },
867        }
868    }
869
870    // ========== parse_version_constraint tests ==========
871
872    #[test]
873    fn parse_none_is_latest() {
874        assert!(matches!(
875            parse_version_constraint(None),
876            VersionConstraint::Latest
877        ));
878    }
879
880    #[test]
881    fn parse_empty_is_latest() {
882        assert!(matches!(
883            parse_version_constraint(Some("")),
884            VersionConstraint::Latest
885        ));
886    }
887
888    #[test]
889    fn parse_latest_string() {
890        assert!(matches!(
891            parse_version_constraint(Some("latest")),
892            VersionConstraint::Latest
893        ));
894        assert!(matches!(
895            parse_version_constraint(Some("LATEST")),
896            VersionConstraint::Latest
897        ));
898    }
899
900    #[test]
901    fn parse_exact_version() {
902        match parse_version_constraint(Some("v1.2.3")) {
903            VersionConstraint::Semver(req) => {
904                assert!(req.matches(&Version::new(1, 2, 3)));
905                assert!(!req.matches(&Version::new(1, 2, 4)));
906            }
907            other => panic!("expected Semver, got {other:?}"),
908        }
909    }
910
911    #[test]
912    fn parse_major_version() {
913        match parse_version_constraint(Some("v2")) {
914            VersionConstraint::Semver(req) => {
915                assert!(req.matches(&Version::new(2, 0, 0)));
916                assert!(req.matches(&Version::new(2, 5, 3)));
917                assert!(!req.matches(&Version::new(1, 9, 9)));
918                assert!(!req.matches(&Version::new(3, 0, 0)));
919            }
920            other => panic!("expected Semver, got {other:?}"),
921        }
922    }
923
924    #[test]
925    fn parse_major_minor_version() {
926        match parse_version_constraint(Some("v2.1")) {
927            VersionConstraint::Semver(req) => {
928                assert!(req.matches(&Version::new(2, 1, 0)));
929                assert!(req.matches(&Version::new(2, 1, 5)));
930                assert!(!req.matches(&Version::new(2, 0, 9)));
931                assert!(!req.matches(&Version::new(2, 2, 0)));
932            }
933            other => panic!("expected Semver, got {other:?}"),
934        }
935    }
936
937    #[test]
938    fn parse_semver_req_gte() {
939        match parse_version_constraint(Some(">=0.5.0")) {
940            VersionConstraint::Semver(req) => {
941                assert!(req.matches(&Version::new(0, 5, 0)));
942                assert!(req.matches(&Version::new(1, 0, 0)));
943                assert!(!req.matches(&Version::new(0, 4, 9)));
944            }
945            other => panic!("expected Semver, got {other:?}"),
946        }
947    }
948
949    #[test]
950    fn parse_semver_req_caret() {
951        match parse_version_constraint(Some("^2.0")) {
952            VersionConstraint::Semver(req) => {
953                assert!(req.matches(&Version::new(2, 0, 0)));
954                assert!(req.matches(&Version::new(2, 9, 0)));
955                assert!(!req.matches(&Version::new(3, 0, 0)));
956            }
957            other => panic!("expected Semver, got {other:?}"),
958        }
959    }
960
961    #[test]
962    fn parse_semver_req_tilde() {
963        match parse_version_constraint(Some("~1.2")) {
964            VersionConstraint::Semver(req) => {
965                assert!(req.matches(&Version::new(1, 2, 0)));
966                assert!(req.matches(&Version::new(1, 2, 9)));
967                assert!(!req.matches(&Version::new(1, 3, 0)));
968            }
969            other => panic!("expected Semver, got {other:?}"),
970        }
971    }
972
973    #[test]
974    fn parse_branch_ref() {
975        match parse_version_constraint(Some("main")) {
976            VersionConstraint::RefPin(ref_name) => {
977                assert_eq!(ref_name, "main");
978            }
979            other => panic!("expected RefPin, got {other:?}"),
980        }
981    }
982
983    #[test]
984    fn parse_commit_ref() {
985        match parse_version_constraint(Some("abc123def456")) {
986            VersionConstraint::RefPin(ref_name) => {
987                assert_eq!(ref_name, "abc123def456");
988            }
989            other => panic!("expected RefPin, got {other:?}"),
990        }
991    }
992
993    // ========== Resolution tests ==========
994
995    #[test]
996    fn single_source_no_deps() {
997        let dir = TempDir::new().unwrap();
998        let tree = dir.path().join("source-a");
999        std::fs::create_dir_all(&tree).unwrap();
1000
1001        let mut provider = MockProvider::new();
1002        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0), (1, 1, 0)]);
1003        provider.add_source("a", tree, None);
1004
1005        let config = make_config(vec![(
1006            "a",
1007            git_spec("https://example.com/a.git", Some("^1.0")),
1008        )]);
1009
1010        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1011
1012        assert_eq!(graph.nodes.len(), 1);
1013        assert!(graph.nodes.contains_key("a"));
1014        assert_eq!(graph.order.len(), 1);
1015        assert_eq!(graph.order[0], "a");
1016
1017        // MVS: should pick 1.0.0 (minimum)
1018        let node = &graph.nodes["a"];
1019        assert_eq!(node.resolved_ref.version, Some(Version::new(1, 0, 0)));
1020    }
1021
1022    #[test]
1023    fn two_sources_no_deps() {
1024        let dir = TempDir::new().unwrap();
1025        let tree_a = dir.path().join("a");
1026        let tree_b = dir.path().join("b");
1027        std::fs::create_dir_all(&tree_a).unwrap();
1028        std::fs::create_dir_all(&tree_b).unwrap();
1029
1030        let mut provider = MockProvider::new();
1031        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1032        provider.add_versions("https://example.com/b.git", vec![(2, 0, 0)]);
1033        provider.add_source("a", tree_a, None);
1034        provider.add_source("b", tree_b, None);
1035
1036        let config = make_config(vec![
1037            ("a", git_spec("https://example.com/a.git", Some("v1.0.0"))),
1038            ("b", git_spec("https://example.com/b.git", Some("v2.0.0"))),
1039        ]);
1040
1041        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1042
1043        assert_eq!(graph.nodes.len(), 2);
1044        assert_eq!(graph.order.len(), 2);
1045        // Both should be in the order (either order is valid since no deps)
1046        assert!(graph.order.contains(&"a".into()));
1047        assert!(graph.order.contains(&"b".into()));
1048    }
1049
1050    #[test]
1051    fn source_with_transitive_dep() {
1052        let dir = TempDir::new().unwrap();
1053        let tree_a = dir.path().join("a");
1054        let tree_dep = dir.path().join("dep");
1055        std::fs::create_dir_all(&tree_a).unwrap();
1056        std::fs::create_dir_all(&tree_dep).unwrap();
1057
1058        let manifest_a = make_manifest(
1059            "a",
1060            "1.0.0",
1061            vec![("dep", "https://example.com/dep.git", ">=0.5.0")],
1062        );
1063
1064        let mut provider = MockProvider::new();
1065        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1066        provider.add_versions(
1067            "https://example.com/dep.git",
1068            vec![(0, 4, 0), (0, 5, 0), (0, 6, 0), (1, 0, 0)],
1069        );
1070        provider.add_source("a", tree_a, Some(manifest_a));
1071        provider.add_source("dep", tree_dep, None);
1072
1073        let config = make_config(vec![(
1074            "a",
1075            git_spec("https://example.com/a.git", Some("v1.0.0")),
1076        )]);
1077
1078        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1079
1080        // Should have both 'a' and 'dep'
1081        assert_eq!(graph.nodes.len(), 2);
1082        assert!(graph.nodes.contains_key("a"));
1083        assert!(graph.nodes.contains_key("dep"));
1084
1085        // Dep should be resolved to minimum satisfying >=0.5.0 → 0.5.0
1086        let dep_node = &graph.nodes["dep"];
1087        assert_eq!(dep_node.resolved_ref.version, Some(Version::new(0, 5, 0)));
1088
1089        // Topological order: dep before a
1090        let dep_pos = graph.order.iter().position(|n| n == "dep").unwrap();
1091        let a_pos = graph.order.iter().position(|n| n == "a").unwrap();
1092        assert!(dep_pos < a_pos, "dep should come before a in topo order");
1093    }
1094
1095    #[test]
1096    fn compatible_constraints_from_two_dependents() {
1097        let dir = TempDir::new().unwrap();
1098        let tree_a = dir.path().join("a");
1099        let tree_b = dir.path().join("b");
1100        let tree_shared = dir.path().join("shared");
1101        std::fs::create_dir_all(&tree_a).unwrap();
1102        std::fs::create_dir_all(&tree_b).unwrap();
1103        std::fs::create_dir_all(&tree_shared).unwrap();
1104
1105        // Both a and b depend on shared with the same constraint.
1106        // The resolved version must satisfy both.
1107        let manifest_a = make_manifest(
1108            "a",
1109            "1.0.0",
1110            vec![("shared", "https://example.com/shared.git", ">=1.0.0")],
1111        );
1112        let manifest_b = make_manifest(
1113            "b",
1114            "1.0.0",
1115            vec![("shared", "https://example.com/shared.git", ">=1.0.0")],
1116        );
1117
1118        let mut provider = MockProvider::new();
1119        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1120        provider.add_versions("https://example.com/b.git", vec![(1, 0, 0)]);
1121        provider.add_versions(
1122            "https://example.com/shared.git",
1123            vec![(1, 0, 0), (1, 2, 0), (1, 5, 0), (2, 0, 0)],
1124        );
1125        provider.add_source("a", tree_a, Some(manifest_a));
1126        provider.add_source("b", tree_b, Some(manifest_b));
1127        provider.add_source("shared", tree_shared, None);
1128
1129        let config = make_config(vec![
1130            ("a", git_spec("https://example.com/a.git", Some("v1.0.0"))),
1131            ("b", git_spec("https://example.com/b.git", Some("v1.0.0"))),
1132        ]);
1133
1134        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1135
1136        assert_eq!(graph.nodes.len(), 3);
1137        // MVS with >=1.0.0 from both → picks 1.0.0 (minimum satisfying all)
1138        let shared_node = &graph.nodes["shared"];
1139        assert_eq!(
1140            shared_node.resolved_ref.version,
1141            Some(Version::new(1, 0, 0))
1142        );
1143    }
1144
1145    #[test]
1146    fn narrower_second_constraint_causes_validation_error() {
1147        let dir = TempDir::new().unwrap();
1148        let tree_a = dir.path().join("a");
1149        let tree_b = dir.path().join("b");
1150        let tree_shared = dir.path().join("shared");
1151        std::fs::create_dir_all(&tree_a).unwrap();
1152        std::fs::create_dir_all(&tree_b).unwrap();
1153        std::fs::create_dir_all(&tree_shared).unwrap();
1154
1155        // a requires shared >=1.0.0, b requires shared >=1.5.0
1156        // First resolver picks 1.0.0 (MVS), then validation catches >=1.5.0 failure
1157        let manifest_a = make_manifest(
1158            "a",
1159            "1.0.0",
1160            vec![("shared", "https://example.com/shared.git", ">=1.0.0")],
1161        );
1162        let manifest_b = make_manifest(
1163            "b",
1164            "1.0.0",
1165            vec![("shared", "https://example.com/shared.git", ">=1.5.0")],
1166        );
1167
1168        let mut provider = MockProvider::new();
1169        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1170        provider.add_versions("https://example.com/b.git", vec![(1, 0, 0)]);
1171        provider.add_versions(
1172            "https://example.com/shared.git",
1173            vec![(1, 0, 0), (1, 2, 0), (1, 5, 0), (2, 0, 0)],
1174        );
1175        provider.add_source("a", tree_a, Some(manifest_a));
1176        provider.add_source("b", tree_b, Some(manifest_b));
1177        provider.add_source("shared", tree_shared, None);
1178
1179        let config = make_config(vec![
1180            ("a", git_spec("https://example.com/a.git", Some("v1.0.0"))),
1181            ("b", git_spec("https://example.com/b.git", Some("v1.0.0"))),
1182        ]);
1183
1184        // This should fail because MVS picked 1.0.0 but b needs >=1.5.0
1185        let result = resolve(&config, &provider, None, &default_options());
1186        assert!(result.is_err());
1187        let err = result.unwrap_err().to_string();
1188        assert!(
1189            err.contains("shared"),
1190            "error should mention 'shared': {err}"
1191        );
1192        assert!(
1193            err.contains("1.5.0"),
1194            "error should mention the constraint: {err}"
1195        );
1196    }
1197
1198    #[test]
1199    fn incompatible_constraints_produce_error() {
1200        let dir = TempDir::new().unwrap();
1201        let tree_a = dir.path().join("a");
1202        let tree_b = dir.path().join("b");
1203        let tree_shared = dir.path().join("shared");
1204        std::fs::create_dir_all(&tree_a).unwrap();
1205        std::fs::create_dir_all(&tree_b).unwrap();
1206        std::fs::create_dir_all(&tree_shared).unwrap();
1207
1208        // a requires shared >=2.0.0, b requires shared <1.0.0 — incompatible
1209        let manifest_a = make_manifest(
1210            "a",
1211            "1.0.0",
1212            vec![("shared", "https://example.com/shared.git", ">=2.0.0")],
1213        );
1214        let manifest_b = make_manifest(
1215            "b",
1216            "1.0.0",
1217            vec![("shared", "https://example.com/shared.git", "<1.0.0")],
1218        );
1219
1220        let mut provider = MockProvider::new();
1221        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1222        provider.add_versions("https://example.com/b.git", vec![(1, 0, 0)]);
1223        provider.add_versions(
1224            "https://example.com/shared.git",
1225            vec![(0, 5, 0), (1, 0, 0), (2, 0, 0)],
1226        );
1227        provider.add_source("a", tree_a, Some(manifest_a));
1228        provider.add_source("b", tree_b, Some(manifest_b));
1229        provider.add_source("shared", tree_shared, None);
1230
1231        let config = make_config(vec![
1232            ("a", git_spec("https://example.com/a.git", Some("v1.0.0"))),
1233            ("b", git_spec("https://example.com/b.git", Some("v1.0.0"))),
1234        ]);
1235
1236        let result = resolve(&config, &provider, None, &default_options());
1237        assert!(result.is_err());
1238        let err = result.unwrap_err().to_string();
1239        assert!(
1240            err.contains("shared"),
1241            "error should mention the conflicting source: {err}"
1242        );
1243    }
1244
1245    #[test]
1246    fn cycle_detected() {
1247        let dir = TempDir::new().unwrap();
1248        let tree_a = dir.path().join("a");
1249        let tree_b = dir.path().join("b");
1250        std::fs::create_dir_all(&tree_a).unwrap();
1251        std::fs::create_dir_all(&tree_b).unwrap();
1252
1253        // a depends on b, b depends on a → cycle
1254        let manifest_a = make_manifest(
1255            "a",
1256            "1.0.0",
1257            vec![("b", "https://example.com/b.git", ">=1.0.0")],
1258        );
1259        let manifest_b = make_manifest(
1260            "b",
1261            "1.0.0",
1262            vec![("a", "https://example.com/a.git", ">=1.0.0")],
1263        );
1264
1265        let mut provider = MockProvider::new();
1266        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1267        provider.add_versions("https://example.com/b.git", vec![(1, 0, 0)]);
1268        provider.add_source("a", tree_a, Some(manifest_a));
1269        provider.add_source("b", tree_b, Some(manifest_b));
1270
1271        let config = make_config(vec![(
1272            "a",
1273            git_spec("https://example.com/a.git", Some("v1.0.0")),
1274        )]);
1275
1276        let result = resolve(&config, &provider, None, &default_options());
1277        assert!(result.is_err());
1278        let err = result.unwrap_err().to_string();
1279        assert!(
1280            err.contains("cycle") || err.contains("Cycle"),
1281            "error should mention cycle: {err}"
1282        );
1283    }
1284
1285    #[test]
1286    fn locked_version_preferred_when_satisfies_constraint() {
1287        let dir = TempDir::new().unwrap();
1288        let tree = dir.path().join("a");
1289        std::fs::create_dir_all(&tree).unwrap();
1290
1291        let mut provider = MockProvider::new();
1292        provider.add_versions(
1293            "https://example.com/a.git",
1294            vec![(1, 0, 0), (1, 1, 0), (1, 2, 0)],
1295        );
1296        provider.add_source("a", tree, None);
1297
1298        let config = make_config(vec![(
1299            "a",
1300            git_spec("https://example.com/a.git", Some("^1.0")),
1301        )]);
1302
1303        // Lock file says v1.1.0
1304        let mut lock = LockFile::empty();
1305        lock.sources.insert(
1306            "a".into(),
1307            crate::lock::LockedSource {
1308                url: Some("https://example.com/a.git".into()),
1309                path: None,
1310                version: Some("v1.1.0".into()),
1311                commit: Some("abc".into()),
1312                tree_hash: None,
1313            },
1314        );
1315
1316        let graph = resolve(&config, &provider, Some(&lock), &default_options()).unwrap();
1317        let node = &graph.nodes["a"];
1318        // Should prefer locked version 1.1.0 over MVS minimum 1.0.0
1319        assert_eq!(node.resolved_ref.version, Some(Version::new(1, 1, 0)));
1320    }
1321
1322    #[test]
1323    fn locked_version_ignored_when_constraint_changed() {
1324        let dir = TempDir::new().unwrap();
1325        let tree = dir.path().join("a");
1326        std::fs::create_dir_all(&tree).unwrap();
1327
1328        let mut provider = MockProvider::new();
1329        provider.add_versions(
1330            "https://example.com/a.git",
1331            vec![(1, 0, 0), (2, 0, 0), (2, 1, 0)],
1332        );
1333        provider.add_source("a", tree, None);
1334
1335        // Config now requires ^2.0
1336        let config = make_config(vec![(
1337            "a",
1338            git_spec("https://example.com/a.git", Some("^2.0")),
1339        )]);
1340
1341        // Lock file says v1.0.0 — no longer satisfies ^2.0
1342        let mut lock = LockFile::empty();
1343        lock.sources.insert(
1344            "a".into(),
1345            crate::lock::LockedSource {
1346                url: Some("https://example.com/a.git".into()),
1347                path: None,
1348                version: Some("v1.0.0".into()),
1349                commit: Some("abc".into()),
1350                tree_hash: None,
1351            },
1352        );
1353
1354        let graph = resolve(&config, &provider, Some(&lock), &default_options()).unwrap();
1355        let node = &graph.nodes["a"];
1356        // Locked version doesn't satisfy ^2.0, so MVS picks 2.0.0
1357        assert_eq!(node.resolved_ref.version, Some(Version::new(2, 0, 0)));
1358    }
1359
1360    #[test]
1361    fn locked_commit_is_used_when_reachable() {
1362        let dir = TempDir::new().unwrap();
1363        let tree = dir.path().join("a");
1364        std::fs::create_dir_all(&tree).unwrap();
1365
1366        let mut provider = MockProvider::new();
1367        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0), (1, 1, 0)]);
1368        provider.add_source("a", tree, None);
1369
1370        let config = make_config(vec![(
1371            "a",
1372            git_spec("https://example.com/a.git", Some("^1.0")),
1373        )]);
1374
1375        let locked_commit = "locked-sha-123";
1376        let mut lock = LockFile::empty();
1377        lock.sources.insert(
1378            "a".into(),
1379            crate::lock::LockedSource {
1380                url: Some("https://example.com/a.git".into()),
1381                path: None,
1382                version: Some("v1.1.0".into()),
1383                commit: Some(locked_commit.into()),
1384                tree_hash: None,
1385            },
1386        );
1387
1388        let graph = resolve(&config, &provider, Some(&lock), &default_options()).unwrap();
1389        assert_eq!(
1390            graph.nodes["a"].resolved_ref.commit.as_deref(),
1391            Some(locked_commit)
1392        );
1393        assert_eq!(
1394            provider.seen_preferred_commits(),
1395            vec![Some(locked_commit.to_string())]
1396        );
1397    }
1398
1399    #[test]
1400    fn normal_mode_falls_back_when_locked_commit_unreachable() {
1401        let dir = TempDir::new().unwrap();
1402        let tree = dir.path().join("a");
1403        std::fs::create_dir_all(&tree).unwrap();
1404
1405        let mut provider = MockProvider::new();
1406        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0), (1, 1, 0)]);
1407        provider.add_source("a", tree, None);
1408
1409        let config = make_config(vec![(
1410            "a",
1411            git_spec("https://example.com/a.git", Some("^1.0")),
1412        )]);
1413
1414        let unreachable_commit = "missing-locked-sha";
1415        provider.mark_unreachable_preferred_commit(unreachable_commit);
1416
1417        let mut lock = LockFile::empty();
1418        lock.sources.insert(
1419            "a".into(),
1420            crate::lock::LockedSource {
1421                url: Some("https://example.com/a.git".into()),
1422                path: None,
1423                version: Some("v1.1.0".into()),
1424                commit: Some(unreachable_commit.into()),
1425                tree_hash: None,
1426            },
1427        );
1428
1429        let graph = resolve(&config, &provider, Some(&lock), &default_options()).unwrap();
1430        assert_eq!(
1431            graph.nodes["a"].resolved_ref.version,
1432            Some(Version::new(1, 1, 0))
1433        );
1434        assert_eq!(
1435            graph.nodes["a"].resolved_ref.commit.as_deref(),
1436            Some("mock-commit")
1437        );
1438        assert_eq!(
1439            provider.seen_preferred_commits(),
1440            vec![Some(unreachable_commit.to_string()), None]
1441        );
1442    }
1443
1444    #[test]
1445    fn frozen_mode_errors_when_locked_commit_unreachable() {
1446        let dir = TempDir::new().unwrap();
1447        let tree = dir.path().join("a");
1448        std::fs::create_dir_all(&tree).unwrap();
1449
1450        let mut provider = MockProvider::new();
1451        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0), (1, 1, 0)]);
1452        provider.add_source("a", tree, None);
1453
1454        let config = make_config(vec![(
1455            "a",
1456            git_spec("https://example.com/a.git", Some("^1.0")),
1457        )]);
1458
1459        let unreachable_commit = "missing-locked-sha";
1460        provider.mark_unreachable_preferred_commit(unreachable_commit);
1461
1462        let mut lock = LockFile::empty();
1463        lock.sources.insert(
1464            "a".into(),
1465            crate::lock::LockedSource {
1466                url: Some("https://example.com/a.git".into()),
1467                path: None,
1468                version: Some("v1.1.0".into()),
1469                commit: Some(unreachable_commit.into()),
1470                tree_hash: None,
1471            },
1472        );
1473
1474        let options = ResolveOptions {
1475            frozen: true,
1476            ..default_options()
1477        };
1478        let result = resolve(&config, &provider, Some(&lock), &options);
1479        assert!(matches!(
1480            result,
1481            Err(MarsError::LockedCommitUnreachable { .. })
1482        ));
1483        assert_eq!(
1484            provider.seen_preferred_commits(),
1485            vec![Some(unreachable_commit.to_string())]
1486        );
1487    }
1488
1489    #[test]
1490    fn maximize_mode_ignores_locked_commit() {
1491        let dir = TempDir::new().unwrap();
1492        let tree = dir.path().join("a");
1493        std::fs::create_dir_all(&tree).unwrap();
1494
1495        let mut provider = MockProvider::new();
1496        provider.add_versions(
1497            "https://example.com/a.git",
1498            vec![(1, 0, 0), (1, 1, 0), (1, 2, 0)],
1499        );
1500        provider.add_source("a", tree, None);
1501
1502        let config = make_config(vec![(
1503            "a",
1504            git_spec("https://example.com/a.git", Some("^1.0")),
1505        )]);
1506
1507        let unreachable_commit = "missing-locked-sha";
1508        provider.mark_unreachable_preferred_commit(unreachable_commit);
1509
1510        let mut lock = LockFile::empty();
1511        lock.sources.insert(
1512            "a".into(),
1513            crate::lock::LockedSource {
1514                url: Some("https://example.com/a.git".into()),
1515                path: None,
1516                version: Some("v1.0.0".into()),
1517                commit: Some(unreachable_commit.into()),
1518                tree_hash: None,
1519            },
1520        );
1521
1522        let options = ResolveOptions {
1523            maximize: true,
1524            upgrade_targets: HashSet::new(),
1525            frozen: false,
1526        };
1527        let graph = resolve(&config, &provider, Some(&lock), &options).unwrap();
1528        assert_eq!(
1529            graph.nodes["a"].resolved_ref.version,
1530            Some(Version::new(1, 2, 0))
1531        );
1532        assert_eq!(provider.seen_preferred_commits(), vec![None]);
1533    }
1534
1535    #[test]
1536    fn latest_resolves_to_newest() {
1537        let dir = TempDir::new().unwrap();
1538        let tree = dir.path().join("a");
1539        std::fs::create_dir_all(&tree).unwrap();
1540
1541        let mut provider = MockProvider::new();
1542        provider.add_versions(
1543            "https://example.com/a.git",
1544            vec![(1, 0, 0), (2, 0, 0), (3, 0, 0)],
1545        );
1546        provider.add_source("a", tree, None);
1547
1548        let config = make_config(vec![(
1549            "a",
1550            git_spec("https://example.com/a.git", Some("latest")),
1551        )]);
1552
1553        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1554        let node = &graph.nodes["a"];
1555        // "latest" has no constraint, MVS picks minimum → 1.0.0
1556        // Actually, "latest" means any version. With MVS, minimum is 1.0.0.
1557        // But "latest" semantically means newest. Let me check the spec...
1558        // The spec says "@latest as any version (newest wins)"
1559        // So latest should pick the newest. Let me handle this in select_version.
1560        assert_eq!(node.resolved_ref.version, Some(Version::new(3, 0, 0)));
1561    }
1562
1563    #[test]
1564    fn v2_resolves_to_major_range() {
1565        let dir = TempDir::new().unwrap();
1566        let tree = dir.path().join("a");
1567        std::fs::create_dir_all(&tree).unwrap();
1568
1569        let mut provider = MockProvider::new();
1570        provider.add_versions(
1571            "https://example.com/a.git",
1572            vec![(1, 9, 0), (2, 0, 0), (2, 1, 0), (2, 5, 0), (3, 0, 0)],
1573        );
1574        provider.add_source("a", tree, None);
1575
1576        let config = make_config(vec![(
1577            "a",
1578            git_spec("https://example.com/a.git", Some("v2")),
1579        )]);
1580
1581        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1582        let node = &graph.nodes["a"];
1583        // v2 → >=2.0.0, <3.0.0, MVS picks minimum → 2.0.0
1584        assert_eq!(node.resolved_ref.version, Some(Version::new(2, 0, 0)));
1585    }
1586
1587    #[test]
1588    fn branch_ref_resolves_without_semver() {
1589        let dir = TempDir::new().unwrap();
1590        let tree = dir.path().join("a");
1591        std::fs::create_dir_all(&tree).unwrap();
1592
1593        let mut provider = MockProvider::new();
1594        provider.add_source("a", tree, None);
1595
1596        let config = make_config(vec![(
1597            "a",
1598            git_spec("https://example.com/a.git", Some("main")),
1599        )]);
1600
1601        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1602        let node = &graph.nodes["a"];
1603        assert!(node.resolved_ref.version.is_none());
1604        assert_eq!(node.resolved_ref.commit, Some("ref:main".into()));
1605    }
1606
1607    #[test]
1608    fn source_without_manifest_has_no_transitive_deps() {
1609        let dir = TempDir::new().unwrap();
1610        let tree = dir.path().join("a");
1611        std::fs::create_dir_all(&tree).unwrap();
1612
1613        let mut provider = MockProvider::new();
1614        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1615        provider.add_source("a", tree, None); // No manifest
1616
1617        let config = make_config(vec![(
1618            "a",
1619            git_spec("https://example.com/a.git", Some("v1.0.0")),
1620        )]);
1621
1622        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1623        assert_eq!(graph.nodes.len(), 1);
1624        assert!(graph.nodes["a"].deps.is_empty());
1625    }
1626
1627    #[test]
1628    fn path_source_resolves_without_version() {
1629        let dir = TempDir::new().unwrap();
1630        let tree = dir.path().join("local-source");
1631        std::fs::create_dir_all(&tree).unwrap();
1632
1633        let mut provider = MockProvider::new();
1634        provider.add_source("local", tree.clone(), None);
1635
1636        let config = make_config(vec![("local", SourceSpec::Path(tree))]);
1637
1638        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1639        assert_eq!(graph.nodes.len(), 1);
1640        let node = &graph.nodes["local"];
1641        assert!(node.resolved_ref.version.is_none());
1642    }
1643
1644    #[test]
1645    fn maximize_mode_picks_newest() {
1646        let dir = TempDir::new().unwrap();
1647        let tree = dir.path().join("a");
1648        std::fs::create_dir_all(&tree).unwrap();
1649
1650        let mut provider = MockProvider::new();
1651        provider.add_versions(
1652            "https://example.com/a.git",
1653            vec![(1, 0, 0), (1, 5, 0), (1, 9, 0)],
1654        );
1655        provider.add_source("a", tree, None);
1656
1657        let config = make_config(vec![(
1658            "a",
1659            git_spec("https://example.com/a.git", Some("^1.0")),
1660        )]);
1661
1662        let options = ResolveOptions {
1663            maximize: true,
1664            upgrade_targets: HashSet::new(),
1665            frozen: false,
1666        };
1667
1668        let graph = resolve(&config, &provider, None, &options).unwrap();
1669        let node = &graph.nodes["a"];
1670        assert_eq!(node.resolved_ref.version, Some(Version::new(1, 9, 0)));
1671    }
1672
1673    #[test]
1674    fn maximize_with_specific_targets() {
1675        let dir = TempDir::new().unwrap();
1676        let tree_a = dir.path().join("a");
1677        let tree_b = dir.path().join("b");
1678        std::fs::create_dir_all(&tree_a).unwrap();
1679        std::fs::create_dir_all(&tree_b).unwrap();
1680
1681        let mut provider = MockProvider::new();
1682        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0), (1, 5, 0)]);
1683        provider.add_versions("https://example.com/b.git", vec![(2, 0, 0), (2, 5, 0)]);
1684        provider.add_source("a", tree_a, None);
1685        provider.add_source("b", tree_b, None);
1686
1687        let config = make_config(vec![
1688            ("a", git_spec("https://example.com/a.git", Some("^1.0"))),
1689            ("b", git_spec("https://example.com/b.git", Some("^2.0"))),
1690        ]);
1691
1692        // Only upgrade "a", not "b"
1693        let options = ResolveOptions {
1694            maximize: true,
1695            upgrade_targets: HashSet::from(["a".into()]),
1696            frozen: false,
1697        };
1698
1699        let graph = resolve(&config, &provider, None, &options).unwrap();
1700        // "a" should be maximized → 1.5.0
1701        assert_eq!(
1702            graph.nodes["a"].resolved_ref.version,
1703            Some(Version::new(1, 5, 0))
1704        );
1705        // "b" should use MVS → 2.0.0
1706        assert_eq!(
1707            graph.nodes["b"].resolved_ref.version,
1708            Some(Version::new(2, 0, 0))
1709        );
1710    }
1711
1712    #[test]
1713    fn no_available_versions_falls_back_to_head() {
1714        let dir = TempDir::new().unwrap();
1715        let tree = dir.path().join("a");
1716        std::fs::create_dir_all(&tree).unwrap();
1717
1718        let mut provider = MockProvider::new();
1719        // No versions registered → empty list
1720        provider.add_source("a", tree, None);
1721
1722        let config = make_config(vec![("a", git_spec("https://example.com/a.git", None))]);
1723
1724        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1725        let node = &graph.nodes["a"];
1726        assert!(node.resolved_ref.version.is_none());
1727        assert_eq!(node.resolved_ref.commit, Some("ref:HEAD".into()));
1728    }
1729
1730    #[test]
1731    fn untagged_source_uses_locked_commit_when_available() {
1732        let dir = TempDir::new().unwrap();
1733        let tree = dir.path().join("a");
1734        std::fs::create_dir_all(&tree).unwrap();
1735
1736        let mut provider = MockProvider::new();
1737        provider.add_source("a", tree, None);
1738
1739        let config = make_config(vec![("a", git_spec("https://example.com/a.git", None))]);
1740
1741        let locked_commit = "locked-untagged-sha";
1742        let mut lock = LockFile::empty();
1743        lock.sources.insert(
1744            "a".into(),
1745            crate::lock::LockedSource {
1746                url: Some("https://example.com/a.git".into()),
1747                path: None,
1748                version: None,
1749                commit: Some(locked_commit.into()),
1750                tree_hash: None,
1751            },
1752        );
1753
1754        let graph = resolve(&config, &provider, Some(&lock), &default_options()).unwrap();
1755        assert_eq!(
1756            graph.nodes["a"].resolved_ref.commit.as_deref(),
1757            Some(locked_commit)
1758        );
1759        assert_eq!(
1760            provider.seen_preferred_commits(),
1761            vec![Some(locked_commit.to_string())]
1762        );
1763    }
1764
1765    #[test]
1766    fn untagged_source_falls_back_to_head_when_locked_commit_unreachable() {
1767        let dir = TempDir::new().unwrap();
1768        let tree = dir.path().join("a");
1769        std::fs::create_dir_all(&tree).unwrap();
1770
1771        let mut provider = MockProvider::new();
1772        provider.add_source("a", tree, None);
1773
1774        let config = make_config(vec![("a", git_spec("https://example.com/a.git", None))]);
1775
1776        let unreachable_commit = "missing-locked-sha";
1777        provider.mark_unreachable_preferred_commit(unreachable_commit);
1778
1779        let mut lock = LockFile::empty();
1780        lock.sources.insert(
1781            "a".into(),
1782            crate::lock::LockedSource {
1783                url: Some("https://example.com/a.git".into()),
1784                path: None,
1785                version: None,
1786                commit: Some(unreachable_commit.into()),
1787                tree_hash: None,
1788            },
1789        );
1790
1791        let graph = resolve(&config, &provider, Some(&lock), &default_options()).unwrap();
1792        assert_eq!(
1793            graph.nodes["a"].resolved_ref.commit.as_deref(),
1794            Some("ref:HEAD")
1795        );
1796        assert_eq!(
1797            provider.seen_preferred_commits(),
1798            vec![Some(unreachable_commit.to_string()), None]
1799        );
1800    }
1801
1802    #[test]
1803    fn frozen_mode_errors_for_untagged_locked_commit_unreachable() {
1804        let dir = TempDir::new().unwrap();
1805        let tree = dir.path().join("a");
1806        std::fs::create_dir_all(&tree).unwrap();
1807
1808        let mut provider = MockProvider::new();
1809        provider.add_source("a", tree, None);
1810
1811        let config = make_config(vec![("a", git_spec("https://example.com/a.git", None))]);
1812
1813        let unreachable_commit = "missing-locked-sha";
1814        provider.mark_unreachable_preferred_commit(unreachable_commit);
1815
1816        let mut lock = LockFile::empty();
1817        lock.sources.insert(
1818            "a".into(),
1819            crate::lock::LockedSource {
1820                url: Some("https://example.com/a.git".into()),
1821                path: None,
1822                version: None,
1823                commit: Some(unreachable_commit.into()),
1824                tree_hash: None,
1825            },
1826        );
1827
1828        let options = ResolveOptions {
1829            frozen: true,
1830            ..default_options()
1831        };
1832        let result = resolve(&config, &provider, Some(&lock), &options);
1833        assert!(matches!(
1834            result,
1835            Err(MarsError::LockedCommitUnreachable { .. })
1836        ));
1837        assert_eq!(
1838            provider.seen_preferred_commits(),
1839            vec![Some(unreachable_commit.to_string())]
1840        );
1841    }
1842
1843    // ========== Topological sort tests ==========
1844
1845    #[test]
1846    fn topo_sort_linear_chain() {
1847        let mut nodes = IndexMap::new();
1848        nodes.insert(
1849            "c".into(),
1850            ResolvedNode {
1851                source_name: "c".into(),
1852                source_id: SourceId::git(SourceUrl::from("example.com/c")),
1853                resolved_ref: dummy_ref("c"),
1854                manifest: None,
1855                deps: vec!["b".into()],
1856            },
1857        );
1858        nodes.insert(
1859            "b".into(),
1860            ResolvedNode {
1861                source_name: "b".into(),
1862                source_id: SourceId::git(SourceUrl::from("example.com/b")),
1863                resolved_ref: dummy_ref("b"),
1864                manifest: None,
1865                deps: vec!["a".into()],
1866            },
1867        );
1868        nodes.insert(
1869            "a".into(),
1870            ResolvedNode {
1871                source_name: "a".into(),
1872                source_id: SourceId::git(SourceUrl::from("example.com/a")),
1873                resolved_ref: dummy_ref("a"),
1874                manifest: None,
1875                deps: vec![],
1876            },
1877        );
1878
1879        let order = topological_sort(&nodes).unwrap();
1880        assert_eq!(order, vec!["a", "b", "c"]);
1881    }
1882
1883    #[test]
1884    fn topo_sort_diamond() {
1885        // a depends on b and c, both depend on d
1886        let mut nodes = IndexMap::new();
1887        nodes.insert(
1888            "a".into(),
1889            ResolvedNode {
1890                source_name: "a".into(),
1891                source_id: SourceId::git(SourceUrl::from("example.com/a")),
1892                resolved_ref: dummy_ref("a"),
1893                manifest: None,
1894                deps: vec!["b".into(), "c".into()],
1895            },
1896        );
1897        nodes.insert(
1898            "b".into(),
1899            ResolvedNode {
1900                source_name: "b".into(),
1901                source_id: SourceId::git(SourceUrl::from("example.com/b")),
1902                resolved_ref: dummy_ref("b"),
1903                manifest: None,
1904                deps: vec!["d".into()],
1905            },
1906        );
1907        nodes.insert(
1908            "c".into(),
1909            ResolvedNode {
1910                source_name: "c".into(),
1911                source_id: SourceId::git(SourceUrl::from("example.com/c")),
1912                resolved_ref: dummy_ref("c"),
1913                manifest: None,
1914                deps: vec!["d".into()],
1915            },
1916        );
1917        nodes.insert(
1918            "d".into(),
1919            ResolvedNode {
1920                source_name: "d".into(),
1921                source_id: SourceId::git(SourceUrl::from("example.com/d")),
1922                resolved_ref: dummy_ref("d"),
1923                manifest: None,
1924                deps: vec![],
1925            },
1926        );
1927
1928        let order = topological_sort(&nodes).unwrap();
1929        // d must come first, a must come last
1930        assert_eq!(order[0], "d");
1931        assert_eq!(*order.last().unwrap(), "a");
1932        // b and c can be in either order, but both before a
1933        let a_pos = order.iter().position(|n| n == "a").unwrap();
1934        let b_pos = order.iter().position(|n| n == "b").unwrap();
1935        let c_pos = order.iter().position(|n| n == "c").unwrap();
1936        assert!(b_pos < a_pos);
1937        assert!(c_pos < a_pos);
1938    }
1939
1940    #[test]
1941    fn topo_sort_no_deps() {
1942        let mut nodes = IndexMap::new();
1943        nodes.insert(
1944            "a".into(),
1945            ResolvedNode {
1946                source_name: "a".into(),
1947                source_id: SourceId::git(SourceUrl::from("example.com/a")),
1948                resolved_ref: dummy_ref("a"),
1949                manifest: None,
1950                deps: vec![],
1951            },
1952        );
1953        nodes.insert(
1954            "b".into(),
1955            ResolvedNode {
1956                source_name: "b".into(),
1957                source_id: SourceId::git(SourceUrl::from("example.com/b")),
1958                resolved_ref: dummy_ref("b"),
1959                manifest: None,
1960                deps: vec![],
1961            },
1962        );
1963
1964        let order = topological_sort(&nodes).unwrap();
1965        assert_eq!(order.len(), 2);
1966        // Deterministic alphabetical order for independent nodes
1967        assert_eq!(order, vec!["a", "b"]);
1968    }
1969
1970    #[test]
1971    fn topo_sort_cycle_error() {
1972        let mut nodes = IndexMap::new();
1973        nodes.insert(
1974            "a".into(),
1975            ResolvedNode {
1976                source_name: "a".into(),
1977                source_id: SourceId::git(SourceUrl::from("example.com/a")),
1978                resolved_ref: dummy_ref("a"),
1979                manifest: None,
1980                deps: vec!["b".into()],
1981            },
1982        );
1983        nodes.insert(
1984            "b".into(),
1985            ResolvedNode {
1986                source_name: "b".into(),
1987                source_id: SourceId::git(SourceUrl::from("example.com/b")),
1988                resolved_ref: dummy_ref("b"),
1989                manifest: None,
1990                deps: vec!["a".into()],
1991            },
1992        );
1993
1994        let result = topological_sort(&nodes);
1995        assert!(result.is_err());
1996        let err = result.unwrap_err().to_string();
1997        assert!(err.contains("cycle") || err.contains("Cycle"), "{err}");
1998    }
1999
2000    fn dummy_ref(name: &str) -> ResolvedRef {
2001        ResolvedRef {
2002            source_name: name.into(),
2003            version: None,
2004            version_tag: None,
2005            commit: None,
2006            tree_path: PathBuf::new(),
2007        }
2008    }
2009}