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