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