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