1use std::collections::{BTreeMap, BTreeSet};
11
12use crate::error::WorkspaceError;
13use crate::graph::PackageGraph;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum SelectionMode {
18 CurrentPackage,
27 DefaultMembers,
30 WholeWorkspace,
33 ExplicitPackages(Vec<String>),
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct PackageSelection {
42 pub mode: SelectionMode,
43 pub exclude: Vec<String>,
46}
47
48impl PackageSelection {
49 pub fn current_package() -> Self {
50 Self {
51 mode: SelectionMode::CurrentPackage,
52 exclude: Vec::new(),
53 }
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ResolvedSelection {
61 pub packages: Vec<usize>,
62}
63
64impl ResolvedSelection {
65 pub fn closure(&self, graph: &PackageGraph) -> BTreeSet<usize> {
73 let mut closure: BTreeSet<usize> = BTreeSet::new();
74 let mut stack: Vec<usize> = self.packages.clone();
75 while let Some(idx) = stack.pop() {
76 if !closure.insert(idx) {
77 continue;
78 }
79 for edge in &graph.packages[idx].deps {
80 if !closure.contains(&edge.index) {
81 stack.push(edge.index);
82 }
83 }
84 }
85 closure
86 }
87
88 pub fn closure_package_names(&self, graph: &PackageGraph) -> BTreeSet<String> {
94 self.closure(graph)
95 .into_iter()
96 .map(|i| graph.packages[i].package.name.as_str().to_owned())
97 .collect()
98 }
99}
100
101pub fn resolve_package_selection(
117 graph: &PackageGraph,
118 selection: &PackageSelection,
119) -> Result<ResolvedSelection, WorkspaceError> {
120 let exclusion_compatible = matches!(
128 selection.mode,
129 SelectionMode::WholeWorkspace | SelectionMode::DefaultMembers
130 );
131 if !selection.exclude.is_empty() && !exclusion_compatible {
132 return Err(WorkspaceError::ExcludeWithoutWorkspaceSelection);
133 }
134
135 let exclude_indices = exclude_indices(graph, &selection.exclude)?;
136
137 let candidates: Vec<usize> = match &selection.mode {
138 SelectionMode::CurrentPackage => current_package_default(graph),
139 SelectionMode::DefaultMembers => {
140 if !graph.is_workspace_root {
141 return Err(WorkspaceError::DefaultMembersWithoutWorkspace);
142 }
143 if graph.default_members.is_empty() {
144 return Err(WorkspaceError::DefaultMemberNotInMembers {
145 member: "<no default-members declared>".to_owned(),
146 });
147 }
148 graph.default_members.clone()
149 }
150 SelectionMode::WholeWorkspace => {
151 if graph.is_workspace_root {
152 graph.primary_packages.clone()
153 } else {
154 current_package_default(graph)
158 }
159 }
160 SelectionMode::ExplicitPackages(names) => {
161 let mut out = Vec::with_capacity(names.len());
162 for name in names {
163 let idx =
164 graph
165 .index_of(name)
166 .ok_or_else(|| WorkspaceError::PackageNotInWorkspace {
167 name: name.clone(),
168 members: workspace_member_names(graph),
169 })?;
170 if !graph.primary_packages.contains(&idx) {
171 return Err(WorkspaceError::PackageNotInWorkspace {
172 name: name.clone(),
173 members: workspace_member_names(graph),
174 });
175 }
176 if !out.contains(&idx) {
177 out.push(idx);
178 }
179 }
180 out
181 }
182 };
183
184 let mut packages: Vec<usize> = candidates
185 .into_iter()
186 .filter(|i| !exclude_indices.contains(i))
187 .collect();
188 packages.sort_by(|a, b| {
190 graph.packages[*a]
191 .package
192 .name
193 .as_str()
194 .cmp(graph.packages[*b].package.name.as_str())
195 });
196 if packages.is_empty() {
197 return Err(WorkspaceError::AmbiguousPackageSelection);
198 }
199 Ok(ResolvedSelection { packages })
200}
201
202fn current_package_default(graph: &PackageGraph) -> Vec<usize> {
203 if graph.is_workspace_root {
204 if graph.default_members.is_empty() {
205 graph.primary_packages.clone()
208 } else {
209 graph.default_members.clone()
210 }
211 } else if let Some(root) = graph.root_package {
212 vec![root]
213 } else {
214 graph.primary_packages.clone()
215 }
216}
217
218fn exclude_indices(
219 graph: &PackageGraph,
220 excludes: &[String],
221) -> Result<BTreeSet<usize>, WorkspaceError> {
222 let mut out = BTreeSet::new();
223 for name in excludes {
224 let idx = graph
225 .index_of(name)
226 .ok_or_else(|| WorkspaceError::PackageNotInWorkspace {
227 name: name.clone(),
228 members: workspace_member_names(graph),
229 })?;
230 if !graph.primary_packages.contains(&idx) {
231 return Err(WorkspaceError::PackageNotInWorkspace {
232 name: name.clone(),
233 members: workspace_member_names(graph),
234 });
235 }
236 out.insert(idx);
237 }
238 Ok(out)
239}
240
241pub fn combine_version_reqs(
255 reqs: &[String],
256) -> Result<semver::VersionReq, (String, semver::Error)> {
257 let joined = reqs.join(", ");
258 match semver::VersionReq::parse(&joined) {
259 Ok(req) => Ok(req),
260 Err(source) => Err((joined, source)),
261 }
262}
263
264fn versioned_dep_active<'a, F>(
272 dep: &'a cabin_core::Dependency,
273 idx: usize,
274 dev_active_here: bool,
275 host: &cabin_core::TargetPlatform,
276 is_optional_dep_enabled: &F,
277 excluded_names: &BTreeSet<String>,
278) -> Option<&'a semver::VersionReq>
279where
280 F: Fn(usize, &str) -> bool,
281{
282 use cabin_core::{DependencyKind, DependencySource};
283 let kind_active =
284 dep.kind.is_resolved_by_default() || (dev_active_here && dep.kind == DependencyKind::Dev);
285 if !kind_active {
286 return None;
287 }
288 if !dep.matches_platform(host) {
289 return None;
290 }
291 if dep.optional && !is_optional_dep_enabled(idx, dep.name.as_str()) {
292 return None;
293 }
294 if excluded_names.contains(dep.name.as_str()) {
295 return None;
296 }
297 match &dep.source {
298 DependencySource::Version(req) => Some(req),
299 _ => None,
300 }
301}
302
303pub fn collect_closure_versioned_deps_excluding_with_dev<F>(
350 graph: &PackageGraph,
351 closure: &BTreeSet<usize>,
352 is_optional_dep_enabled: F,
353 excluded_names: &BTreeSet<String>,
354 dev_active_for: &BTreeSet<String>,
355) -> Result<BTreeMap<cabin_core::PackageName, semver::VersionReq>, WorkspaceError>
356where
357 F: Fn(usize, &str) -> bool,
358{
359 let host_platform = cabin_core::TargetPlatform::current();
362 let mut combined: BTreeMap<String, Vec<String>> = BTreeMap::new();
363 let mut name_lookup: BTreeMap<String, cabin_core::PackageName> = BTreeMap::new();
364 for &idx in closure {
365 let pkg = &graph.packages[idx];
366 if !matches!(pkg.kind, crate::graph::PackageKind::Local) {
370 continue;
371 }
372 let dev_active_here = dev_active_for.contains(pkg.package.name.as_str());
373 for dep in &pkg.package.dependencies {
374 if let Some(req) = versioned_dep_active(
375 dep,
376 idx,
377 dev_active_here,
378 &host_platform,
379 &is_optional_dep_enabled,
380 excluded_names,
381 ) {
382 let key = dep.name.as_str().to_owned();
383 combined
384 .entry(key.clone())
385 .or_default()
386 .push(req.to_string());
387 name_lookup.insert(key, dep.name.clone());
388 }
389 }
390 }
391 let mut out = BTreeMap::new();
392 for (name, mut reqs) in combined {
393 reqs.sort();
394 reqs.dedup();
395 let parsed = combine_version_reqs(&reqs).map_err(|(requirements, source)| {
396 WorkspaceError::IncompatibleWorkspaceRequirements {
397 name: name.clone(),
398 requirements,
399 source,
400 }
401 })?;
402 out.insert(name_lookup.remove(&name).unwrap(), parsed);
403 }
404 Ok(out)
405}
406
407pub fn closure_has_versioned_deps_excluding_with_dev<F>(
417 graph: &PackageGraph,
418 closure: &BTreeSet<usize>,
419 is_optional_dep_enabled: F,
420 excluded_names: &BTreeSet<String>,
421 dev_active_for: &BTreeSet<String>,
422) -> bool
423where
424 F: Fn(usize, &str) -> bool,
425{
426 let host_platform = cabin_core::TargetPlatform::current();
427 closure.iter().any(|&idx| {
428 let pkg = &graph.packages[idx];
429 if !matches!(pkg.kind, crate::graph::PackageKind::Local) {
430 return false;
431 }
432 let dev_active_here = dev_active_for.contains(pkg.package.name.as_str());
433 pkg.package.dependencies.iter().any(|dep| {
434 versioned_dep_active(
435 dep,
436 idx,
437 dev_active_here,
438 &host_platform,
439 &is_optional_dep_enabled,
440 excluded_names,
441 )
442 .is_some()
443 })
444 })
445}
446
447fn workspace_member_names(graph: &PackageGraph) -> Vec<String> {
448 let mut names: Vec<String> = graph
449 .primary_packages
450 .iter()
451 .map(|i| graph.packages[*i].package.name.as_str().to_owned())
452 .collect();
453 names.sort();
454 names
455}
456
457#[cfg(test)]
458mod tests {
459 use std::fmt::Write as _;
460
461 use super::*;
462 use crate::loader::load_workspace;
463 use assert_fs::TempDir;
464 use assert_fs::prelude::*;
465
466 fn workspace_with_two_members(default_members: Option<&str>) -> TempDir {
467 let dir = TempDir::new().unwrap();
468 let mut root = String::from("[workspace]\nmembers = [\"packages/*\"]\n");
469 if let Some(dm) = default_members {
470 writeln!(root, "default-members = [\"packages/{dm}\"]").unwrap();
471 }
472 dir.child("cabin.toml").write_str(&root).unwrap();
473 dir.child("packages/a/cabin.toml")
474 .write_str("[package]\nname = \"a\"\nversion = \"0.1.0\"\n")
475 .unwrap();
476 dir.child("packages/b/cabin.toml")
477 .write_str("[package]\nname = \"b\"\nversion = \"0.1.0\"\n")
478 .unwrap();
479 dir
480 }
481
482 fn names(graph: &PackageGraph, sel: &ResolvedSelection) -> Vec<String> {
483 sel.packages
484 .iter()
485 .map(|i| graph.packages[*i].package.name.as_str().to_owned())
486 .collect()
487 }
488
489 #[test]
490 fn current_package_falls_back_to_all_members_without_defaults() {
491 let dir = workspace_with_two_members(None);
492 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
493 let sel = resolve_package_selection(&graph, &PackageSelection::current_package()).unwrap();
494 assert_eq!(names(&graph, &sel), vec!["a", "b"]);
495 }
496
497 #[test]
498 fn current_package_uses_declared_defaults() {
499 let dir = workspace_with_two_members(Some("a"));
500 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
501 let sel = resolve_package_selection(&graph, &PackageSelection::current_package()).unwrap();
502 assert_eq!(names(&graph, &sel), vec!["a"]);
503 }
504
505 #[test]
506 fn whole_workspace_selects_all_members() {
507 let dir = workspace_with_two_members(Some("a"));
508 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
509 let sel = resolve_package_selection(
510 &graph,
511 &PackageSelection {
512 mode: SelectionMode::WholeWorkspace,
513 exclude: Vec::new(),
514 },
515 )
516 .unwrap();
517 assert_eq!(names(&graph, &sel), vec!["a", "b"]);
518 }
519
520 #[test]
521 fn whole_workspace_with_exclude_drops_member() {
522 let dir = workspace_with_two_members(None);
523 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
524 let sel = resolve_package_selection(
525 &graph,
526 &PackageSelection {
527 mode: SelectionMode::WholeWorkspace,
528 exclude: vec!["b".into()],
529 },
530 )
531 .unwrap();
532 assert_eq!(names(&graph, &sel), vec!["a"]);
533 }
534
535 #[test]
536 fn explicit_package_selects_named_member() {
537 let dir = workspace_with_two_members(None);
538 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
539 let sel = resolve_package_selection(
540 &graph,
541 &PackageSelection {
542 mode: SelectionMode::ExplicitPackages(vec!["a".into()]),
543 exclude: Vec::new(),
544 },
545 )
546 .unwrap();
547 assert_eq!(names(&graph, &sel), vec!["a"]);
548 }
549
550 #[test]
551 fn explicit_package_unknown_errors() {
552 let dir = workspace_with_two_members(None);
553 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
554 let err = resolve_package_selection(
555 &graph,
556 &PackageSelection {
557 mode: SelectionMode::ExplicitPackages(vec!["nope".into()]),
558 exclude: Vec::new(),
559 },
560 )
561 .unwrap_err();
562 assert!(matches!(err, WorkspaceError::PackageNotInWorkspace { .. }));
563 }
564
565 #[test]
566 fn default_members_mode_errors_when_none_declared() {
567 let dir = workspace_with_two_members(None);
568 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
569 let err = resolve_package_selection(
570 &graph,
571 &PackageSelection {
572 mode: SelectionMode::DefaultMembers,
573 exclude: Vec::new(),
574 },
575 )
576 .unwrap_err();
577 assert!(matches!(
578 err,
579 WorkspaceError::DefaultMemberNotInMembers { .. }
580 ));
581 }
582
583 #[test]
584 fn exclude_with_explicit_packages_errors() {
585 let dir = workspace_with_two_members(None);
586 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
587 let err = resolve_package_selection(
588 &graph,
589 &PackageSelection {
590 mode: SelectionMode::ExplicitPackages(vec!["a".into()]),
591 exclude: vec!["b".into()],
592 },
593 )
594 .unwrap_err();
595 assert!(matches!(
596 err,
597 WorkspaceError::ExcludeWithoutWorkspaceSelection
598 ));
599 }
600
601 fn three_member_workspace_app_lib_unrelated() -> TempDir {
609 let dir = TempDir::new().unwrap();
610 dir.child("cabin.toml")
611 .write_str(
612 r#"[workspace]
613members = ["packages/*"]
614"#,
615 )
616 .unwrap();
617 dir.child("packages/app/cabin.toml")
618 .write_str(
619 r#"[package]
620name = "app"
621version = "0.1.0"
622
623[dependencies]
624lib = { path = "../lib" }
625"#,
626 )
627 .unwrap();
628 dir.child("packages/lib/cabin.toml")
629 .write_str(
630 r#"[package]
631name = "lib"
632version = "0.1.0"
633
634[dependencies]
635fmt = ">=10 <11"
636"#,
637 )
638 .unwrap();
639 dir.child("packages/unrelated/cabin.toml")
640 .write_str(
641 r#"[package]
642name = "unrelated"
643version = "0.1.0"
644
645[dependencies]
646spdlog = "^1"
647"#,
648 )
649 .unwrap();
650 dir
651 }
652
653 #[test]
654 fn closure_includes_local_path_dependency() {
655 let dir = three_member_workspace_app_lib_unrelated();
656 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
657 let sel = resolve_package_selection(
658 &graph,
659 &PackageSelection {
660 mode: SelectionMode::ExplicitPackages(vec!["app".into()]),
661 exclude: Vec::new(),
662 },
663 )
664 .unwrap();
665 let closure = sel.closure(&graph);
666 let names: Vec<&str> = closure
667 .iter()
668 .map(|i| graph.packages[*i].package.name.as_str())
669 .collect();
670 assert!(names.contains(&"app"), "closure missing app: {names:?}");
671 assert!(names.contains(&"lib"), "closure missing lib: {names:?}");
672 assert!(
673 !names.contains(&"unrelated"),
674 "closure leaked unrelated: {names:?}"
675 );
676 }
677
678 #[test]
679 fn versioned_deps_walks_path_dep_closure() {
680 let dir = three_member_workspace_app_lib_unrelated();
681 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
682 let sel = resolve_package_selection(
683 &graph,
684 &PackageSelection {
685 mode: SelectionMode::ExplicitPackages(vec!["app".into()]),
686 exclude: Vec::new(),
687 },
688 )
689 .unwrap();
690 let closure = sel.closure(&graph);
691 let deps = collect_closure_versioned_deps_excluding_with_dev(
692 &graph,
693 &closure,
694 |_, _| false,
695 &BTreeSet::new(),
696 &BTreeSet::new(),
697 )
698 .unwrap();
699 let keys: Vec<&str> = deps.keys().map(cabin_core::PackageName::as_str).collect();
700 assert_eq!(keys, vec!["fmt"], "expected only fmt, got {keys:?}");
701 }
702
703 #[test]
704 fn versioned_deps_skip_unrelated_workspace_members() {
705 let dir = three_member_workspace_app_lib_unrelated();
706 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
707 let sel = resolve_package_selection(
708 &graph,
709 &PackageSelection {
710 mode: SelectionMode::ExplicitPackages(vec!["app".into()]),
711 exclude: Vec::new(),
712 },
713 )
714 .unwrap();
715 let closure = sel.closure(&graph);
716 let deps = collect_closure_versioned_deps_excluding_with_dev(
717 &graph,
718 &closure,
719 |_, _| false,
720 &BTreeSet::new(),
721 &BTreeSet::new(),
722 )
723 .unwrap();
724 assert!(
725 !deps.contains_key(&cabin_core::PackageName::new("spdlog").unwrap()),
726 "unrelated spdlog leaked into closure deps"
727 );
728 }
729
730 #[test]
735 fn versioned_deps_excludes_dev_kind() {
736 let dir = TempDir::new().unwrap();
737 dir.child("cabin.toml")
738 .write_str(
739 r#"[workspace]
740members = ["packages/app"]
741"#,
742 )
743 .unwrap();
744 dir.child("packages/app/cabin.toml")
745 .write_str(
746 r#"[package]
747name = "app"
748version = "0.1.0"
749
750[dependencies]
751fmt = ">=10"
752
753[dev-dependencies]
754gtest = "^1.14"
755"#,
756 )
757 .unwrap();
758 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
759 let sel = resolve_package_selection(
760 &graph,
761 &PackageSelection {
762 mode: SelectionMode::ExplicitPackages(vec!["app".into()]),
763 exclude: Vec::new(),
764 },
765 )
766 .unwrap();
767 let closure = sel.closure(&graph);
768 let deps = collect_closure_versioned_deps_excluding_with_dev(
769 &graph,
770 &closure,
771 |_, _| false,
772 &BTreeSet::new(),
773 &BTreeSet::new(),
774 )
775 .unwrap();
776 let keys: Vec<&str> = deps.keys().map(cabin_core::PackageName::as_str).collect();
777 assert_eq!(keys, vec!["fmt"]);
778 }
779
780 #[test]
781 fn excluded_names_are_dropped_from_versioned_deps() {
782 let dir = TempDir::new().unwrap();
783 dir.child("cabin.toml")
784 .write_str(
785 r#"[workspace]
786members = ["packages/app"]
787"#,
788 )
789 .unwrap();
790 dir.child("packages/app/cabin.toml")
791 .write_str(
792 r#"[package]
793name = "app"
794version = "0.1.0"
795
796[dependencies]
797fmt = ">=10"
798spdlog = "^1"
799"#,
800 )
801 .unwrap();
802 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
803 let sel = resolve_package_selection(
804 &graph,
805 &PackageSelection {
806 mode: SelectionMode::ExplicitPackages(vec!["app".into()]),
807 exclude: Vec::new(),
808 },
809 )
810 .unwrap();
811 let closure = sel.closure(&graph);
812 let mut excluded: BTreeSet<String> = BTreeSet::new();
813 excluded.insert("fmt".into());
814 let deps = collect_closure_versioned_deps_excluding_with_dev(
815 &graph,
816 &closure,
817 |_, _| false,
818 &excluded,
819 &BTreeSet::new(),
820 )
821 .unwrap();
822 let keys: Vec<&str> = deps.keys().map(cabin_core::PackageName::as_str).collect();
823 assert_eq!(keys, vec!["spdlog"]);
824 }
825
826 #[test]
827 fn closure_has_versioned_deps_excluding_returns_false_when_only_dep_is_excluded() {
828 let dir = TempDir::new().unwrap();
829 dir.child("cabin.toml")
830 .write_str(
831 r#"[workspace]
832members = ["packages/app"]
833"#,
834 )
835 .unwrap();
836 dir.child("packages/app/cabin.toml")
837 .write_str(
838 r#"[package]
839name = "app"
840version = "0.1.0"
841
842[dependencies]
843fmt = ">=10"
844"#,
845 )
846 .unwrap();
847 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
848 let sel = resolve_package_selection(
849 &graph,
850 &PackageSelection {
851 mode: SelectionMode::ExplicitPackages(vec!["app".into()]),
852 exclude: Vec::new(),
853 },
854 )
855 .unwrap();
856 let closure = sel.closure(&graph);
857 let mut excluded: BTreeSet<String> = BTreeSet::new();
858 excluded.insert("fmt".into());
859 assert!(!closure_has_versioned_deps_excluding_with_dev(
860 &graph,
861 &closure,
862 |_, _| false,
863 &excluded,
864 &BTreeSet::new(),
865 ));
866 assert!(closure_has_versioned_deps_excluding_with_dev(
869 &graph,
870 &closure,
871 |_, _| false,
872 &BTreeSet::new(),
873 &BTreeSet::new(),
874 ));
875 }
876
877 #[test]
878 fn versioned_deps_excludes_dev_dependencies() {
879 let dir = TempDir::new().unwrap();
880 dir.child("cabin.toml")
881 .write_str(
882 r#"[workspace]
883members = ["packages/app"]
884"#,
885 )
886 .unwrap();
887 dir.child("packages/app/cabin.toml")
888 .write_str(
889 r#"[package]
890name = "app"
891version = "0.1.0"
892
893[dependencies]
894fmt = ">=10"
895
896[dev-dependencies]
897gtest = "^1.14"
898"#,
899 )
900 .unwrap();
901 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
902 let sel = resolve_package_selection(
903 &graph,
904 &PackageSelection {
905 mode: SelectionMode::ExplicitPackages(vec!["app".into()]),
906 exclude: Vec::new(),
907 },
908 )
909 .unwrap();
910 let closure = sel.closure(&graph);
911 let deps = collect_closure_versioned_deps_excluding_with_dev(
912 &graph,
913 &closure,
914 |_, _| false,
915 &BTreeSet::new(),
916 &BTreeSet::new(),
917 )
918 .unwrap();
919 let keys: Vec<&str> = deps.keys().map(cabin_core::PackageName::as_str).collect();
920 assert_eq!(keys, vec!["fmt"]);
921 assert!(
922 !deps.contains_key(&cabin_core::PackageName::new("gtest").unwrap()),
923 "dev-dep gtest must not enter ordinary resolution"
924 );
925 }
926}