Skip to main content

mars_agents/resolve/
mod.rs

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