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