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