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