1use 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#[derive(Debug, Clone)]
30pub struct ResolvedGraph {
31 pub nodes: IndexMap<SourceName, ResolvedNode>,
32 pub order: Vec<SourceName>,
34 pub id_index: HashMap<SourceId, SourceName>,
35 pub filters: HashMap<SourceName, Vec<FilterMode>>,
37}
38
39#[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 pub manifest: Option<Manifest>,
49 pub deps: Vec<SourceName>,
51}
52
53#[derive(Debug, Clone)]
55pub struct RootedSourceRef {
56 pub checkout_root: PathBuf,
57 pub package_root: PathBuf,
58}
59
60#[derive(Debug, Clone)]
62pub enum VersionConstraint {
63 Semver(VersionReq),
65 Latest,
67 RefPin(String),
69}
70
71#[derive(Debug, Clone, Default)]
73pub struct ResolveOptions {
74 pub maximize: bool,
76 pub upgrade_targets: HashSet<SourceName>,
78 pub bump_direct_constraints: bool,
81 pub frozen: bool,
83}
84
85pub trait VersionLister {
87 fn list_versions(&self, url: &SourceUrl) -> Result<Vec<AvailableVersion>, MarsError>;
88}
89
90pub trait SourceFetcher {
92 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 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 fn fetch_path(
114 &self,
115 path: &Path,
116 source_name: &str,
117 diag: &mut DiagnosticCollector,
118 ) -> Result<ResolvedRef, MarsError>;
119}
120
121pub trait ManifestReader {
123 fn read_manifest(
124 &self,
125 source_tree: &Path,
126 diag: &mut DiagnosticCollector,
127 ) -> Result<Option<Manifest>, MarsError>;
128}
129
130pub trait SourceProvider: VersionLister + SourceFetcher + ManifestReader {}
132
133impl<T> SourceProvider for T where T: VersionLister + SourceFetcher + ManifestReader {}
134
135pub 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 if let Some(stripped) = version.strip_prefix('v') {
155 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 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 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 if let Ok(req) = VersionReq::parse(version) {
181 return VersionConstraint::Semver(req);
182 }
183
184 VersionConstraint::RefPin(version.to_string())
186}
187
188pub 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 let mut pending: VecDeque<PendingSource> = VecDeque::new();
210
211 let mut constraints: HashMap<SourceName, Vec<(String, VersionConstraint)>> = HashMap::new();
213
214 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, };
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 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 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 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 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 let manifest = provider.read_manifest(&rooted_ref.package_root, diag)?;
299
300 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 let dep_url = dep_spec.url.clone();
308
309 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 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_all_constraints(&nodes, &constraints)?;
361
362 let order = topological_sort(&nodes)?;
364
365 Ok(ResolvedGraph {
366 nodes,
367 order,
368 id_index,
369 filters: filter_constraints,
370 })
371}
372
373struct 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
487fn 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 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
518fn 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 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 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 let maximize = has_latest || upgrade_maximize;
558
559 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 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 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 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 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
657fn 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 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 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 !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 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
718fn 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, };
732
733 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
754fn topological_sort(
759 nodes: &IndexMap<SourceName, ResolvedNode>,
760) -> Result<Vec<SourceName>, MarsError> {
761 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; *in_degree.entry(name.clone()).or_insert(0) += 1;
778 adjacency.entry(dep.clone()).or_default().push(name.clone());
779 }
780 }
781 }
782
783 let mut queue: VecDeque<SourceName> = VecDeque::new();
785 for (name, °ree) in &in_degree {
786 if degree == 0 {
787 queue.push_back(name.clone());
788 }
789 }
790
791 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 if let Some(dependents) = adjacency.get(¤t) {
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 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 struct MockProvider {
849 versions: HashMap<String, Vec<AvailableVersion>>,
851 trees: HashMap<String, PathBuf>,
853 manifests: HashMap<PathBuf, Option<Manifest>>,
855 unreachable_preferred_commits: HashSet<String>,
857 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 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 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 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 #[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 #[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 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 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 assert_eq!(graph.nodes.len(), 2);
1390 assert!(graph.nodes.contains_key("a"));
1391 assert!(graph.nodes.contains_key("dep"));
1392
1393 let dep_node = &graph.nodes["dep"];
1395 assert_eq!(dep_node.resolved_ref.version, Some(Version::new(0, 5, 0)));
1396
1397 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 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 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 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 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 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 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 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 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 let config = make_config(vec![(
1956 "a",
1957 git_spec("https://example.com/a.git", Some("^2.0")),
1958 )]);
1959
1960 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 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 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 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); 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 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 assert_eq!(
2332 graph.nodes["a"].resolved_ref.version,
2333 Some(Version::new(1, 5, 0))
2334 );
2335 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 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 #[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 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 assert_eq!(order[0], "d");
2607 assert_eq!(*order.last().unwrap(), "a");
2608 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 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 #[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 #[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 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 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 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 #[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 assert_eq!(node_a.rooted_ref.package_root, pkg_a);
2859 assert_eq!(node_b.rooted_ref.package_root, pkg_b);
2860 assert_ne!(
2862 node_a.rooted_ref.package_root,
2863 node_b.rooted_ref.package_root
2864 );
2865 }
2866
2867 #[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 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 assert_eq!(
2916 dep_node.source_id,
2917 SourceId::git_with_subpath(SourceUrl::from("https://example.com/dep.git"), None)
2918 );
2919 assert_eq!(dep_node.rooted_ref.package_root, tree_dep);
2921 assert_eq!(dep_node.rooted_ref.checkout_root, tree_dep);
2922 }
2923}