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, PathBuf};
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, SourceSubpath, 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 rooted_ref: RootedSourceRef,
45    pub resolved_ref: ResolvedRef,
46    pub latest_version: Option<Version>,
47    /// None if source has no mars.toml.
48    pub manifest: Option<Manifest>,
49    /// Source names this depends on.
50    pub deps: Vec<SourceName>,
51}
52
53/// Source checkout provenance and rooted package boundary.
54#[derive(Debug, Clone)]
55pub struct RootedSourceRef {
56    pub checkout_root: PathBuf,
57    pub package_root: PathBuf,
58}
59
60/// How a version constraint was specified.
61#[derive(Debug, Clone)]
62pub enum VersionConstraint {
63    /// Semver requirement (^1.0, >=0.5.0, ~2.1, exact version).
64    Semver(VersionReq),
65    /// Any version, prefer newest.
66    Latest,
67    /// Branch or commit pin — no semver resolution.
68    RefPin(String),
69}
70
71/// Options controlling resolution behavior.
72#[derive(Debug, Clone, Default)]
73pub struct ResolveOptions {
74    /// If true, prefer newest version instead of minimum (for `mars upgrade`).
75    pub maximize: bool,
76    /// Source names to upgrade (empty = all, when maximize=true).
77    pub upgrade_targets: HashSet<SourceName>,
78    /// If true, treat direct dependency constraints for upgrade targets as
79    /// unconstrained during resolution (used by `mars upgrade --bump`).
80    pub bump_direct_constraints: bool,
81    /// If true, locked commit replay failures become hard errors.
82    pub frozen: bool,
83}
84
85/// Lists semver-tagged versions available for a git source.
86pub trait VersionLister {
87    fn list_versions(&self, url: &SourceUrl) -> Result<Vec<AvailableVersion>, MarsError>;
88}
89
90/// Fetches concrete source trees after the resolver has picked a strategy.
91pub trait SourceFetcher {
92    /// Fetch a git source at a specific version tag.
93    fn fetch_git_version(
94        &self,
95        url: &SourceUrl,
96        version: &AvailableVersion,
97        source_name: &str,
98        preferred_commit: Option<&str>,
99        diag: &mut DiagnosticCollector,
100    ) -> Result<ResolvedRef, MarsError>;
101
102    /// Fetch a git source at a branch/commit ref (non-semver path).
103    fn fetch_git_ref(
104        &self,
105        url: &SourceUrl,
106        ref_name: &str,
107        source_name: &str,
108        preferred_commit: Option<&str>,
109        diag: &mut DiagnosticCollector,
110    ) -> Result<ResolvedRef, MarsError>;
111
112    /// Resolve a local path source into a concrete tree reference.
113    fn fetch_path(
114        &self,
115        path: &Path,
116        source_name: &str,
117        diag: &mut DiagnosticCollector,
118    ) -> Result<ResolvedRef, MarsError>;
119}
120
121/// Reads source manifests for transitive dependency discovery.
122pub trait ManifestReader {
123    fn read_manifest(
124        &self,
125        source_tree: &Path,
126        diag: &mut DiagnosticCollector,
127    ) -> Result<Option<Manifest>, MarsError>;
128}
129
130/// Composite trait used by `resolve()`.
131pub trait SourceProvider: VersionLister + SourceFetcher + ManifestReader {}
132
133impl<T> SourceProvider for T where T: VersionLister + SourceFetcher + ManifestReader {}
134
135/// Parse a version string into a constraint.
136///
137/// - `None` / `"latest"` → Latest (any version, newest wins)
138/// - `"v1.2.3"` → exact match
139/// - `"v2"` → `>=2.0.0, <3.0.0` (major range)
140/// - `"v2.1"` → `>=2.1.0, <2.2.0` (minor range)
141/// - `">=0.5.0"`, `"^2.0"`, `"~1.2"` → semver requirement
142/// - anything else → branch/commit ref pin
143pub fn parse_version_constraint(version: Option<&str>) -> VersionConstraint {
144    let version = match version {
145        None => return VersionConstraint::Latest,
146        Some(v) => v.trim(),
147    };
148
149    if version.is_empty() || version.eq_ignore_ascii_case("latest") {
150        return VersionConstraint::Latest;
151    }
152
153    // Try "v"-prefixed versions: v1.2.3, v2, v2.1
154    if let Some(stripped) = version.strip_prefix('v') {
155        // Try exact semver: v1.2.3
156        if let Ok(ver) = Version::parse(stripped) {
157            let req = VersionReq::parse(&format!("={ver}")).expect("valid exact req");
158            return VersionConstraint::Semver(req);
159        }
160
161        // Try major-only: v2 → >=2.0.0, <3.0.0
162        if let Ok(major) = stripped.parse::<u64>() {
163            let req = VersionReq::parse(&format!(">={major}.0.0, <{}.0.0", major + 1))
164                .expect("valid major range req");
165            return VersionConstraint::Semver(req);
166        }
167
168        // Try major.minor: v2.1 → >=2.1.0, <2.2.0
169        let parts: Vec<&str> = stripped.split('.').collect();
170        if parts.len() == 2
171            && let (Ok(major), Ok(minor)) = (parts[0].parse::<u64>(), parts[1].parse::<u64>())
172        {
173            let req = VersionReq::parse(&format!(">={major}.{minor}.0, <{major}.{}.0", minor + 1))
174                .expect("valid minor range req");
175            return VersionConstraint::Semver(req);
176        }
177    }
178
179    // Try as semver requirement directly (>=0.5.0, ^2.0, ~1.2, =1.0.0, etc.)
180    if let Ok(req) = VersionReq::parse(version) {
181        return VersionConstraint::Semver(req);
182    }
183
184    // Otherwise it's a branch or commit ref pin
185    VersionConstraint::RefPin(version.to_string())
186}
187
188/// Resolve the full dependency graph from config.
189///
190/// Uses Minimum Version Selection (MVS) by default: selects the lowest
191/// version satisfying all constraints. This is conservative and reproducible —
192/// the same constraint always resolves to the same version. Users who want
193/// the latest use `@latest` explicitly, or `mars upgrade`.
194///
195/// When `locked` is provided, prefer locked versions when constraints allow
196/// (reproducible builds).
197pub fn resolve(
198    config: &EffectiveConfig,
199    provider: &dyn SourceProvider,
200    locked: Option<&LockFile>,
201    options: &ResolveOptions,
202    diag: &mut DiagnosticCollector,
203) -> Result<ResolvedGraph, MarsError> {
204    let mut nodes: IndexMap<SourceName, ResolvedNode> = IndexMap::new();
205    let mut id_index: HashMap<SourceId, SourceName> = HashMap::new();
206    let mut filter_constraints: HashMap<SourceName, Vec<FilterMode>> = HashMap::new();
207
208    // Pending sources to process: (name, url_or_path, version_constraint, required_by)
209    let mut pending: VecDeque<PendingSource> = VecDeque::new();
210
211    // Track constraints per source name for intersection
212    let mut constraints: HashMap<SourceName, Vec<(String, VersionConstraint)>> = HashMap::new();
213
214    // Seed with direct dependencies from config
215    for (name, source) in &config.dependencies {
216        let is_upgrade_target = options.maximize
217            && (options.upgrade_targets.is_empty() || options.upgrade_targets.contains(name));
218        let constraint = match &source.spec {
219            SourceSpec::Git(git) => {
220                if options.bump_direct_constraints && is_upgrade_target {
221                    VersionConstraint::Latest
222                } else {
223                    parse_version_constraint(git.version.as_deref())
224                }
225            }
226            SourceSpec::Path(_) => VersionConstraint::Latest, // Path sources: no version
227        };
228        pending.push_back(PendingSource {
229            name: name.clone(),
230            source_id: source.id.clone(),
231            spec: source.spec.clone(),
232            subpath: source.subpath.clone(),
233            constraint,
234            filter: source.filter.clone(),
235            required_by: "mars.toml".into(),
236        });
237    }
238
239    // BFS: resolve each source, discover transitive deps
240    while let Some(pending_src) = pending.pop_front() {
241        if let Some(existing_name) = id_index.get(&pending_src.source_id)
242            && existing_name != &pending_src.name
243        {
244            return Err(ResolutionError::DuplicateSourceIdentity {
245                existing_name: existing_name.to_string(),
246                duplicate_name: pending_src.name.to_string(),
247                source_id: pending_src.source_id.to_string(),
248            }
249            .into());
250        }
251
252        // If already resolved, just record the additional constraint
253        if let Some(existing) = nodes.get(&pending_src.name) {
254            if existing.source_id != pending_src.source_id {
255                return Err(ResolutionError::SourceIdentityMismatch {
256                    name: pending_src.name.to_string(),
257                    existing: existing.source_id.to_string(),
258                    incoming: pending_src.source_id.to_string(),
259                }
260                .into());
261            }
262            constraints
263                .entry(pending_src.name.clone())
264                .or_default()
265                .push((pending_src.required_by.clone(), pending_src.constraint));
266            push_filter_constraint(
267                &mut filter_constraints,
268                &pending_src.name,
269                &pending_src.filter,
270            );
271            continue;
272        }
273
274        // Record constraint
275        constraints
276            .entry(pending_src.name.clone())
277            .or_default()
278            .push((
279                pending_src.required_by.clone(),
280                pending_src.constraint.clone(),
281            ));
282        push_filter_constraint(
283            &mut filter_constraints,
284            &pending_src.name,
285            &pending_src.filter,
286        );
287
288        // Resolve and fetch the source
289        let (resolved_ref, latest_version) =
290            resolve_single_source(&pending_src, provider, locked, options, &constraints, diag)?;
291        let rooted_ref = apply_subpath(
292            &pending_src.name,
293            &resolved_ref.tree_path,
294            pending_src.subpath.as_ref(),
295        )?;
296
297        // Read manifest for transitive deps
298        let manifest = provider.read_manifest(&rooted_ref.package_root, diag)?;
299
300        // Discover transitive dependencies
301        let mut deps = Vec::new();
302        if let Some(ref manifest) = manifest {
303            for (dep_name, dep_spec) in &manifest.dependencies {
304                deps.push(SourceName::from(dep_name.clone()));
305
306                // ManifestDep always has a URL (path-only deps filtered at load_manifest)
307                let dep_url = dep_spec.url.clone();
308
309                // Only add as pending if not already resolved
310                if !nodes.contains_key(dep_name.as_str()) {
311                    let dep_constraint = parse_version_constraint(dep_spec.version.as_deref());
312                    let dep_name_typed = SourceName::from(dep_name.clone());
313                    pending.push_back(PendingSource {
314                        name: dep_name_typed,
315                        source_id: SourceId::git_with_subpath(
316                            dep_url.clone(),
317                            dep_spec.subpath.clone(),
318                        ),
319                        spec: SourceSpec::Git(GitSpec {
320                            url: dep_url,
321                            version: dep_spec.version.clone(),
322                        }),
323                        subpath: dep_spec.subpath.clone(),
324                        constraint: dep_constraint,
325                        filter: dep_spec.filter.to_mode(),
326                        required_by: pending_src.name.to_string(),
327                    });
328                } else {
329                    // Already resolved — record additional constraint for later validation
330                    let dep_constraint = parse_version_constraint(dep_spec.version.as_deref());
331                    constraints
332                        .entry(SourceName::from(dep_name.clone()))
333                        .or_default()
334                        .push((pending_src.name.to_string(), dep_constraint));
335                    push_filter_constraint(
336                        &mut filter_constraints,
337                        &SourceName::from(dep_name.clone()),
338                        &dep_spec.filter.to_mode(),
339                    );
340                }
341            }
342        }
343
344        nodes.insert(
345            pending_src.name.clone(),
346            ResolvedNode {
347                source_name: pending_src.name.clone(),
348                source_id: pending_src.source_id.clone(),
349                rooted_ref,
350                resolved_ref,
351                latest_version,
352                manifest,
353                deps,
354            },
355        );
356        id_index.insert(pending_src.source_id, pending_src.name);
357    }
358
359    // Validate that all constraints are satisfied by resolved versions
360    validate_all_constraints(&nodes, &constraints)?;
361
362    // Topological sort
363    let order = topological_sort(&nodes)?;
364
365    Ok(ResolvedGraph {
366        nodes,
367        order,
368        id_index,
369        filters: filter_constraints,
370    })
371}
372
373/// Internal: a source waiting to be resolved.
374struct PendingSource {
375    name: SourceName,
376    source_id: SourceId,
377    spec: SourceSpec,
378    subpath: Option<SourceSubpath>,
379    constraint: VersionConstraint,
380    filter: FilterMode,
381    required_by: String,
382}
383
384fn push_filter_constraint(
385    constraints: &mut HashMap<SourceName, Vec<FilterMode>>,
386    source_name: &SourceName,
387    filter: &FilterMode,
388) {
389    let entry = constraints.entry(source_name.clone()).or_default();
390    if !entry.contains(filter) {
391        entry.push(filter.clone());
392    }
393}
394
395fn apply_subpath(
396    source_name: &SourceName,
397    checkout_root: &Path,
398    subpath: Option<&SourceSubpath>,
399) -> Result<RootedSourceRef, MarsError> {
400    let package_root = match subpath {
401        Some(subpath) => {
402            subpath
403                .join_under(checkout_root)
404                .map_err(|_| MarsError::SubpathTraversal {
405                    source_name: source_name.to_string(),
406                    subpath: subpath.to_string(),
407                    checkout_root: checkout_root.to_path_buf(),
408                })?
409        }
410        None => checkout_root.to_path_buf(),
411    };
412
413    if !package_root.exists() {
414        return match subpath {
415            Some(subpath) => Err(MarsError::SubpathMissing {
416                source_name: source_name.to_string(),
417                subpath: subpath.to_string(),
418                checkout_root: checkout_root.to_path_buf(),
419            }),
420            None => Err(MarsError::Source {
421                source_name: source_name.to_string(),
422                message: format!(
423                    "package root does not exist under checkout root `{}`",
424                    checkout_root.display()
425                ),
426            }),
427        };
428    }
429
430    if !package_root.is_dir() {
431        return match subpath {
432            Some(subpath) => Err(MarsError::SubpathNotDirectory {
433                source_name: source_name.to_string(),
434                subpath: subpath.to_string(),
435                checkout_root: checkout_root.to_path_buf(),
436            }),
437            None => Err(MarsError::Source {
438                source_name: source_name.to_string(),
439                message: format!(
440                    "package root is not a directory under checkout root `{}`",
441                    checkout_root.display()
442                ),
443            }),
444        };
445    }
446
447    let canonical_checkout = checkout_root
448        .canonicalize()
449        .map_err(|e| MarsError::Source {
450            source_name: source_name.to_string(),
451            message: format!(
452                "failed to canonicalize checkout root `{}`: {e}",
453                checkout_root.display()
454            ),
455        })?;
456    let canonical_package = package_root.canonicalize().map_err(|e| MarsError::Source {
457        source_name: source_name.to_string(),
458        message: format!(
459            "failed to canonicalize package root `{}`: {e}",
460            package_root.display()
461        ),
462    })?;
463
464    if !canonical_package.starts_with(&canonical_checkout) {
465        return match subpath {
466            Some(subpath) => Err(MarsError::SubpathTraversal {
467                source_name: source_name.to_string(),
468                subpath: subpath.to_string(),
469                checkout_root: checkout_root.to_path_buf(),
470            }),
471            None => Err(MarsError::Source {
472                source_name: source_name.to_string(),
473                message: format!(
474                    "package root escapes checkout root `{}`",
475                    checkout_root.display()
476                ),
477            }),
478        };
479    }
480
481    Ok(RootedSourceRef {
482        checkout_root: checkout_root.to_path_buf(),
483        package_root,
484    })
485}
486
487/// Resolve a single source to a concrete version/ref.
488fn resolve_single_source(
489    pending: &PendingSource,
490    provider: &dyn SourceProvider,
491    locked: Option<&LockFile>,
492    options: &ResolveOptions,
493    constraints: &HashMap<SourceName, Vec<(String, VersionConstraint)>>,
494    diag: &mut DiagnosticCollector,
495) -> Result<(ResolvedRef, Option<Version>), MarsError> {
496    match &pending.spec {
497        SourceSpec::Path(path) => {
498            // Path sources: no version resolution, just use the path
499            provider
500                .fetch_path(path, pending.name.as_ref(), diag)
501                .map(|resolved_ref| (resolved_ref, None))
502        }
503        SourceSpec::Git(git) => resolve_git_source(
504            &pending.name,
505            &git.url,
506            constraints
507                .get(&pending.name)
508                .map(|c| c.as_slice())
509                .unwrap_or(&[]),
510            provider,
511            locked,
512            options,
513            diag,
514        ),
515    }
516}
517
518/// Resolve a git source: list versions, intersect constraints, select version.
519fn resolve_git_source(
520    name: &SourceName,
521    url: &SourceUrl,
522    constraints: &[(String, VersionConstraint)],
523    provider: &dyn SourceProvider,
524    locked: Option<&LockFile>,
525    options: &ResolveOptions,
526    diag: &mut DiagnosticCollector,
527) -> Result<(ResolvedRef, Option<Version>), MarsError> {
528    // If all constraints are ref pins, use the first one
529    // (multiple ref pins for the same source is likely an error, but we'll use first)
530    let has_ref_pin = constraints
531        .iter()
532        .any(|(_, c)| matches!(c, VersionConstraint::RefPin(_)));
533    if has_ref_pin {
534        for (_, constraint) in constraints {
535            if let VersionConstraint::RefPin(ref_name) = constraint {
536                return provider
537                    .fetch_git_ref(url, ref_name, name.as_ref(), None, diag)
538                    .map(|resolved_ref| (resolved_ref, None));
539            }
540        }
541    }
542
543    // Check if any constraint is "Latest" — if so, pick newest (not MVS)
544    let has_latest = constraints
545        .iter()
546        .any(|(_, c)| matches!(c, VersionConstraint::Latest));
547
548    let locked_source = locked.and_then(|lf| lf.dependencies.get(name));
549    let locked_commit = locked_source.and_then(|ls| ls.commit.as_deref());
550
551    let upgrade_maximize = options.maximize
552        && (options.upgrade_targets.is_empty() || options.upgrade_targets.contains(name));
553
554    // Determine whether to maximize this source:
555    // - explicit maximize mode (mars upgrade)
556    // - "latest" constraint means "newest available"
557    let maximize = has_latest || upgrade_maximize;
558
559    // List available versions
560    let available = provider.list_versions(url)?;
561    let latest = available
562        .iter()
563        .max_by(|a, b| a.version.cmp(&b.version))
564        .map(|v| v.version.clone());
565
566    if available.is_empty() {
567        // No semver tags → treat as "latest commit", with locked-commit replay.
568        // For untagged sources, replay lock by default unless explicitly upgrading.
569        let preferred_commit = if !upgrade_maximize {
570            locked_commit
571        } else {
572            None
573        };
574        match provider.fetch_git_ref(url, "HEAD", name.as_ref(), preferred_commit, diag) {
575            Ok(resolved) => return Ok((resolved, latest)),
576            Err(err @ MarsError::LockedCommitUnreachable { .. }) if options.frozen => {
577                return Err(err);
578            }
579            Err(MarsError::LockedCommitUnreachable {
580                commit,
581                url: source_url,
582            }) => {
583                diag.warn(
584                    "locked-commit-unreachable",
585                    format!(
586                        "locked commit {commit} for {source_url} is unreachable; re-resolving from HEAD"
587                    ),
588                );
589                return provider
590                    .fetch_git_ref(url, "HEAD", name.as_ref(), None, diag)
591                    .map(|resolved_ref| (resolved_ref, latest));
592            }
593            Err(err) => return Err(err),
594        }
595    }
596
597    // Collect all semver constraints
598    let semver_reqs: Vec<(&str, &VersionReq)> = constraints
599        .iter()
600        .filter_map(|(requester, c)| match c {
601            VersionConstraint::Semver(req) => Some((requester.as_str(), req)),
602            _ => None,
603        })
604        .collect();
605
606    // Get locked version for this source (if any)
607    let locked_version = locked_source
608        .and_then(|ls| ls.version.as_ref())
609        .and_then(|v| {
610            let v = v.strip_prefix('v').unwrap_or(v);
611            Version::parse(v).ok()
612        });
613
614    // Select version
615    let selected = select_version(
616        name,
617        &available,
618        &semver_reqs,
619        locked_version.as_ref(),
620        maximize,
621    )?;
622
623    let should_try_locked_commit = !maximize
624        && locked_commit.is_some()
625        && match locked_version.as_ref() {
626            Some(version) => selected.version == *version,
627            None => true,
628        };
629
630    let preferred_commit = if should_try_locked_commit {
631        locked_commit
632    } else {
633        None
634    };
635
636    match provider.fetch_git_version(url, selected, name.as_ref(), preferred_commit, diag) {
637        Ok(resolved) => Ok((resolved, latest)),
638        Err(err @ MarsError::LockedCommitUnreachable { .. }) if options.frozen => Err(err),
639        Err(MarsError::LockedCommitUnreachable {
640            commit,
641            url: source_url,
642        }) => {
643            diag.warn(
644                "locked-commit-unreachable",
645                format!(
646                    "locked commit {commit} for {source_url} is unreachable; re-resolving from tag"
647                ),
648            );
649            provider
650                .fetch_git_version(url, selected, name.as_ref(), None, diag)
651                .map(|resolved_ref| (resolved_ref, latest))
652        }
653        Err(err) => Err(err),
654    }
655}
656
657/// Select a concrete version from available versions, respecting constraints.
658///
659/// - MVS (default): pick the minimum version satisfying all constraints.
660/// - Maximize mode: pick the newest version satisfying all constraints.
661/// - Locked version preference: if a locked version satisfies all constraints, use it.
662fn select_version<'a>(
663    source_name: &SourceName,
664    available: &'a [AvailableVersion],
665    constraints: &[(&str, &VersionReq)],
666    locked: Option<&Version>,
667    maximize: bool,
668) -> Result<&'a AvailableVersion, MarsError> {
669    // Find all versions satisfying all constraints
670    let satisfying: Vec<&AvailableVersion> = available
671        .iter()
672        .filter(|av| {
673            if constraints.is_empty() {
674                return true;
675            }
676            constraints.iter().all(|(_, req)| req.matches(&av.version))
677        })
678        .collect();
679
680    if satisfying.is_empty() {
681        // Build helpful error message listing all constraints
682        let constraint_desc: Vec<String> = constraints
683            .iter()
684            .map(|(requester, req)| format!("  `{requester}` requires {req}"))
685            .collect();
686
687        let available_desc: Vec<String> =
688            available.iter().map(|av| av.version.to_string()).collect();
689
690        return Err(ResolutionError::VersionConflict {
691            name: source_name.to_string(),
692            message: format!(
693                "no version satisfies all constraints:\n{}\navailable versions: [{}]",
694                constraint_desc.join("\n"),
695                available_desc.join(", ")
696            ),
697        }
698        .into());
699    }
700
701    // If we have a locked version and it satisfies constraints, prefer it
702    if !maximize
703        && let Some(locked_ver) = locked
704        && let Some(av) = satisfying.iter().find(|av| av.version == *locked_ver)
705    {
706        return Ok(av);
707    }
708
709    // MVS: pick minimum. Maximize: pick maximum.
710    // Available versions from list_versions are sorted ascending by semver.
711    if maximize {
712        Ok(satisfying.last().expect("satisfying is non-empty"))
713    } else {
714        Ok(satisfying.first().expect("satisfying is non-empty"))
715    }
716}
717
718/// Validate that all constraints are satisfied by the resolved versions.
719///
720/// This catches cases where a source was resolved before all constraints
721/// were known (e.g., a later transitive dep adds a new constraint on an
722/// already-resolved source).
723fn validate_all_constraints(
724    nodes: &IndexMap<SourceName, ResolvedNode>,
725    constraints: &HashMap<SourceName, Vec<(String, VersionConstraint)>>,
726) -> Result<(), MarsError> {
727    for (name, constraint_list) in constraints {
728        let node = match nodes.get(name) {
729            Some(n) => n,
730            None => continue, // Should not happen, but be safe
731        };
732
733        // Only validate semver constraints against resolved versions
734        if let Some(ref resolved_ver) = node.resolved_ref.version {
735            for (requester, constraint) in constraint_list {
736                if let VersionConstraint::Semver(req) = constraint
737                    && !req.matches(resolved_ver)
738                {
739                    return Err(ResolutionError::VersionConflict {
740                        name: name.to_string(),
741                        message: format!(
742                            "resolved version {resolved_ver} does not satisfy \
743                             constraint {req} (required by `{requester}`)"
744                        ),
745                    }
746                    .into());
747                }
748            }
749        }
750    }
751    Ok(())
752}
753
754/// Topological sort using Kahn's algorithm (BFS-based).
755///
756/// Returns source names in dependency order (deps before dependents).
757/// Errors if a cycle is detected.
758fn topological_sort(
759    nodes: &IndexMap<SourceName, ResolvedNode>,
760) -> Result<Vec<SourceName>, MarsError> {
761    // Build in-degree map
762    let mut in_degree: HashMap<SourceName, usize> = HashMap::new();
763    let mut adjacency: HashMap<SourceName, Vec<SourceName>> = HashMap::new();
764
765    for (name, _) in nodes {
766        in_degree.entry(name.clone()).or_insert(0);
767        adjacency.entry(name.clone()).or_default();
768    }
769
770    for (name, node) in nodes {
771        for dep in &node.deps {
772            if nodes.contains_key(dep) {
773                adjacency.entry(name.clone()).or_default();
774                *in_degree.entry(dep.clone()).or_insert(0) += 0; // ensure dep exists
775                // dep → name edge means name depends on dep
776                // In Kahn's: in_degree[name] += 1 (name has an incoming dep edge)
777                *in_degree.entry(name.clone()).or_insert(0) += 1;
778                adjacency.entry(dep.clone()).or_default().push(name.clone());
779            }
780        }
781    }
782
783    // Start with nodes that have no dependencies (in_degree == 0)
784    let mut queue: VecDeque<SourceName> = VecDeque::new();
785    for (name, &degree) in &in_degree {
786        if degree == 0 {
787            queue.push_back(name.clone());
788        }
789    }
790
791    // Sort the initial queue for deterministic output
792    let mut sorted_queue: Vec<SourceName> = queue.drain(..).collect();
793    sorted_queue.sort();
794    queue.extend(sorted_queue);
795
796    let mut order: Vec<SourceName> = Vec::new();
797
798    while let Some(current) = queue.pop_front() {
799        order.push(current.clone());
800
801        // Collect and sort dependents for determinism
802        if let Some(dependents) = adjacency.get(&current) {
803            let mut sorted_dependents: Vec<SourceName> = dependents.clone();
804            sorted_dependents.sort();
805
806            for dependent in sorted_dependents {
807                if let Some(degree) = in_degree.get_mut(&dependent) {
808                    *degree -= 1;
809                    if *degree == 0 {
810                        queue.push_back(dependent);
811                    }
812                }
813            }
814        }
815    }
816
817    // If we haven't visited all nodes, there's a cycle
818    if order.len() != nodes.len() {
819        let unvisited: Vec<&str> = nodes
820            .keys()
821            .filter(|name| !order.contains(name))
822            .map(|s| s.as_str())
823            .collect();
824        let chain = unvisited.join(" → ");
825        return Err(ResolutionError::Cycle { chain }.into());
826    }
827
828    Ok(order)
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834    use crate::config::{
835        EffectiveConfig, EffectiveDependency, FilterConfig, FilterMode, GitSpec, Manifest,
836        ManifestDep, PackageInfo, Settings, SourceSpec,
837    };
838    use crate::types::{RenameMap, SourceId, SourceName, SourceSubpath, SourceUrl};
839    use indexmap::IndexMap;
840    use std::cell::RefCell;
841    use std::collections::{HashMap, HashSet};
842    use std::path::PathBuf;
843    use tempfile::TempDir;
844
845    // ========== Mock SourceProvider ==========
846
847    /// Mock provider for testing the resolver without real git repos.
848    struct MockProvider {
849        /// url → sorted available versions
850        versions: HashMap<String, Vec<AvailableVersion>>,
851        /// source tree paths keyed by source name (pre-created temp dirs)
852        trees: HashMap<String, PathBuf>,
853        /// Manifests to return for specific source trees
854        manifests: HashMap<PathBuf, Option<Manifest>>,
855        /// Preferred commits that should simulate an unreachable lock replay.
856        unreachable_preferred_commits: HashSet<String>,
857        /// Captures preferred-commit hints passed by the resolver.
858        seen_preferred_commits: RefCell<Vec<Option<String>>>,
859    }
860
861    impl MockProvider {
862        fn new() -> Self {
863            MockProvider {
864                versions: HashMap::new(),
865                trees: HashMap::new(),
866                manifests: HashMap::new(),
867                unreachable_preferred_commits: HashSet::new(),
868                seen_preferred_commits: RefCell::new(Vec::new()),
869            }
870        }
871
872        /// Register available versions for a URL.
873        fn add_versions(&mut self, url: &str, versions: Vec<(u64, u64, u64)>) {
874            let avs: Vec<AvailableVersion> = versions
875                .into_iter()
876                .map(|(major, minor, patch)| AvailableVersion {
877                    tag: format!("v{major}.{minor}.{patch}"),
878                    version: Version::new(major, minor, patch),
879                    commit_id: "0000000000000000000000000000000000000000".to_string(),
880                })
881                .collect();
882            self.versions.insert(url.to_string(), avs);
883        }
884
885        /// Register a source tree for a source name, with optional manifest.
886        fn add_source(&mut self, name: &str, tree_path: PathBuf, manifest: Option<Manifest>) {
887            if let Some(ref m) = manifest {
888                self.manifests.insert(tree_path.clone(), Some(m.clone()));
889            } else {
890                self.manifests.insert(tree_path.clone(), None);
891            }
892            self.trees.insert(name.to_string(), tree_path);
893        }
894
895        fn mark_unreachable_preferred_commit(&mut self, commit: &str) {
896            self.unreachable_preferred_commits
897                .insert(commit.to_string());
898        }
899
900        fn seen_preferred_commits(&self) -> Vec<Option<String>> {
901            self.seen_preferred_commits.borrow().clone()
902        }
903    }
904
905    impl VersionLister for MockProvider {
906        fn list_versions(&self, url: &SourceUrl) -> Result<Vec<AvailableVersion>, MarsError> {
907            Ok(self.versions.get(url.as_ref()).cloned().unwrap_or_default())
908        }
909    }
910
911    impl SourceFetcher for MockProvider {
912        fn fetch_git_version(
913            &self,
914            url: &SourceUrl,
915            version: &AvailableVersion,
916            source_name: &str,
917            preferred_commit: Option<&str>,
918            _diag: &mut DiagnosticCollector,
919        ) -> Result<ResolvedRef, MarsError> {
920            self.seen_preferred_commits
921                .borrow_mut()
922                .push(preferred_commit.map(str::to_string));
923
924            if let Some(commit) = preferred_commit
925                && self.unreachable_preferred_commits.contains(commit)
926            {
927                return Err(MarsError::LockedCommitUnreachable {
928                    commit: commit.to_string(),
929                    url: url.to_string(),
930                });
931            }
932
933            let tree_path = self.trees.get(source_name).cloned().unwrap_or_default();
934            Ok(ResolvedRef {
935                source_name: source_name.into(),
936                version: Some(version.version.clone()),
937                version_tag: Some(version.tag.clone()),
938                commit: Some(
939                    preferred_commit
940                        .map(|c| c.into())
941                        .unwrap_or_else(|| "mock-commit".into()),
942                ),
943                tree_path,
944            })
945        }
946
947        fn fetch_git_ref(
948            &self,
949            url: &SourceUrl,
950            ref_name: &str,
951            source_name: &str,
952            preferred_commit: Option<&str>,
953            _diag: &mut DiagnosticCollector,
954        ) -> Result<ResolvedRef, MarsError> {
955            self.seen_preferred_commits
956                .borrow_mut()
957                .push(preferred_commit.map(str::to_string));
958
959            if let Some(commit) = preferred_commit
960                && self.unreachable_preferred_commits.contains(commit)
961            {
962                return Err(MarsError::LockedCommitUnreachable {
963                    commit: commit.to_string(),
964                    url: url.to_string(),
965                });
966            }
967
968            let tree_path = self.trees.get(source_name).cloned().unwrap_or_default();
969            Ok(ResolvedRef {
970                source_name: source_name.into(),
971                version: None,
972                version_tag: None,
973                commit: Some(
974                    preferred_commit
975                        .map(|c| c.into())
976                        .unwrap_or_else(|| format!("ref:{ref_name}").into()),
977                ),
978                tree_path,
979            })
980        }
981
982        fn fetch_path(
983            &self,
984            path: &Path,
985            source_name: &str,
986            _diag: &mut DiagnosticCollector,
987        ) -> Result<ResolvedRef, MarsError> {
988            Ok(ResolvedRef {
989                source_name: source_name.into(),
990                version: None,
991                version_tag: None,
992                commit: None,
993                tree_path: path.to_path_buf(),
994            })
995        }
996    }
997
998    impl ManifestReader for MockProvider {
999        fn read_manifest(
1000            &self,
1001            source_tree: &Path,
1002            _diag: &mut DiagnosticCollector,
1003        ) -> Result<Option<Manifest>, MarsError> {
1004            Ok(self.manifests.get(source_tree).cloned().unwrap_or(None))
1005        }
1006    }
1007
1008    // ========== Helper functions ==========
1009
1010    fn make_config(sources: Vec<(&str, SourceSpec)>) -> EffectiveConfig {
1011        let mut map = IndexMap::new();
1012        for (name, spec) in sources {
1013            map.insert(
1014                name.into(),
1015                EffectiveDependency {
1016                    name: name.into(),
1017                    id: source_id_for_spec(&spec, None),
1018                    spec,
1019                    subpath: None,
1020                    filter: FilterMode::All,
1021                    rename: RenameMap::new(),
1022                    is_overridden: false,
1023                    original_git: None,
1024                },
1025            );
1026        }
1027        EffectiveConfig {
1028            dependencies: map,
1029            settings: Settings::default(),
1030        }
1031    }
1032
1033    fn git_spec(url: &str, version: Option<&str>) -> SourceSpec {
1034        SourceSpec::Git(GitSpec {
1035            url: SourceUrl::from(url),
1036            version: version.map(|s| s.to_string()),
1037        })
1038    }
1039
1040    fn make_manifest(name: &str, version: &str, deps: Vec<(&str, &str, &str)>) -> Manifest {
1041        let mut dependencies = IndexMap::new();
1042        for (dep_name, dep_url, dep_ver) in deps {
1043            dependencies.insert(
1044                dep_name.to_string(),
1045                ManifestDep {
1046                    url: SourceUrl::from(dep_url),
1047                    subpath: None,
1048                    version: Some(dep_ver.to_string()),
1049                    filter: crate::config::FilterConfig::default(),
1050                },
1051            );
1052        }
1053        Manifest {
1054            package: PackageInfo {
1055                name: name.to_string(),
1056                version: version.to_string(),
1057                description: None,
1058            },
1059            dependencies,
1060            models: indexmap::IndexMap::new(),
1061        }
1062    }
1063
1064    fn make_manifest_with_filters(
1065        name: &str,
1066        version: &str,
1067        deps: Vec<(&str, &str, &str, FilterConfig)>,
1068    ) -> Manifest {
1069        let mut dependencies = IndexMap::new();
1070        for (dep_name, dep_url, dep_ver, dep_filter) in deps {
1071            dependencies.insert(
1072                dep_name.to_string(),
1073                ManifestDep {
1074                    url: SourceUrl::from(dep_url),
1075                    subpath: None,
1076                    version: Some(dep_ver.to_string()),
1077                    filter: dep_filter,
1078                },
1079            );
1080        }
1081        Manifest {
1082            package: PackageInfo {
1083                name: name.to_string(),
1084                version: version.to_string(),
1085                description: None,
1086            },
1087            dependencies,
1088            models: indexmap::IndexMap::new(),
1089        }
1090    }
1091
1092    fn default_options() -> ResolveOptions {
1093        ResolveOptions::default()
1094    }
1095
1096    fn resolve(
1097        config: &EffectiveConfig,
1098        provider: &dyn SourceProvider,
1099        locked: Option<&LockFile>,
1100        options: &ResolveOptions,
1101    ) -> Result<ResolvedGraph, MarsError> {
1102        let mut diag = DiagnosticCollector::new();
1103        super::resolve(config, provider, locked, options, &mut diag)
1104    }
1105
1106    fn source_id_for_spec(spec: &SourceSpec, subpath: Option<SourceSubpath>) -> SourceId {
1107        match spec {
1108            SourceSpec::Git(g) => SourceId::git_with_subpath(g.url.clone(), subpath),
1109            SourceSpec::Path(path) => SourceId::Path {
1110                canonical: path.clone(),
1111                subpath,
1112            },
1113        }
1114    }
1115
1116    #[test]
1117    fn apply_subpath_success_case() {
1118        let dir = TempDir::new().unwrap();
1119        let package_root = dir.path().join("plugins/foo");
1120        std::fs::create_dir_all(&package_root).unwrap();
1121
1122        let subpath = SourceSubpath::new("plugins/foo").unwrap();
1123        let rooted = apply_subpath(&SourceName::from("dep"), dir.path(), Some(&subpath)).unwrap();
1124
1125        assert_eq!(rooted.checkout_root, dir.path());
1126        assert_eq!(rooted.package_root, package_root);
1127    }
1128
1129    #[test]
1130    fn apply_subpath_missing_directory_rejection() {
1131        let dir = TempDir::new().unwrap();
1132        let subpath = SourceSubpath::new("plugins/missing").unwrap();
1133
1134        let err = apply_subpath(&SourceName::from("dep"), dir.path(), Some(&subpath))
1135            .unwrap_err()
1136            .to_string();
1137        assert!(
1138            err.contains("does not exist"),
1139            "missing directory should be rejected: {err}"
1140        );
1141    }
1142
1143    #[test]
1144    fn apply_subpath_file_not_dir_rejection() {
1145        let dir = TempDir::new().unwrap();
1146        let file_path = dir.path().join("plugins");
1147        std::fs::write(&file_path, "not a directory").unwrap();
1148        let subpath = SourceSubpath::new("plugins").unwrap();
1149
1150        let err = apply_subpath(&SourceName::from("dep"), dir.path(), Some(&subpath))
1151            .unwrap_err()
1152            .to_string();
1153        assert!(
1154            err.contains("not a directory"),
1155            "file subpath should be rejected: {err}"
1156        );
1157    }
1158
1159    #[cfg(unix)]
1160    #[test]
1161    fn apply_subpath_traversal_rejection() {
1162        let dir = TempDir::new().unwrap();
1163        let outside = TempDir::new().unwrap();
1164        let outside_pkg = outside.path().join("pkg");
1165        std::fs::create_dir_all(&outside_pkg).unwrap();
1166        std::os::unix::fs::symlink(outside.path(), dir.path().join("escape")).unwrap();
1167        let subpath = SourceSubpath::new("escape").unwrap();
1168
1169        let err = apply_subpath(&SourceName::from("dep"), dir.path(), Some(&subpath))
1170            .unwrap_err()
1171            .to_string();
1172        assert!(
1173            err.contains("escapes checkout root"),
1174            "symlink traversal should be rejected: {err}"
1175        );
1176    }
1177
1178    // ========== parse_version_constraint tests ==========
1179
1180    #[test]
1181    fn parse_none_is_latest() {
1182        assert!(matches!(
1183            parse_version_constraint(None),
1184            VersionConstraint::Latest
1185        ));
1186    }
1187
1188    #[test]
1189    fn parse_empty_is_latest() {
1190        assert!(matches!(
1191            parse_version_constraint(Some("")),
1192            VersionConstraint::Latest
1193        ));
1194    }
1195
1196    #[test]
1197    fn parse_latest_string() {
1198        assert!(matches!(
1199            parse_version_constraint(Some("latest")),
1200            VersionConstraint::Latest
1201        ));
1202        assert!(matches!(
1203            parse_version_constraint(Some("LATEST")),
1204            VersionConstraint::Latest
1205        ));
1206    }
1207
1208    #[test]
1209    fn parse_exact_version() {
1210        match parse_version_constraint(Some("v1.2.3")) {
1211            VersionConstraint::Semver(req) => {
1212                assert!(req.matches(&Version::new(1, 2, 3)));
1213                assert!(!req.matches(&Version::new(1, 2, 4)));
1214            }
1215            other => panic!("expected Semver, got {other:?}"),
1216        }
1217    }
1218
1219    #[test]
1220    fn parse_major_version() {
1221        match parse_version_constraint(Some("v2")) {
1222            VersionConstraint::Semver(req) => {
1223                assert!(req.matches(&Version::new(2, 0, 0)));
1224                assert!(req.matches(&Version::new(2, 5, 3)));
1225                assert!(!req.matches(&Version::new(1, 9, 9)));
1226                assert!(!req.matches(&Version::new(3, 0, 0)));
1227            }
1228            other => panic!("expected Semver, got {other:?}"),
1229        }
1230    }
1231
1232    #[test]
1233    fn parse_major_minor_version() {
1234        match parse_version_constraint(Some("v2.1")) {
1235            VersionConstraint::Semver(req) => {
1236                assert!(req.matches(&Version::new(2, 1, 0)));
1237                assert!(req.matches(&Version::new(2, 1, 5)));
1238                assert!(!req.matches(&Version::new(2, 0, 9)));
1239                assert!(!req.matches(&Version::new(2, 2, 0)));
1240            }
1241            other => panic!("expected Semver, got {other:?}"),
1242        }
1243    }
1244
1245    #[test]
1246    fn parse_semver_req_gte() {
1247        match parse_version_constraint(Some(">=0.5.0")) {
1248            VersionConstraint::Semver(req) => {
1249                assert!(req.matches(&Version::new(0, 5, 0)));
1250                assert!(req.matches(&Version::new(1, 0, 0)));
1251                assert!(!req.matches(&Version::new(0, 4, 9)));
1252            }
1253            other => panic!("expected Semver, got {other:?}"),
1254        }
1255    }
1256
1257    #[test]
1258    fn parse_semver_req_caret() {
1259        match parse_version_constraint(Some("^2.0")) {
1260            VersionConstraint::Semver(req) => {
1261                assert!(req.matches(&Version::new(2, 0, 0)));
1262                assert!(req.matches(&Version::new(2, 9, 0)));
1263                assert!(!req.matches(&Version::new(3, 0, 0)));
1264            }
1265            other => panic!("expected Semver, got {other:?}"),
1266        }
1267    }
1268
1269    #[test]
1270    fn parse_semver_req_tilde() {
1271        match parse_version_constraint(Some("~1.2")) {
1272            VersionConstraint::Semver(req) => {
1273                assert!(req.matches(&Version::new(1, 2, 0)));
1274                assert!(req.matches(&Version::new(1, 2, 9)));
1275                assert!(!req.matches(&Version::new(1, 3, 0)));
1276            }
1277            other => panic!("expected Semver, got {other:?}"),
1278        }
1279    }
1280
1281    #[test]
1282    fn parse_branch_ref() {
1283        match parse_version_constraint(Some("main")) {
1284            VersionConstraint::RefPin(ref_name) => {
1285                assert_eq!(ref_name, "main");
1286            }
1287            other => panic!("expected RefPin, got {other:?}"),
1288        }
1289    }
1290
1291    #[test]
1292    fn parse_commit_ref() {
1293        match parse_version_constraint(Some("abc123def456")) {
1294            VersionConstraint::RefPin(ref_name) => {
1295                assert_eq!(ref_name, "abc123def456");
1296            }
1297            other => panic!("expected RefPin, got {other:?}"),
1298        }
1299    }
1300
1301    // ========== Resolution tests ==========
1302
1303    #[test]
1304    fn single_source_no_deps() {
1305        let dir = TempDir::new().unwrap();
1306        let tree = dir.path().join("source-a");
1307        std::fs::create_dir_all(&tree).unwrap();
1308
1309        let mut provider = MockProvider::new();
1310        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0), (1, 1, 0)]);
1311        provider.add_source("a", tree, None);
1312
1313        let config = make_config(vec![(
1314            "a",
1315            git_spec("https://example.com/a.git", Some("^1.0")),
1316        )]);
1317
1318        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1319
1320        assert_eq!(graph.nodes.len(), 1);
1321        assert!(graph.nodes.contains_key("a"));
1322        assert_eq!(graph.order.len(), 1);
1323        assert_eq!(graph.order[0], "a");
1324
1325        // MVS: should pick 1.0.0 (minimum)
1326        let node = &graph.nodes["a"];
1327        assert_eq!(node.resolved_ref.version, Some(Version::new(1, 0, 0)));
1328    }
1329
1330    #[test]
1331    fn two_sources_no_deps() {
1332        let dir = TempDir::new().unwrap();
1333        let tree_a = dir.path().join("a");
1334        let tree_b = dir.path().join("b");
1335        std::fs::create_dir_all(&tree_a).unwrap();
1336        std::fs::create_dir_all(&tree_b).unwrap();
1337
1338        let mut provider = MockProvider::new();
1339        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1340        provider.add_versions("https://example.com/b.git", vec![(2, 0, 0)]);
1341        provider.add_source("a", tree_a, None);
1342        provider.add_source("b", tree_b, None);
1343
1344        let config = make_config(vec![
1345            ("a", git_spec("https://example.com/a.git", Some("v1.0.0"))),
1346            ("b", git_spec("https://example.com/b.git", Some("v2.0.0"))),
1347        ]);
1348
1349        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1350
1351        assert_eq!(graph.nodes.len(), 2);
1352        assert_eq!(graph.order.len(), 2);
1353        // Both should be in the order (either order is valid since no deps)
1354        assert!(graph.order.contains(&"a".into()));
1355        assert!(graph.order.contains(&"b".into()));
1356    }
1357
1358    #[test]
1359    fn source_with_transitive_dep() {
1360        let dir = TempDir::new().unwrap();
1361        let tree_a = dir.path().join("a");
1362        let tree_dep = dir.path().join("dep");
1363        std::fs::create_dir_all(&tree_a).unwrap();
1364        std::fs::create_dir_all(&tree_dep).unwrap();
1365
1366        let manifest_a = make_manifest(
1367            "a",
1368            "1.0.0",
1369            vec![("dep", "https://example.com/dep.git", ">=0.5.0")],
1370        );
1371
1372        let mut provider = MockProvider::new();
1373        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1374        provider.add_versions(
1375            "https://example.com/dep.git",
1376            vec![(0, 4, 0), (0, 5, 0), (0, 6, 0), (1, 0, 0)],
1377        );
1378        provider.add_source("a", tree_a, Some(manifest_a));
1379        provider.add_source("dep", tree_dep, None);
1380
1381        let config = make_config(vec![(
1382            "a",
1383            git_spec("https://example.com/a.git", Some("v1.0.0")),
1384        )]);
1385
1386        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1387
1388        // Should have both 'a' and 'dep'
1389        assert_eq!(graph.nodes.len(), 2);
1390        assert!(graph.nodes.contains_key("a"));
1391        assert!(graph.nodes.contains_key("dep"));
1392
1393        // Dep should be resolved to minimum satisfying >=0.5.0 → 0.5.0
1394        let dep_node = &graph.nodes["dep"];
1395        assert_eq!(dep_node.resolved_ref.version, Some(Version::new(0, 5, 0)));
1396
1397        // Topological order: dep before a
1398        let dep_pos = graph.order.iter().position(|n| n == "dep").unwrap();
1399        let a_pos = graph.order.iter().position(|n| n == "a").unwrap();
1400        assert!(dep_pos < a_pos, "dep should come before a in topo order");
1401    }
1402
1403    #[test]
1404    fn duplicate_source_identity_detects_same_url_and_subpath() {
1405        let dir = TempDir::new().unwrap();
1406        let tree_a = dir.path().join("a");
1407        std::fs::create_dir_all(tree_a.join("plugins/foo")).unwrap();
1408
1409        let mut provider = MockProvider::new();
1410        provider.add_versions("https://example.com/shared.git", vec![(1, 0, 0)]);
1411        provider.add_source("a", tree_a, None);
1412
1413        let subpath = SourceSubpath::new("plugins/foo").unwrap();
1414        let mut dependencies = IndexMap::new();
1415        dependencies.insert(
1416            SourceName::from("a"),
1417            EffectiveDependency {
1418                name: "a".into(),
1419                id: SourceId::git_with_subpath(
1420                    SourceUrl::from("https://example.com/shared.git"),
1421                    Some(subpath.clone()),
1422                ),
1423                spec: git_spec("https://example.com/shared.git", Some("v1.0.0")),
1424                subpath: Some(subpath.clone()),
1425                filter: FilterMode::All,
1426                rename: RenameMap::new(),
1427                is_overridden: false,
1428                original_git: None,
1429            },
1430        );
1431        dependencies.insert(
1432            SourceName::from("b"),
1433            EffectiveDependency {
1434                name: "b".into(),
1435                id: SourceId::git_with_subpath(
1436                    SourceUrl::from("https://example.com/shared.git"),
1437                    Some(subpath.clone()),
1438                ),
1439                spec: git_spec("https://example.com/shared.git", Some("v1.0.0")),
1440                subpath: Some(subpath),
1441                filter: FilterMode::All,
1442                rename: RenameMap::new(),
1443                is_overridden: false,
1444                original_git: None,
1445            },
1446        );
1447        let config = EffectiveConfig {
1448            dependencies,
1449            settings: Settings::default(),
1450        };
1451
1452        let err = resolve(&config, &provider, None, &default_options())
1453            .unwrap_err()
1454            .to_string();
1455        assert!(
1456            err.contains("duplicate source identity"),
1457            "expected duplicate identity error: {err}"
1458        );
1459    }
1460
1461    #[test]
1462    fn source_identity_mismatch_detects_different_subpaths_for_same_name() {
1463        let dir = TempDir::new().unwrap();
1464        let tree_a = dir.path().join("a");
1465        let tree_dep = dir.path().join("dep");
1466        std::fs::create_dir_all(&tree_a).unwrap();
1467        std::fs::create_dir_all(tree_dep.join("plugins/foo")).unwrap();
1468        std::fs::create_dir_all(tree_dep.join("plugins/bar")).unwrap();
1469
1470        let mut manifest_deps = IndexMap::new();
1471        manifest_deps.insert(
1472            "dep".to_string(),
1473            ManifestDep {
1474                url: SourceUrl::from("https://example.com/dep.git"),
1475                subpath: Some(SourceSubpath::new("plugins/bar").unwrap()),
1476                version: Some(">=1.0.0".to_string()),
1477                filter: FilterConfig::default(),
1478            },
1479        );
1480        let manifest_a = Manifest {
1481            package: PackageInfo {
1482                name: "a".to_string(),
1483                version: "1.0.0".to_string(),
1484                description: None,
1485            },
1486            dependencies: manifest_deps,
1487            models: IndexMap::new(),
1488        };
1489
1490        let mut provider = MockProvider::new();
1491        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1492        provider.add_versions("https://example.com/dep.git", vec![(1, 0, 0)]);
1493        provider.add_source("a", tree_a, Some(manifest_a));
1494        provider.add_source("dep", tree_dep, None);
1495
1496        let mut dependencies = IndexMap::new();
1497        dependencies.insert(
1498            SourceName::from("a"),
1499            EffectiveDependency {
1500                name: "a".into(),
1501                id: SourceId::git(SourceUrl::from("https://example.com/a.git")),
1502                spec: git_spec("https://example.com/a.git", Some("v1.0.0")),
1503                subpath: None,
1504                filter: FilterMode::All,
1505                rename: RenameMap::new(),
1506                is_overridden: false,
1507                original_git: None,
1508            },
1509        );
1510        dependencies.insert(
1511            SourceName::from("dep"),
1512            EffectiveDependency {
1513                name: "dep".into(),
1514                id: SourceId::git_with_subpath(
1515                    SourceUrl::from("https://example.com/dep.git"),
1516                    Some(SourceSubpath::new("plugins/foo").unwrap()),
1517                ),
1518                spec: git_spec("https://example.com/dep.git", Some("v1.0.0")),
1519                subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1520                filter: FilterMode::All,
1521                rename: RenameMap::new(),
1522                is_overridden: false,
1523                original_git: None,
1524            },
1525        );
1526        let config = EffectiveConfig {
1527            dependencies,
1528            settings: Settings::default(),
1529        };
1530
1531        let err = resolve(&config, &provider, None, &default_options())
1532            .unwrap_err()
1533            .to_string();
1534        assert!(
1535            err.contains("conflicting identities"),
1536            "expected identity mismatch error: {err}"
1537        );
1538    }
1539
1540    #[test]
1541    fn transitive_dep_propagates_subpath_into_source_identity() {
1542        let dir = TempDir::new().unwrap();
1543        let tree_a = dir.path().join("a");
1544        let tree_dep = dir.path().join("dep");
1545        std::fs::create_dir_all(&tree_a).unwrap();
1546        std::fs::create_dir_all(tree_dep.join("plugins/foo")).unwrap();
1547
1548        let mut manifest_deps = IndexMap::new();
1549        manifest_deps.insert(
1550            "dep".to_string(),
1551            ManifestDep {
1552                url: SourceUrl::from("https://example.com/dep.git"),
1553                subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1554                version: Some(">=1.0.0".to_string()),
1555                filter: FilterConfig::default(),
1556            },
1557        );
1558        let manifest_a = Manifest {
1559            package: PackageInfo {
1560                name: "a".to_string(),
1561                version: "1.0.0".to_string(),
1562                description: None,
1563            },
1564            dependencies: manifest_deps,
1565            models: IndexMap::new(),
1566        };
1567
1568        let mut provider = MockProvider::new();
1569        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1570        provider.add_versions("https://example.com/dep.git", vec![(1, 0, 0)]);
1571        provider.add_source("a", tree_a, Some(manifest_a));
1572        provider.add_source("dep", tree_dep.clone(), None);
1573
1574        let config = make_config(vec![(
1575            "a",
1576            git_spec("https://example.com/a.git", Some("v1.0.0")),
1577        )]);
1578        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1579
1580        let dep_node = graph.nodes.get("dep").expect("dep should be resolved");
1581        assert_eq!(
1582            dep_node.source_id,
1583            SourceId::git_with_subpath(
1584                SourceUrl::from("https://example.com/dep.git"),
1585                Some(SourceSubpath::new("plugins/foo").unwrap())
1586            )
1587        );
1588        assert_eq!(
1589            dep_node.rooted_ref.package_root,
1590            tree_dep.join("plugins/foo")
1591        );
1592    }
1593
1594    #[test]
1595    fn transitive_dep_filter_is_collected() {
1596        let dir = TempDir::new().unwrap();
1597        let tree_a = dir.path().join("a");
1598        let tree_dep = dir.path().join("dep");
1599        std::fs::create_dir_all(&tree_a).unwrap();
1600        std::fs::create_dir_all(&tree_dep).unwrap();
1601
1602        let manifest_a = make_manifest_with_filters(
1603            "a",
1604            "1.0.0",
1605            vec![(
1606                "dep",
1607                "https://example.com/dep.git",
1608                ">=1.0.0",
1609                FilterConfig {
1610                    skills: Some(vec!["frontend-design".into()]),
1611                    ..FilterConfig::default()
1612                },
1613            )],
1614        );
1615
1616        let mut provider = MockProvider::new();
1617        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1618        provider.add_versions("https://example.com/dep.git", vec![(1, 0, 0)]);
1619        provider.add_source("a", tree_a, Some(manifest_a));
1620        provider.add_source("dep", tree_dep, None);
1621
1622        let config = make_config(vec![(
1623            "a",
1624            git_spec("https://example.com/a.git", Some("v1.0.0")),
1625        )]);
1626
1627        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1628        assert_eq!(
1629            graph.filters.get(&SourceName::from("dep")),
1630            Some(&vec![FilterMode::Include {
1631                agents: vec![],
1632                skills: vec!["frontend-design".into()],
1633            }])
1634        );
1635    }
1636
1637    #[test]
1638    fn direct_and_transitive_filters_are_both_collected_for_same_source() {
1639        let dir = TempDir::new().unwrap();
1640        let tree_a = dir.path().join("a");
1641        let tree_dep = dir.path().join("dep");
1642        std::fs::create_dir_all(&tree_a).unwrap();
1643        std::fs::create_dir_all(&tree_dep).unwrap();
1644
1645        let manifest_a = make_manifest_with_filters(
1646            "a",
1647            "1.0.0",
1648            vec![(
1649                "dep",
1650                "https://example.com/dep.git",
1651                ">=1.0.0",
1652                FilterConfig {
1653                    skills: Some(vec!["skill-b".into(), "skill-c".into()]),
1654                    ..FilterConfig::default()
1655                },
1656            )],
1657        );
1658
1659        let mut provider = MockProvider::new();
1660        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1661        provider.add_versions("https://example.com/dep.git", vec![(1, 0, 0)]);
1662        provider.add_source("a", tree_a, Some(manifest_a));
1663        provider.add_source("dep", tree_dep, None);
1664
1665        let mut dependencies = IndexMap::new();
1666        dependencies.insert(
1667            SourceName::from("a"),
1668            EffectiveDependency {
1669                name: "a".into(),
1670                id: SourceId::git(SourceUrl::from("https://example.com/a.git")),
1671                spec: git_spec("https://example.com/a.git", Some("v1.0.0")),
1672                subpath: None,
1673                filter: FilterMode::All,
1674                rename: RenameMap::new(),
1675                is_overridden: false,
1676                original_git: None,
1677            },
1678        );
1679        dependencies.insert(
1680            SourceName::from("dep"),
1681            EffectiveDependency {
1682                name: "dep".into(),
1683                id: SourceId::git(SourceUrl::from("https://example.com/dep.git")),
1684                spec: git_spec("https://example.com/dep.git", Some("v1.0.0")),
1685                subpath: None,
1686                filter: FilterMode::Include {
1687                    agents: vec![],
1688                    skills: vec!["skill-a".into(), "skill-b".into()],
1689                },
1690                rename: RenameMap::new(),
1691                is_overridden: false,
1692                original_git: None,
1693            },
1694        );
1695        let config = EffectiveConfig {
1696            dependencies,
1697            settings: Settings::default(),
1698        };
1699
1700        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1701        let filters = graph.filters.get(&SourceName::from("dep")).unwrap();
1702        assert_eq!(filters.len(), 2);
1703        assert!(filters.contains(&FilterMode::Include {
1704            agents: vec![],
1705            skills: vec!["skill-a".into(), "skill-b".into()],
1706        }));
1707        assert!(filters.contains(&FilterMode::Include {
1708            agents: vec![],
1709            skills: vec!["skill-b".into(), "skill-c".into()],
1710        }));
1711    }
1712
1713    #[test]
1714    fn compatible_constraints_from_two_dependents() {
1715        let dir = TempDir::new().unwrap();
1716        let tree_a = dir.path().join("a");
1717        let tree_b = dir.path().join("b");
1718        let tree_shared = dir.path().join("shared");
1719        std::fs::create_dir_all(&tree_a).unwrap();
1720        std::fs::create_dir_all(&tree_b).unwrap();
1721        std::fs::create_dir_all(&tree_shared).unwrap();
1722
1723        // Both a and b depend on shared with the same constraint.
1724        // The resolved version must satisfy both.
1725        let manifest_a = make_manifest(
1726            "a",
1727            "1.0.0",
1728            vec![("shared", "https://example.com/shared.git", ">=1.0.0")],
1729        );
1730        let manifest_b = make_manifest(
1731            "b",
1732            "1.0.0",
1733            vec![("shared", "https://example.com/shared.git", ">=1.0.0")],
1734        );
1735
1736        let mut provider = MockProvider::new();
1737        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1738        provider.add_versions("https://example.com/b.git", vec![(1, 0, 0)]);
1739        provider.add_versions(
1740            "https://example.com/shared.git",
1741            vec![(1, 0, 0), (1, 2, 0), (1, 5, 0), (2, 0, 0)],
1742        );
1743        provider.add_source("a", tree_a, Some(manifest_a));
1744        provider.add_source("b", tree_b, Some(manifest_b));
1745        provider.add_source("shared", tree_shared, None);
1746
1747        let config = make_config(vec![
1748            ("a", git_spec("https://example.com/a.git", Some("v1.0.0"))),
1749            ("b", git_spec("https://example.com/b.git", Some("v1.0.0"))),
1750        ]);
1751
1752        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
1753
1754        assert_eq!(graph.nodes.len(), 3);
1755        // MVS with >=1.0.0 from both → picks 1.0.0 (minimum satisfying all)
1756        let shared_node = &graph.nodes["shared"];
1757        assert_eq!(
1758            shared_node.resolved_ref.version,
1759            Some(Version::new(1, 0, 0))
1760        );
1761    }
1762
1763    #[test]
1764    fn narrower_second_constraint_causes_validation_error() {
1765        let dir = TempDir::new().unwrap();
1766        let tree_a = dir.path().join("a");
1767        let tree_b = dir.path().join("b");
1768        let tree_shared = dir.path().join("shared");
1769        std::fs::create_dir_all(&tree_a).unwrap();
1770        std::fs::create_dir_all(&tree_b).unwrap();
1771        std::fs::create_dir_all(&tree_shared).unwrap();
1772
1773        // a requires shared >=1.0.0, b requires shared >=1.5.0
1774        // First resolver picks 1.0.0 (MVS), then validation catches >=1.5.0 failure
1775        let manifest_a = make_manifest(
1776            "a",
1777            "1.0.0",
1778            vec![("shared", "https://example.com/shared.git", ">=1.0.0")],
1779        );
1780        let manifest_b = make_manifest(
1781            "b",
1782            "1.0.0",
1783            vec![("shared", "https://example.com/shared.git", ">=1.5.0")],
1784        );
1785
1786        let mut provider = MockProvider::new();
1787        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1788        provider.add_versions("https://example.com/b.git", vec![(1, 0, 0)]);
1789        provider.add_versions(
1790            "https://example.com/shared.git",
1791            vec![(1, 0, 0), (1, 2, 0), (1, 5, 0), (2, 0, 0)],
1792        );
1793        provider.add_source("a", tree_a, Some(manifest_a));
1794        provider.add_source("b", tree_b, Some(manifest_b));
1795        provider.add_source("shared", tree_shared, None);
1796
1797        let config = make_config(vec![
1798            ("a", git_spec("https://example.com/a.git", Some("v1.0.0"))),
1799            ("b", git_spec("https://example.com/b.git", Some("v1.0.0"))),
1800        ]);
1801
1802        // This should fail because MVS picked 1.0.0 but b needs >=1.5.0
1803        let result = resolve(&config, &provider, None, &default_options());
1804        assert!(result.is_err());
1805        let err = result.unwrap_err().to_string();
1806        assert!(
1807            err.contains("shared"),
1808            "error should mention 'shared': {err}"
1809        );
1810        assert!(
1811            err.contains("1.5.0"),
1812            "error should mention the constraint: {err}"
1813        );
1814    }
1815
1816    #[test]
1817    fn incompatible_constraints_produce_error() {
1818        let dir = TempDir::new().unwrap();
1819        let tree_a = dir.path().join("a");
1820        let tree_b = dir.path().join("b");
1821        let tree_shared = dir.path().join("shared");
1822        std::fs::create_dir_all(&tree_a).unwrap();
1823        std::fs::create_dir_all(&tree_b).unwrap();
1824        std::fs::create_dir_all(&tree_shared).unwrap();
1825
1826        // a requires shared >=2.0.0, b requires shared <1.0.0 — incompatible
1827        let manifest_a = make_manifest(
1828            "a",
1829            "1.0.0",
1830            vec![("shared", "https://example.com/shared.git", ">=2.0.0")],
1831        );
1832        let manifest_b = make_manifest(
1833            "b",
1834            "1.0.0",
1835            vec![("shared", "https://example.com/shared.git", "<1.0.0")],
1836        );
1837
1838        let mut provider = MockProvider::new();
1839        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1840        provider.add_versions("https://example.com/b.git", vec![(1, 0, 0)]);
1841        provider.add_versions(
1842            "https://example.com/shared.git",
1843            vec![(0, 5, 0), (1, 0, 0), (2, 0, 0)],
1844        );
1845        provider.add_source("a", tree_a, Some(manifest_a));
1846        provider.add_source("b", tree_b, Some(manifest_b));
1847        provider.add_source("shared", tree_shared, None);
1848
1849        let config = make_config(vec![
1850            ("a", git_spec("https://example.com/a.git", Some("v1.0.0"))),
1851            ("b", git_spec("https://example.com/b.git", Some("v1.0.0"))),
1852        ]);
1853
1854        let result = resolve(&config, &provider, None, &default_options());
1855        assert!(result.is_err());
1856        let err = result.unwrap_err().to_string();
1857        assert!(
1858            err.contains("shared"),
1859            "error should mention the conflicting source: {err}"
1860        );
1861    }
1862
1863    #[test]
1864    fn cycle_detected() {
1865        let dir = TempDir::new().unwrap();
1866        let tree_a = dir.path().join("a");
1867        let tree_b = dir.path().join("b");
1868        std::fs::create_dir_all(&tree_a).unwrap();
1869        std::fs::create_dir_all(&tree_b).unwrap();
1870
1871        // a depends on b, b depends on a → cycle
1872        let manifest_a = make_manifest(
1873            "a",
1874            "1.0.0",
1875            vec![("b", "https://example.com/b.git", ">=1.0.0")],
1876        );
1877        let manifest_b = make_manifest(
1878            "b",
1879            "1.0.0",
1880            vec![("a", "https://example.com/a.git", ">=1.0.0")],
1881        );
1882
1883        let mut provider = MockProvider::new();
1884        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
1885        provider.add_versions("https://example.com/b.git", vec![(1, 0, 0)]);
1886        provider.add_source("a", tree_a, Some(manifest_a));
1887        provider.add_source("b", tree_b, Some(manifest_b));
1888
1889        let config = make_config(vec![(
1890            "a",
1891            git_spec("https://example.com/a.git", Some("v1.0.0")),
1892        )]);
1893
1894        let result = resolve(&config, &provider, None, &default_options());
1895        assert!(result.is_err());
1896        let err = result.unwrap_err().to_string();
1897        assert!(
1898            err.contains("cycle") || err.contains("Cycle"),
1899            "error should mention cycle: {err}"
1900        );
1901    }
1902
1903    #[test]
1904    fn locked_version_preferred_when_satisfies_constraint() {
1905        let dir = TempDir::new().unwrap();
1906        let tree = dir.path().join("a");
1907        std::fs::create_dir_all(&tree).unwrap();
1908
1909        let mut provider = MockProvider::new();
1910        provider.add_versions(
1911            "https://example.com/a.git",
1912            vec![(1, 0, 0), (1, 1, 0), (1, 2, 0)],
1913        );
1914        provider.add_source("a", tree, None);
1915
1916        let config = make_config(vec![(
1917            "a",
1918            git_spec("https://example.com/a.git", Some("^1.0")),
1919        )]);
1920
1921        // Lock file says v1.1.0
1922        let mut lock = LockFile::empty();
1923        lock.dependencies.insert(
1924            "a".into(),
1925            crate::lock::LockedSource {
1926                url: Some("https://example.com/a.git".into()),
1927                path: None,
1928                subpath: None,
1929                version: Some("v1.1.0".into()),
1930                commit: Some("abc".into()),
1931                tree_hash: None,
1932            },
1933        );
1934
1935        let graph = resolve(&config, &provider, Some(&lock), &default_options()).unwrap();
1936        let node = &graph.nodes["a"];
1937        // Should prefer locked version 1.1.0 over MVS minimum 1.0.0
1938        assert_eq!(node.resolved_ref.version, Some(Version::new(1, 1, 0)));
1939    }
1940
1941    #[test]
1942    fn locked_version_ignored_when_constraint_changed() {
1943        let dir = TempDir::new().unwrap();
1944        let tree = dir.path().join("a");
1945        std::fs::create_dir_all(&tree).unwrap();
1946
1947        let mut provider = MockProvider::new();
1948        provider.add_versions(
1949            "https://example.com/a.git",
1950            vec![(1, 0, 0), (2, 0, 0), (2, 1, 0)],
1951        );
1952        provider.add_source("a", tree, None);
1953
1954        // Config now requires ^2.0
1955        let config = make_config(vec![(
1956            "a",
1957            git_spec("https://example.com/a.git", Some("^2.0")),
1958        )]);
1959
1960        // Lock file says v1.0.0 — no longer satisfies ^2.0
1961        let mut lock = LockFile::empty();
1962        lock.dependencies.insert(
1963            "a".into(),
1964            crate::lock::LockedSource {
1965                url: Some("https://example.com/a.git".into()),
1966                path: None,
1967                subpath: None,
1968                version: Some("v1.0.0".into()),
1969                commit: Some("abc".into()),
1970                tree_hash: None,
1971            },
1972        );
1973
1974        let graph = resolve(&config, &provider, Some(&lock), &default_options()).unwrap();
1975        let node = &graph.nodes["a"];
1976        // Locked version doesn't satisfy ^2.0, so MVS picks 2.0.0
1977        assert_eq!(node.resolved_ref.version, Some(Version::new(2, 0, 0)));
1978    }
1979
1980    #[test]
1981    fn locked_commit_is_used_when_reachable() {
1982        let dir = TempDir::new().unwrap();
1983        let tree = dir.path().join("a");
1984        std::fs::create_dir_all(&tree).unwrap();
1985
1986        let mut provider = MockProvider::new();
1987        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0), (1, 1, 0)]);
1988        provider.add_source("a", tree, None);
1989
1990        let config = make_config(vec![(
1991            "a",
1992            git_spec("https://example.com/a.git", Some("^1.0")),
1993        )]);
1994
1995        let locked_commit = "locked-sha-123";
1996        let mut lock = LockFile::empty();
1997        lock.dependencies.insert(
1998            "a".into(),
1999            crate::lock::LockedSource {
2000                url: Some("https://example.com/a.git".into()),
2001                path: None,
2002                subpath: None,
2003                version: Some("v1.1.0".into()),
2004                commit: Some(locked_commit.into()),
2005                tree_hash: None,
2006            },
2007        );
2008
2009        let graph = resolve(&config, &provider, Some(&lock), &default_options()).unwrap();
2010        assert_eq!(
2011            graph.nodes["a"].resolved_ref.commit.as_deref(),
2012            Some(locked_commit)
2013        );
2014        assert_eq!(
2015            provider.seen_preferred_commits(),
2016            vec![Some(locked_commit.to_string())]
2017        );
2018    }
2019
2020    #[test]
2021    fn normal_mode_falls_back_when_locked_commit_unreachable() {
2022        let dir = TempDir::new().unwrap();
2023        let tree = dir.path().join("a");
2024        std::fs::create_dir_all(&tree).unwrap();
2025
2026        let mut provider = MockProvider::new();
2027        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0), (1, 1, 0)]);
2028        provider.add_source("a", tree, None);
2029
2030        let config = make_config(vec![(
2031            "a",
2032            git_spec("https://example.com/a.git", Some("^1.0")),
2033        )]);
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                subpath: None,
2045                version: Some("v1.1.0".into()),
2046                commit: Some(unreachable_commit.into()),
2047                tree_hash: None,
2048            },
2049        );
2050
2051        let graph = resolve(&config, &provider, Some(&lock), &default_options()).unwrap();
2052        assert_eq!(
2053            graph.nodes["a"].resolved_ref.version,
2054            Some(Version::new(1, 1, 0))
2055        );
2056        assert_eq!(
2057            graph.nodes["a"].resolved_ref.commit.as_deref(),
2058            Some("mock-commit")
2059        );
2060        assert_eq!(
2061            provider.seen_preferred_commits(),
2062            vec![Some(unreachable_commit.to_string()), None]
2063        );
2064    }
2065
2066    #[test]
2067    fn frozen_mode_errors_when_locked_commit_unreachable() {
2068        let dir = TempDir::new().unwrap();
2069        let tree = dir.path().join("a");
2070        std::fs::create_dir_all(&tree).unwrap();
2071
2072        let mut provider = MockProvider::new();
2073        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0), (1, 1, 0)]);
2074        provider.add_source("a", tree, None);
2075
2076        let config = make_config(vec![(
2077            "a",
2078            git_spec("https://example.com/a.git", Some("^1.0")),
2079        )]);
2080
2081        let unreachable_commit = "missing-locked-sha";
2082        provider.mark_unreachable_preferred_commit(unreachable_commit);
2083
2084        let mut lock = LockFile::empty();
2085        lock.dependencies.insert(
2086            "a".into(),
2087            crate::lock::LockedSource {
2088                url: Some("https://example.com/a.git".into()),
2089                path: None,
2090                subpath: None,
2091                version: Some("v1.1.0".into()),
2092                commit: Some(unreachable_commit.into()),
2093                tree_hash: None,
2094            },
2095        );
2096
2097        let options = ResolveOptions {
2098            frozen: true,
2099            ..default_options()
2100        };
2101        let result = resolve(&config, &provider, Some(&lock), &options);
2102        assert!(matches!(
2103            result,
2104            Err(MarsError::LockedCommitUnreachable { .. })
2105        ));
2106        assert_eq!(
2107            provider.seen_preferred_commits(),
2108            vec![Some(unreachable_commit.to_string())]
2109        );
2110    }
2111
2112    #[test]
2113    fn maximize_mode_ignores_locked_commit() {
2114        let dir = TempDir::new().unwrap();
2115        let tree = dir.path().join("a");
2116        std::fs::create_dir_all(&tree).unwrap();
2117
2118        let mut provider = MockProvider::new();
2119        provider.add_versions(
2120            "https://example.com/a.git",
2121            vec![(1, 0, 0), (1, 1, 0), (1, 2, 0)],
2122        );
2123        provider.add_source("a", tree, None);
2124
2125        let config = make_config(vec![(
2126            "a",
2127            git_spec("https://example.com/a.git", Some("^1.0")),
2128        )]);
2129
2130        let unreachable_commit = "missing-locked-sha";
2131        provider.mark_unreachable_preferred_commit(unreachable_commit);
2132
2133        let mut lock = LockFile::empty();
2134        lock.dependencies.insert(
2135            "a".into(),
2136            crate::lock::LockedSource {
2137                url: Some("https://example.com/a.git".into()),
2138                path: None,
2139                subpath: None,
2140                version: Some("v1.0.0".into()),
2141                commit: Some(unreachable_commit.into()),
2142                tree_hash: None,
2143            },
2144        );
2145
2146        let options = ResolveOptions {
2147            maximize: true,
2148            upgrade_targets: HashSet::new(),
2149            bump_direct_constraints: false,
2150            frozen: false,
2151        };
2152        let graph = resolve(&config, &provider, Some(&lock), &options).unwrap();
2153        assert_eq!(
2154            graph.nodes["a"].resolved_ref.version,
2155            Some(Version::new(1, 2, 0))
2156        );
2157        assert_eq!(provider.seen_preferred_commits(), vec![None]);
2158    }
2159
2160    #[test]
2161    fn latest_resolves_to_newest() {
2162        let dir = TempDir::new().unwrap();
2163        let tree = dir.path().join("a");
2164        std::fs::create_dir_all(&tree).unwrap();
2165
2166        let mut provider = MockProvider::new();
2167        provider.add_versions(
2168            "https://example.com/a.git",
2169            vec![(1, 0, 0), (2, 0, 0), (3, 0, 0)],
2170        );
2171        provider.add_source("a", tree, None);
2172
2173        let config = make_config(vec![(
2174            "a",
2175            git_spec("https://example.com/a.git", Some("latest")),
2176        )]);
2177
2178        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
2179        let node = &graph.nodes["a"];
2180        // "latest" has no constraint, MVS picks minimum → 1.0.0
2181        // Actually, "latest" means any version. With MVS, minimum is 1.0.0.
2182        // But "latest" semantically means newest. Let me check the spec...
2183        // The spec says "@latest as any version (newest wins)"
2184        // So latest should pick the newest. Let me handle this in select_version.
2185        assert_eq!(node.resolved_ref.version, Some(Version::new(3, 0, 0)));
2186        assert_eq!(node.latest_version, Some(Version::new(3, 0, 0)));
2187    }
2188
2189    #[test]
2190    fn v2_resolves_to_major_range() {
2191        let dir = TempDir::new().unwrap();
2192        let tree = dir.path().join("a");
2193        std::fs::create_dir_all(&tree).unwrap();
2194
2195        let mut provider = MockProvider::new();
2196        provider.add_versions(
2197            "https://example.com/a.git",
2198            vec![(1, 9, 0), (2, 0, 0), (2, 1, 0), (2, 5, 0), (3, 0, 0)],
2199        );
2200        provider.add_source("a", tree, None);
2201
2202        let config = make_config(vec![(
2203            "a",
2204            git_spec("https://example.com/a.git", Some("v2")),
2205        )]);
2206
2207        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
2208        let node = &graph.nodes["a"];
2209        // v2 → >=2.0.0, <3.0.0, MVS picks minimum → 2.0.0
2210        assert_eq!(node.resolved_ref.version, Some(Version::new(2, 0, 0)));
2211    }
2212
2213    #[test]
2214    fn branch_ref_resolves_without_semver() {
2215        let dir = TempDir::new().unwrap();
2216        let tree = dir.path().join("a");
2217        std::fs::create_dir_all(&tree).unwrap();
2218
2219        let mut provider = MockProvider::new();
2220        provider.add_source("a", tree, None);
2221
2222        let config = make_config(vec![(
2223            "a",
2224            git_spec("https://example.com/a.git", Some("main")),
2225        )]);
2226
2227        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
2228        let node = &graph.nodes["a"];
2229        assert!(node.resolved_ref.version.is_none());
2230        assert!(node.latest_version.is_none());
2231        assert_eq!(node.resolved_ref.commit, Some("ref:main".into()));
2232    }
2233
2234    #[test]
2235    fn source_without_manifest_has_no_transitive_deps() {
2236        let dir = TempDir::new().unwrap();
2237        let tree = dir.path().join("a");
2238        std::fs::create_dir_all(&tree).unwrap();
2239
2240        let mut provider = MockProvider::new();
2241        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
2242        provider.add_source("a", tree, None); // No manifest
2243
2244        let config = make_config(vec![(
2245            "a",
2246            git_spec("https://example.com/a.git", Some("v1.0.0")),
2247        )]);
2248
2249        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
2250        assert_eq!(graph.nodes.len(), 1);
2251        assert!(graph.nodes["a"].deps.is_empty());
2252    }
2253
2254    #[test]
2255    fn path_source_resolves_without_version() {
2256        let dir = TempDir::new().unwrap();
2257        let tree = dir.path().join("local-source");
2258        std::fs::create_dir_all(&tree).unwrap();
2259
2260        let mut provider = MockProvider::new();
2261        provider.add_source("local", tree.clone(), None);
2262
2263        let config = make_config(vec![("local", SourceSpec::Path(tree))]);
2264
2265        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
2266        assert_eq!(graph.nodes.len(), 1);
2267        let node = &graph.nodes["local"];
2268        assert!(node.resolved_ref.version.is_none());
2269        assert!(node.latest_version.is_none());
2270    }
2271
2272    #[test]
2273    fn maximize_mode_picks_newest() {
2274        let dir = TempDir::new().unwrap();
2275        let tree = dir.path().join("a");
2276        std::fs::create_dir_all(&tree).unwrap();
2277
2278        let mut provider = MockProvider::new();
2279        provider.add_versions(
2280            "https://example.com/a.git",
2281            vec![(1, 0, 0), (1, 5, 0), (1, 9, 0)],
2282        );
2283        provider.add_source("a", tree, None);
2284
2285        let config = make_config(vec![(
2286            "a",
2287            git_spec("https://example.com/a.git", Some("^1.0")),
2288        )]);
2289
2290        let options = ResolveOptions {
2291            maximize: true,
2292            upgrade_targets: HashSet::new(),
2293            bump_direct_constraints: false,
2294            frozen: false,
2295        };
2296
2297        let graph = resolve(&config, &provider, None, &options).unwrap();
2298        let node = &graph.nodes["a"];
2299        assert_eq!(node.resolved_ref.version, Some(Version::new(1, 9, 0)));
2300    }
2301
2302    #[test]
2303    fn maximize_with_specific_targets() {
2304        let dir = TempDir::new().unwrap();
2305        let tree_a = dir.path().join("a");
2306        let tree_b = dir.path().join("b");
2307        std::fs::create_dir_all(&tree_a).unwrap();
2308        std::fs::create_dir_all(&tree_b).unwrap();
2309
2310        let mut provider = MockProvider::new();
2311        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0), (1, 5, 0)]);
2312        provider.add_versions("https://example.com/b.git", vec![(2, 0, 0), (2, 5, 0)]);
2313        provider.add_source("a", tree_a, None);
2314        provider.add_source("b", tree_b, None);
2315
2316        let config = make_config(vec![
2317            ("a", git_spec("https://example.com/a.git", Some("^1.0"))),
2318            ("b", git_spec("https://example.com/b.git", Some("^2.0"))),
2319        ]);
2320
2321        // Only upgrade "a", not "b"
2322        let options = ResolveOptions {
2323            maximize: true,
2324            upgrade_targets: HashSet::from(["a".into()]),
2325            bump_direct_constraints: false,
2326            frozen: false,
2327        };
2328
2329        let graph = resolve(&config, &provider, None, &options).unwrap();
2330        // "a" should be maximized → 1.5.0
2331        assert_eq!(
2332            graph.nodes["a"].resolved_ref.version,
2333            Some(Version::new(1, 5, 0))
2334        );
2335        // "b" should use MVS → 2.0.0
2336        assert_eq!(
2337            graph.nodes["b"].resolved_ref.version,
2338            Some(Version::new(2, 0, 0))
2339        );
2340    }
2341
2342    #[test]
2343    fn bump_direct_constraints_ignores_direct_pin_for_target() {
2344        let dir = TempDir::new().unwrap();
2345        let tree = dir.path().join("a");
2346        std::fs::create_dir_all(&tree).unwrap();
2347
2348        let mut provider = MockProvider::new();
2349        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0), (2, 0, 0)]);
2350        provider.add_source("a", tree, None);
2351
2352        let config = make_config(vec![(
2353            "a",
2354            git_spec("https://example.com/a.git", Some("v1.0.0")),
2355        )]);
2356
2357        let options = ResolveOptions {
2358            maximize: true,
2359            upgrade_targets: HashSet::from([SourceName::from("a")]),
2360            bump_direct_constraints: true,
2361            frozen: false,
2362        };
2363
2364        let graph = resolve(&config, &provider, None, &options).unwrap();
2365        assert_eq!(
2366            graph.nodes["a"].resolved_ref.version,
2367            Some(Version::new(2, 0, 0))
2368        );
2369    }
2370
2371    #[test]
2372    fn no_available_versions_falls_back_to_head() {
2373        let dir = TempDir::new().unwrap();
2374        let tree = dir.path().join("a");
2375        std::fs::create_dir_all(&tree).unwrap();
2376
2377        let mut provider = MockProvider::new();
2378        // No versions registered → empty list
2379        provider.add_source("a", tree, None);
2380
2381        let config = make_config(vec![("a", git_spec("https://example.com/a.git", None))]);
2382
2383        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
2384        let node = &graph.nodes["a"];
2385        assert!(node.resolved_ref.version.is_none());
2386        assert_eq!(node.resolved_ref.commit, Some("ref:HEAD".into()));
2387    }
2388
2389    #[test]
2390    fn untagged_source_uses_locked_commit_when_available() {
2391        let dir = TempDir::new().unwrap();
2392        let tree = dir.path().join("a");
2393        std::fs::create_dir_all(&tree).unwrap();
2394
2395        let mut provider = MockProvider::new();
2396        provider.add_source("a", tree, None);
2397
2398        let config = make_config(vec![("a", git_spec("https://example.com/a.git", None))]);
2399
2400        let locked_commit = "locked-untagged-sha";
2401        let mut lock = LockFile::empty();
2402        lock.dependencies.insert(
2403            "a".into(),
2404            crate::lock::LockedSource {
2405                url: Some("https://example.com/a.git".into()),
2406                path: None,
2407                subpath: None,
2408                version: None,
2409                commit: Some(locked_commit.into()),
2410                tree_hash: None,
2411            },
2412        );
2413
2414        let graph = resolve(&config, &provider, Some(&lock), &default_options()).unwrap();
2415        assert_eq!(
2416            graph.nodes["a"].resolved_ref.commit.as_deref(),
2417            Some(locked_commit)
2418        );
2419        assert_eq!(
2420            provider.seen_preferred_commits(),
2421            vec![Some(locked_commit.to_string())]
2422        );
2423    }
2424
2425    #[test]
2426    fn untagged_source_falls_back_to_head_when_locked_commit_unreachable() {
2427        let dir = TempDir::new().unwrap();
2428        let tree = dir.path().join("a");
2429        std::fs::create_dir_all(&tree).unwrap();
2430
2431        let mut provider = MockProvider::new();
2432        provider.add_source("a", tree, None);
2433
2434        let config = make_config(vec![("a", git_spec("https://example.com/a.git", None))]);
2435
2436        let unreachable_commit = "missing-locked-sha";
2437        provider.mark_unreachable_preferred_commit(unreachable_commit);
2438
2439        let mut lock = LockFile::empty();
2440        lock.dependencies.insert(
2441            "a".into(),
2442            crate::lock::LockedSource {
2443                url: Some("https://example.com/a.git".into()),
2444                path: None,
2445                subpath: None,
2446                version: None,
2447                commit: Some(unreachable_commit.into()),
2448                tree_hash: None,
2449            },
2450        );
2451
2452        let graph = resolve(&config, &provider, Some(&lock), &default_options()).unwrap();
2453        assert_eq!(
2454            graph.nodes["a"].resolved_ref.commit.as_deref(),
2455            Some("ref:HEAD")
2456        );
2457        assert_eq!(
2458            provider.seen_preferred_commits(),
2459            vec![Some(unreachable_commit.to_string()), None]
2460        );
2461    }
2462
2463    #[test]
2464    fn frozen_mode_errors_for_untagged_locked_commit_unreachable() {
2465        let dir = TempDir::new().unwrap();
2466        let tree = dir.path().join("a");
2467        std::fs::create_dir_all(&tree).unwrap();
2468
2469        let mut provider = MockProvider::new();
2470        provider.add_source("a", tree, None);
2471
2472        let config = make_config(vec![("a", git_spec("https://example.com/a.git", None))]);
2473
2474        let unreachable_commit = "missing-locked-sha";
2475        provider.mark_unreachable_preferred_commit(unreachable_commit);
2476
2477        let mut lock = LockFile::empty();
2478        lock.dependencies.insert(
2479            "a".into(),
2480            crate::lock::LockedSource {
2481                url: Some("https://example.com/a.git".into()),
2482                path: None,
2483                subpath: None,
2484                version: None,
2485                commit: Some(unreachable_commit.into()),
2486                tree_hash: None,
2487            },
2488        );
2489
2490        let options = ResolveOptions {
2491            frozen: true,
2492            ..default_options()
2493        };
2494        let result = resolve(&config, &provider, Some(&lock), &options);
2495        assert!(matches!(
2496            result,
2497            Err(MarsError::LockedCommitUnreachable { .. })
2498        ));
2499        assert_eq!(
2500            provider.seen_preferred_commits(),
2501            vec![Some(unreachable_commit.to_string())]
2502        );
2503    }
2504
2505    // ========== Topological sort tests ==========
2506
2507    #[test]
2508    fn topo_sort_linear_chain() {
2509        let mut nodes = IndexMap::new();
2510        nodes.insert(
2511            "c".into(),
2512            ResolvedNode {
2513                source_name: "c".into(),
2514                source_id: SourceId::git(SourceUrl::from("example.com/c")),
2515                resolved_ref: dummy_ref("c"),
2516                rooted_ref: dummy_rooted_ref(),
2517                latest_version: None,
2518                manifest: None,
2519                deps: vec!["b".into()],
2520            },
2521        );
2522        nodes.insert(
2523            "b".into(),
2524            ResolvedNode {
2525                source_name: "b".into(),
2526                source_id: SourceId::git(SourceUrl::from("example.com/b")),
2527                resolved_ref: dummy_ref("b"),
2528                rooted_ref: dummy_rooted_ref(),
2529                latest_version: None,
2530                manifest: None,
2531                deps: vec!["a".into()],
2532            },
2533        );
2534        nodes.insert(
2535            "a".into(),
2536            ResolvedNode {
2537                source_name: "a".into(),
2538                source_id: SourceId::git(SourceUrl::from("example.com/a")),
2539                resolved_ref: dummy_ref("a"),
2540                rooted_ref: dummy_rooted_ref(),
2541                latest_version: None,
2542                manifest: None,
2543                deps: vec![],
2544            },
2545        );
2546
2547        let order = topological_sort(&nodes).unwrap();
2548        assert_eq!(order, vec!["a", "b", "c"]);
2549    }
2550
2551    #[test]
2552    fn topo_sort_diamond() {
2553        // a depends on b and c, both depend on d
2554        let mut nodes = IndexMap::new();
2555        nodes.insert(
2556            "a".into(),
2557            ResolvedNode {
2558                source_name: "a".into(),
2559                source_id: SourceId::git(SourceUrl::from("example.com/a")),
2560                resolved_ref: dummy_ref("a"),
2561                rooted_ref: dummy_rooted_ref(),
2562                latest_version: None,
2563                manifest: None,
2564                deps: vec!["b".into(), "c".into()],
2565            },
2566        );
2567        nodes.insert(
2568            "b".into(),
2569            ResolvedNode {
2570                source_name: "b".into(),
2571                source_id: SourceId::git(SourceUrl::from("example.com/b")),
2572                resolved_ref: dummy_ref("b"),
2573                rooted_ref: dummy_rooted_ref(),
2574                latest_version: None,
2575                manifest: None,
2576                deps: vec!["d".into()],
2577            },
2578        );
2579        nodes.insert(
2580            "c".into(),
2581            ResolvedNode {
2582                source_name: "c".into(),
2583                source_id: SourceId::git(SourceUrl::from("example.com/c")),
2584                resolved_ref: dummy_ref("c"),
2585                rooted_ref: dummy_rooted_ref(),
2586                latest_version: None,
2587                manifest: None,
2588                deps: vec!["d".into()],
2589            },
2590        );
2591        nodes.insert(
2592            "d".into(),
2593            ResolvedNode {
2594                source_name: "d".into(),
2595                source_id: SourceId::git(SourceUrl::from("example.com/d")),
2596                resolved_ref: dummy_ref("d"),
2597                rooted_ref: dummy_rooted_ref(),
2598                latest_version: None,
2599                manifest: None,
2600                deps: vec![],
2601            },
2602        );
2603
2604        let order = topological_sort(&nodes).unwrap();
2605        // d must come first, a must come last
2606        assert_eq!(order[0], "d");
2607        assert_eq!(*order.last().unwrap(), "a");
2608        // b and c can be in either order, but both before a
2609        let a_pos = order.iter().position(|n| n == "a").unwrap();
2610        let b_pos = order.iter().position(|n| n == "b").unwrap();
2611        let c_pos = order.iter().position(|n| n == "c").unwrap();
2612        assert!(b_pos < a_pos);
2613        assert!(c_pos < a_pos);
2614    }
2615
2616    #[test]
2617    fn topo_sort_no_deps() {
2618        let mut nodes = IndexMap::new();
2619        nodes.insert(
2620            "a".into(),
2621            ResolvedNode {
2622                source_name: "a".into(),
2623                source_id: SourceId::git(SourceUrl::from("example.com/a")),
2624                resolved_ref: dummy_ref("a"),
2625                rooted_ref: dummy_rooted_ref(),
2626                latest_version: None,
2627                manifest: None,
2628                deps: vec![],
2629            },
2630        );
2631        nodes.insert(
2632            "b".into(),
2633            ResolvedNode {
2634                source_name: "b".into(),
2635                source_id: SourceId::git(SourceUrl::from("example.com/b")),
2636                resolved_ref: dummy_ref("b"),
2637                rooted_ref: dummy_rooted_ref(),
2638                latest_version: None,
2639                manifest: None,
2640                deps: vec![],
2641            },
2642        );
2643
2644        let order = topological_sort(&nodes).unwrap();
2645        assert_eq!(order.len(), 2);
2646        // Deterministic alphabetical order for independent nodes
2647        assert_eq!(order, vec!["a", "b"]);
2648    }
2649
2650    #[test]
2651    fn topo_sort_cycle_error() {
2652        let mut nodes = IndexMap::new();
2653        nodes.insert(
2654            "a".into(),
2655            ResolvedNode {
2656                source_name: "a".into(),
2657                source_id: SourceId::git(SourceUrl::from("example.com/a")),
2658                resolved_ref: dummy_ref("a"),
2659                rooted_ref: dummy_rooted_ref(),
2660                latest_version: None,
2661                manifest: None,
2662                deps: vec!["b".into()],
2663            },
2664        );
2665        nodes.insert(
2666            "b".into(),
2667            ResolvedNode {
2668                source_name: "b".into(),
2669                source_id: SourceId::git(SourceUrl::from("example.com/b")),
2670                resolved_ref: dummy_ref("b"),
2671                rooted_ref: dummy_rooted_ref(),
2672                latest_version: None,
2673                manifest: None,
2674                deps: vec!["a".into()],
2675            },
2676        );
2677
2678        let result = topological_sort(&nodes);
2679        assert!(result.is_err());
2680        let err = result.unwrap_err().to_string();
2681        assert!(err.contains("cycle") || err.contains("Cycle"), "{err}");
2682    }
2683
2684    fn dummy_ref(name: &str) -> ResolvedRef {
2685        ResolvedRef {
2686            source_name: name.into(),
2687            version: None,
2688            version_tag: None,
2689            commit: None,
2690            tree_path: PathBuf::new(),
2691        }
2692    }
2693
2694    fn dummy_rooted_ref() -> RootedSourceRef {
2695        RootedSourceRef {
2696            checkout_root: PathBuf::new(),
2697            package_root: PathBuf::new(),
2698        }
2699    }
2700
2701    // ========== RES-006 / RES-008: apply_subpath with None subpath ==========
2702
2703    /// RES-006 / RES-008: When no subpath is specified, checkout_root IS the
2704    /// package_root and the resolver produces a RootedSourceRef where both
2705    /// fields point to the same directory.
2706    #[test]
2707    fn apply_subpath_none_yields_checkout_as_package_root() {
2708        let dir = TempDir::new().unwrap();
2709        let rooted = apply_subpath(&SourceName::from("dep"), dir.path(), None).unwrap();
2710        assert_eq!(rooted.checkout_root, dir.path());
2711        assert_eq!(rooted.package_root, dir.path());
2712    }
2713
2714    // ========== RES-009: manifest reader is called with package_root ==========
2715
2716    /// RES-009: The resolver must pass `package_root` (not checkout_root) to
2717    /// the manifest reader.  We arrange a subpath dep whose checkout_root has
2718    /// no mars.toml but whose package_root (a subdirectory) does, then verify
2719    /// that the manifest is successfully discovered — proving package_root was
2720    /// used as the read base.
2721    #[test]
2722    fn resolver_reads_manifest_from_package_root_not_checkout_root() {
2723        let dir = TempDir::new().unwrap();
2724        let checkout = dir.path().join("checkout");
2725        let package_root = checkout.join("plugins/foo");
2726        std::fs::create_dir_all(&package_root).unwrap();
2727
2728        // The manifest is associated with package_root, NOT the checkout root.
2729        // MockProvider keyed by tree_path: we register the manifest under
2730        // package_root so that a read from checkout_root would return None
2731        // while a read from package_root returns the manifest.
2732        let manifest = Manifest {
2733            package: PackageInfo {
2734                name: "foo".to_string(),
2735                version: "1.0.0".to_string(),
2736                description: None,
2737            },
2738            dependencies: IndexMap::new(),
2739            models: IndexMap::new(),
2740        };
2741
2742        let subpath = SourceSubpath::new("plugins/foo").unwrap();
2743
2744        let mut provider = MockProvider::new();
2745        provider.add_versions("https://example.com/repo.git", vec![(1, 0, 0)]);
2746        // Register tree at checkout but map manifest only for package_root
2747        provider.trees.insert("dep".to_string(), checkout.clone());
2748        provider
2749            .manifests
2750            .insert(package_root.clone(), Some(manifest.clone()));
2751        provider.manifests.insert(checkout.clone(), None);
2752
2753        let mut dependencies = IndexMap::new();
2754        dependencies.insert(
2755            SourceName::from("dep"),
2756            EffectiveDependency {
2757                name: "dep".into(),
2758                id: SourceId::git_with_subpath(
2759                    SourceUrl::from("https://example.com/repo.git"),
2760                    Some(subpath.clone()),
2761                ),
2762                spec: git_spec("https://example.com/repo.git", Some("v1.0.0")),
2763                subpath: Some(subpath),
2764                filter: FilterMode::All,
2765                rename: RenameMap::new(),
2766                is_overridden: false,
2767                original_git: None,
2768            },
2769        );
2770        let config = EffectiveConfig {
2771            dependencies,
2772            settings: Settings::default(),
2773        };
2774
2775        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
2776        let node = graph.nodes.get("dep").expect("dep should be in graph");
2777        // Manifest must be present — only possible if package_root was used
2778        assert!(
2779            node.manifest.is_some(),
2780            "manifest should be loaded from package_root; got None — checkout_root was likely used instead"
2781        );
2782        assert_eq!(node.rooted_ref.package_root, package_root);
2783        assert_eq!(node.rooted_ref.checkout_root, checkout);
2784    }
2785
2786    // ========== RES-005: single fetch for same URL, multiple subpaths ==========
2787
2788    /// RES-005: Two dependencies at different subpaths of the same git URL
2789    /// must not trigger a second fetch.  In our resolver the fetch is keyed by
2790    /// (source name, URL) so two DISTINCT dep names pointing to the same URL
2791    /// but different subpaths each call fetch_git_version once — but the test
2792    /// verifies they both resolve successfully with distinct package_roots,
2793    /// which is the observable contract from the resolver's perspective
2794    /// (cache sharing is a source-layer concern; here we verify no error is
2795    /// raised and both roots are distinct).
2796    #[test]
2797    fn two_subpaths_same_url_resolve_to_distinct_package_roots() {
2798        let dir = TempDir::new().unwrap();
2799        let checkout_a = dir.path().join("a");
2800        let checkout_b = dir.path().join("b");
2801        let pkg_a = checkout_a.join("plugins/foo");
2802        let pkg_b = checkout_b.join("plugins/bar");
2803        std::fs::create_dir_all(&pkg_a).unwrap();
2804        std::fs::create_dir_all(&pkg_b).unwrap();
2805
2806        let subpath_foo = SourceSubpath::new("plugins/foo").unwrap();
2807        let subpath_bar = SourceSubpath::new("plugins/bar").unwrap();
2808
2809        let mut provider = MockProvider::new();
2810        provider.add_versions("https://example.com/mono.git", vec![(1, 0, 0)]);
2811        provider.add_source("dep-a", checkout_a.clone(), None);
2812        provider.add_source("dep-b", checkout_b.clone(), None);
2813
2814        let mut dependencies = IndexMap::new();
2815        dependencies.insert(
2816            SourceName::from("dep-a"),
2817            EffectiveDependency {
2818                name: "dep-a".into(),
2819                id: SourceId::git_with_subpath(
2820                    SourceUrl::from("https://example.com/mono.git"),
2821                    Some(subpath_foo.clone()),
2822                ),
2823                spec: git_spec("https://example.com/mono.git", Some("v1.0.0")),
2824                subpath: Some(subpath_foo),
2825                filter: FilterMode::All,
2826                rename: RenameMap::new(),
2827                is_overridden: false,
2828                original_git: None,
2829            },
2830        );
2831        dependencies.insert(
2832            SourceName::from("dep-b"),
2833            EffectiveDependency {
2834                name: "dep-b".into(),
2835                id: SourceId::git_with_subpath(
2836                    SourceUrl::from("https://example.com/mono.git"),
2837                    Some(subpath_bar.clone()),
2838                ),
2839                spec: git_spec("https://example.com/mono.git", Some("v1.0.0")),
2840                subpath: Some(subpath_bar),
2841                filter: FilterMode::All,
2842                rename: RenameMap::new(),
2843                is_overridden: false,
2844                original_git: None,
2845            },
2846        );
2847        let config = EffectiveConfig {
2848            dependencies,
2849            settings: Settings::default(),
2850        };
2851
2852        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
2853        assert_eq!(graph.nodes.len(), 2);
2854
2855        let node_a = graph.nodes.get("dep-a").expect("dep-a should be resolved");
2856        let node_b = graph.nodes.get("dep-b").expect("dep-b should be resolved");
2857        // Each gets its own distinct package_root
2858        assert_eq!(node_a.rooted_ref.package_root, pkg_a);
2859        assert_eq!(node_b.rooted_ref.package_root, pkg_b);
2860        // checkout_roots differ because MockProvider returns different trees per name
2861        assert_ne!(
2862            node_a.rooted_ref.package_root,
2863            node_b.rooted_ref.package_root
2864        );
2865    }
2866
2867    // ========== RES-011: transitive dep with no subpath gets None identity ==========
2868
2869    /// RES-011 contrast: a transitive dep whose manifest entry has NO subpath
2870    /// should produce a source identity with subpath = None (not inherit from
2871    /// the parent).
2872    #[test]
2873    fn transitive_dep_without_subpath_has_none_in_source_identity() {
2874        let dir = TempDir::new().unwrap();
2875        let tree_a = dir.path().join("a");
2876        let tree_dep = dir.path().join("dep");
2877        std::fs::create_dir_all(&tree_a).unwrap();
2878        std::fs::create_dir_all(&tree_dep).unwrap();
2879
2880        // 'a' depends on 'dep' with NO subpath declared
2881        let mut manifest_deps = IndexMap::new();
2882        manifest_deps.insert(
2883            "dep".to_string(),
2884            ManifestDep {
2885                url: SourceUrl::from("https://example.com/dep.git"),
2886                subpath: None,
2887                version: Some(">=1.0.0".to_string()),
2888                filter: FilterConfig::default(),
2889            },
2890        );
2891        let manifest_a = Manifest {
2892            package: PackageInfo {
2893                name: "a".to_string(),
2894                version: "1.0.0".to_string(),
2895                description: None,
2896            },
2897            dependencies: manifest_deps,
2898            models: IndexMap::new(),
2899        };
2900
2901        let mut provider = MockProvider::new();
2902        provider.add_versions("https://example.com/a.git", vec![(1, 0, 0)]);
2903        provider.add_versions("https://example.com/dep.git", vec![(1, 0, 0)]);
2904        provider.add_source("a", tree_a, Some(manifest_a));
2905        provider.add_source("dep", tree_dep.clone(), None);
2906
2907        let config = make_config(vec![(
2908            "a",
2909            git_spec("https://example.com/a.git", Some("v1.0.0")),
2910        )]);
2911        let graph = resolve(&config, &provider, None, &default_options()).unwrap();
2912
2913        let dep_node = graph.nodes.get("dep").expect("dep should be in graph");
2914        // No subpath declared → identity must have subpath = None
2915        assert_eq!(
2916            dep_node.source_id,
2917            SourceId::git_with_subpath(SourceUrl::from("https://example.com/dep.git"), None)
2918        );
2919        // package_root equals checkout_root when subpath is None
2920        assert_eq!(dep_node.rooted_ref.package_root, tree_dep);
2921        assert_eq!(dep_node.rooted_ref.checkout_root, tree_dep);
2922    }
2923}