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