1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use cabin_core::{DependencyKind, DependencySource, PackageName, PortDepSource};
5use cabin_manifest::ParsedManifest;
6
7use crate::error::WorkspaceError;
8use crate::graph::{DependencyEdge, PackageGraph, PackageKind, WorkspacePackage};
9
10#[derive(Debug, Clone)]
15pub struct RegistryPackageSource {
16 pub name: PackageName,
17 pub version: semver::Version,
18 pub manifest_path: PathBuf,
20}
21
22#[derive(Debug, Clone)]
31pub struct PatchedPackageSource {
32 pub name: PackageName,
33 pub version: semver::Version,
34 pub manifest_path: PathBuf,
36}
37
38#[derive(Debug, Clone)]
46pub struct PortPackageSource {
47 pub name: PackageName,
49 pub version: semver::Version,
50 pub manifest_path: PathBuf,
54 pub origin: cabin_port::PortOrigin,
58}
59
60pub fn load_workspace(manifest_path: impl AsRef<Path>) -> Result<PackageGraph, WorkspaceError> {
77 load_workspace_inner(
78 manifest_path,
79 &[],
80 &[],
81 &[],
82 &RegistryEnforcement::strict(),
83 &BTreeSet::new(),
84 &PortMode::Strict,
85 )
86}
87
88pub fn load_workspace_skip_ports(
107 manifest_path: impl AsRef<Path>,
108) -> Result<PackageGraph, WorkspaceError> {
109 load_workspace_inner(
110 manifest_path,
111 &[],
112 &[],
113 &[],
114 &RegistryEnforcement::strict(),
115 &BTreeSet::new(),
116 &PortMode::SkipAll,
117 )
118}
119
120#[derive(Debug, Clone)]
124pub struct WorkspaceLoadOptions<'a> {
125 pub registry: &'a [RegistryPackageSource],
127 pub patches: &'a [PatchedPackageSource],
129 pub ports: &'a [PortPackageSource],
135 pub registry_policy: RegistryPolicy<'a>,
140 pub include_dev_for: &'a BTreeSet<String>,
146 pub port_policy: PortPolicy<'a>,
157}
158
159#[derive(Debug, Clone, Default)]
162pub enum PortPolicy<'a> {
163 #[default]
169 Strict,
170 TolerateExcept(&'a BTreeSet<String>),
181}
182
183#[derive(Debug, Clone, Default)]
191pub enum RegistryPolicy<'a> {
192 #[default]
196 Strict,
197 StrictFor(&'a BTreeSet<String>),
202}
203
204pub fn load_workspace_with_options(
222 manifest_path: impl AsRef<Path>,
223 options: &WorkspaceLoadOptions<'_>,
224) -> Result<PackageGraph, WorkspaceError> {
225 let policy = match &options.registry_policy {
226 RegistryPolicy::Strict => RegistryEnforcement::strict(),
227 RegistryPolicy::StrictFor(set) => RegistryEnforcement::scoped((*set).clone()),
228 };
229 let port_mode = match &options.port_policy {
230 PortPolicy::Strict => PortMode::Strict,
231 PortPolicy::TolerateExcept(strict) => PortMode::TolerateExcept((*strict).clone()),
232 };
233 load_workspace_inner(
234 manifest_path,
235 options.registry,
236 options.patches,
237 options.ports,
238 &policy,
239 options.include_dev_for,
240 &port_mode,
241 )
242}
243
244#[derive(Debug, Clone)]
249struct RegistryEnforcement {
250 strict_packages: Option<BTreeSet<String>>,
254}
255
256impl RegistryEnforcement {
257 fn strict() -> Self {
258 Self {
259 strict_packages: None,
260 }
261 }
262
263 fn scoped(strict_packages: BTreeSet<String>) -> Self {
264 Self {
265 strict_packages: Some(strict_packages),
266 }
267 }
268
269 fn requires_registry_for(&self, parent_name: &str) -> bool {
270 match &self.strict_packages {
271 None => true,
272 Some(set) => set.contains(parent_name),
273 }
274 }
275}
276
277#[derive(Debug, Clone)]
282enum PortMode {
283 Strict,
291 SkipAll,
296 TolerateExcept(BTreeSet<String>),
304}
305
306fn load_workspace_inner(
307 manifest_path: impl AsRef<Path>,
308 registry: &[RegistryPackageSource],
309 patches: &[PatchedPackageSource],
310 ports: &[PortPackageSource],
311 policy: &RegistryEnforcement,
312 include_dev_for: &BTreeSet<String>,
313 port_mode: &PortMode,
314) -> Result<PackageGraph, WorkspaceError> {
315 let skip_port_edges = matches!(port_mode, PortMode::SkipAll);
316 let tolerate_strict_set: Option<&BTreeSet<String>> = match port_mode {
317 PortMode::TolerateExcept(set) => Some(set),
318 _ => None,
319 };
320 let manifest_path = canonicalize(manifest_path.as_ref())?;
321 let root_dir = manifest_path
322 .parent()
323 .ok_or_else(|| WorkspaceError::Io {
324 path: manifest_path.clone(),
325 source: std::io::Error::other("manifest path has no parent directory"),
326 })?
327 .to_path_buf();
328
329 let root_manifest = parse_manifest(&manifest_path)?;
330 if root_manifest.package.is_none() && root_manifest.workspace.is_none() {
331 return Err(WorkspaceError::EmptyManifest {
332 path: manifest_path,
333 });
334 }
335
336 let host_platform = cabin_core::TargetPlatform::current();
342
343 let is_workspace_root = root_manifest.workspace.is_some();
344
345 let mut loader = Loader {
346 packages: Vec::new(),
347 manifest_index: HashMap::new(),
348 };
349
350 let mut primary_manifest_paths: Vec<PathBuf> = Vec::new();
354
355 if root_manifest.package.is_some() {
356 primary_manifest_paths.push(manifest_path.clone());
357 }
358
359 let mut workspace_default_members: Vec<String> = Vec::new();
362 let mut workspace_deps: BTreeMap<DependencyKind, BTreeMap<String, DependencySource>> =
370 BTreeMap::new();
371
372 let mut excluded_member_paths: Vec<PathBuf> = Vec::new();
373 if let Some(workspace) = &root_manifest.workspace {
374 let WorkspaceMembers { included, excluded } =
375 expand_workspace_members(&root_dir, &workspace.members, &workspace.exclude)?;
376 for canonical in included {
377 let parsed = parse_manifest(&canonical)?;
382 if parsed.workspace.is_some() {
383 return Err(WorkspaceError::NestedWorkspace { path: canonical });
384 }
385 primary_manifest_paths.push(canonical);
386 }
387 excluded_member_paths = excluded;
388 workspace_default_members.clone_from(&workspace.default_members);
389 for (kind, table) in [
390 (DependencyKind::Normal, &workspace.dependencies),
391 (DependencyKind::Dev, &workspace.dev_dependencies),
392 ] {
393 if table.is_empty() {
394 continue;
395 }
396 let entry = workspace_deps.entry(kind).or_default();
397 for (name, req) in table {
398 entry.insert(name.clone(), parse_workspace_dep_source(name, req)?);
399 }
400 }
401 }
402
403 let mut port_by_canonical_dir: HashMap<PathBuf, PathBuf> = HashMap::new();
413 let mut port_by_name: HashMap<String, PathBuf> = HashMap::new();
414 let mut port_canonical_paths: HashSet<PathBuf> = HashSet::new();
415 for entry in ports {
416 match &entry.origin {
417 cabin_port::PortOrigin::PortDir(port_dir) => {
418 let port_dir_canonical = canonicalize(port_dir)?;
419 if let Some(previous) =
420 port_by_canonical_dir.insert(port_dir_canonical, entry.manifest_path.clone())
421 {
422 return Err(WorkspaceError::DuplicatePackageName {
423 name: entry.name.as_str().to_owned(),
424 first: previous,
425 second: entry.manifest_path.clone(),
426 });
427 }
428 }
429 cabin_port::PortOrigin::Builtin(name) => {
430 if let Some(previous) =
431 port_by_name.insert((*name).to_owned(), entry.manifest_path.clone())
432 {
433 return Err(WorkspaceError::DuplicatePackageName {
434 name: entry.name.as_str().to_owned(),
435 first: previous,
436 second: entry.manifest_path.clone(),
437 });
438 }
439 }
440 }
441 port_canonical_paths.insert(canonicalize(&entry.manifest_path)?);
442 }
443
444 let mut registry_by_name: HashMap<&str, PathBuf> = HashMap::new();
450 let mut registry_canonical_names: HashMap<PathBuf, &PackageName> = HashMap::new();
451 let mut registry_canonical_versions: HashMap<PathBuf, &semver::Version> = HashMap::new();
452 let mut registry_canonical_paths: HashSet<PathBuf> = HashSet::new();
453 let mut patch_canonical_paths: HashSet<PathBuf> = HashSet::new();
454 for entry in registry {
455 let canonical = canonicalize(&entry.manifest_path)?;
456 registry_by_name.insert(entry.name.as_str(), canonical.clone());
457 registry_canonical_names.insert(canonical.clone(), &entry.name);
458 registry_canonical_versions.insert(canonical.clone(), &entry.version);
459 registry_canonical_paths.insert(canonical);
460 }
461 for entry in patches {
467 let canonical = canonicalize(&entry.manifest_path)?;
468 if registry_canonical_paths.contains(&canonical) {
469 return Err(WorkspaceError::PatchConflictsWithRegistry {
470 package: entry.name.as_str().to_owned(),
471 path: canonical,
472 });
473 }
474 if let Some(existing) = registry_by_name.insert(entry.name.as_str(), canonical.clone()) {
475 return Err(WorkspaceError::DuplicatePackageName {
476 name: entry.name.as_str().to_owned(),
477 first: existing,
478 second: canonical,
479 });
480 }
481 registry_canonical_names.insert(canonical.clone(), &entry.name);
482 registry_canonical_versions.insert(canonical.clone(), &entry.version);
483 registry_canonical_paths.insert(canonical.clone());
484 patch_canonical_paths.insert(canonical);
485 }
486
487 let mut to_load: Vec<PathBuf> = primary_manifest_paths.clone();
491 for entry in registry {
494 let canonical = canonicalize(&entry.manifest_path)?;
495 to_load.push(canonical);
496 }
497 for entry in patches {
501 let canonical = canonicalize(&entry.manifest_path)?;
502 to_load.push(canonical);
503 }
504 for entry in ports {
509 let canonical = canonicalize(&entry.manifest_path)?;
510 to_load.push(canonical);
511 }
512 let root_manifest_path = manifest_path.clone();
513 while let Some(manifest_path) = to_load.pop() {
514 if loader.manifest_index.contains_key(&manifest_path) {
515 continue;
516 }
517 let parsed = parse_manifest(&manifest_path)?;
518 let package = parsed.package.ok_or_else(|| {
519 WorkspaceError::LocalDependencyIsWorkspace {
521 dep_name: project_alias_for(&loader, &manifest_path),
522 path: manifest_path.clone(),
523 }
524 })?;
525
526 if manifest_path != root_manifest_path && !package.profiles.is_empty() {
537 return Err(WorkspaceError::MemberDeclaresProfiles {
538 package: package.name.as_str().to_owned(),
539 path: manifest_path,
540 });
541 }
542 if manifest_path != root_manifest_path && !package.toolchain.is_empty() {
543 return Err(WorkspaceError::MemberDeclaresToolchain {
544 package: package.name.as_str().to_owned(),
545 path: manifest_path,
546 });
547 }
548 if manifest_path != root_manifest_path && !package.compiler_wrapper.is_empty() {
549 return Err(WorkspaceError::MemberDeclaresCompilerWrapper {
550 package: package.name.as_str().to_owned(),
551 path: manifest_path,
552 });
553 }
554 if manifest_path != root_manifest_path && !package.patches.is_empty() {
555 return Err(WorkspaceError::MemberDeclaresPatches {
556 package: package.name.as_str().to_owned(),
557 path: manifest_path,
558 });
559 }
560
561 if let Some(expected_version) = registry_canonical_versions.get(&manifest_path) {
573 let expected_name = registry_canonical_names.get(&manifest_path).copied();
574 let version_ok = &package.version == *expected_version;
575 let name_ok = expected_name.is_none_or(|n| n.as_str() == package.name.as_str());
576 if !name_ok {
577 return Err(WorkspaceError::RegistryPackageNameMismatch {
578 name: expected_name
579 .map(|n| n.as_str().to_owned())
580 .unwrap_or_default(),
581 actual_name: package.name.as_str().to_owned(),
582 path: manifest_path.clone(),
583 });
584 }
585 if !version_ok {
586 return Err(WorkspaceError::RegistryPackageMismatch {
587 name: expected_name
588 .map(|n| n.as_str().to_owned())
589 .unwrap_or_default(),
590 version: expected_version.to_string(),
591 actual_name: package.name.as_str().to_owned(),
592 actual_version: package.version.to_string(),
593 path: manifest_path.clone(),
594 });
595 }
596 }
597
598 let manifest_dir = manifest_path
599 .parent()
600 .expect("canonicalized manifest path has a parent")
601 .to_path_buf();
602
603 let resolved_project = resolve_workspace_dependencies(package.clone(), &workspace_deps)?;
609 let package = resolved_project;
610
611 let dev_active_for_this_pkg = include_dev_for.contains(package.name.as_str());
618 let parent_is_registry = registry_canonical_paths.contains(&manifest_path)
632 && !patch_canonical_paths.contains(&manifest_path)
633 && !port_canonical_paths.contains(&manifest_path);
634 let mut dep_paths: Vec<DepPath> = Vec::with_capacity(package.dependencies.len());
635 for dep in &package.dependencies {
636 let kind_active = dep.kind.is_resolved_by_default()
642 || (dev_active_for_this_pkg && dep.kind == DependencyKind::Dev);
643 if !kind_active {
644 continue;
645 }
646 if !dep.matches_platform(&host_platform) {
652 continue;
653 }
654 if parent_is_registry {
655 match &dep.source {
656 DependencySource::Path(_) => {
657 return Err(WorkspaceError::RegistryPackageDeclaresPathDependency {
658 package: package.name.as_str().to_owned(),
659 dep_name: dep.name.as_str().to_owned(),
660 path: manifest_path.clone(),
661 });
662 }
663 DependencySource::Port(_) => {
664 return Err(WorkspaceError::RegistryPackageDeclaresPortDependency {
665 package: package.name.as_str().to_owned(),
666 dep_name: dep.name.as_str().to_owned(),
667 path: manifest_path.clone(),
668 });
669 }
670 DependencySource::Version(_) | DependencySource::Workspace => {}
671 }
672 }
673 let canonical = match &dep.source {
674 DependencySource::Path(rel) => {
675 let candidate = manifest_dir.join(rel).join("cabin.toml");
676 if !candidate.is_file() {
677 return Err(WorkspaceError::LocalDependencyManifestMissing {
678 dep_name: dep.name.as_str().to_owned(),
679 expected: candidate,
680 });
681 }
682 canonicalize(&candidate)?
683 }
684 DependencySource::Port(PortDepSource::Path(rel)) => {
685 if skip_port_edges {
686 continue;
687 }
688 let tolerate =
696 tolerate_strict_set.is_some_and(|set| !set.contains(package.name.as_str()));
697 let port_dir = manifest_dir.join(rel);
698 if !port_dir.is_dir() {
699 if tolerate {
700 continue;
701 }
702 return Err(WorkspaceError::PortDirectoryMissing {
703 dep_name: dep.name.as_str().to_owned(),
704 parent: package.name.as_str().to_owned(),
705 port_dir,
706 });
707 }
708 let port_dir_canonical = canonicalize(&port_dir)?;
709 if let Some(manifest_path) = port_by_canonical_dir.get(&port_dir_canonical) {
710 canonicalize(manifest_path)?
711 } else {
712 if tolerate {
713 continue;
714 }
715 return Err(WorkspaceError::PortDependencyNotPrepared {
716 dep_name: dep.name.as_str().to_owned(),
717 parent: package.name.as_str().to_owned(),
718 port_dir: port_dir_canonical,
719 });
720 }
721 }
722 DependencySource::Port(PortDepSource::Builtin { name, .. }) => {
723 if skip_port_edges {
724 continue;
725 }
726 let tolerate =
727 tolerate_strict_set.is_some_and(|set| !set.contains(package.name.as_str()));
728 if let Some(manifest_path) = port_by_name.get(name.as_str()) {
729 canonicalize(manifest_path)?
730 } else {
731 if tolerate {
732 continue;
733 }
734 return Err(WorkspaceError::BuiltinPortDependencyNotPrepared {
735 dep_name: dep.name.as_str().to_owned(),
736 parent: package.name.as_str().to_owned(),
737 });
738 }
739 }
740 DependencySource::Version(_) => {
741 if registry_by_name.is_empty() {
746 continue;
747 }
748 if let Some(path) = registry_by_name.get(dep.name.as_str()) {
749 path.clone()
750 } else {
751 if !policy.requires_registry_for(package.name.as_str()) {
761 continue;
762 }
763 return Err(WorkspaceError::UnresolvedRegistryDependency {
764 dep_name: dep.name.as_str().to_owned(),
765 parent: package.name.as_str().to_owned(),
766 });
767 }
768 }
769 DependencySource::Workspace => {
770 return Err(WorkspaceError::UnresolvedWorkspaceDependency {
776 dep_name: dep.name.as_str().to_owned(),
777 parent: package.name.as_str().to_owned(),
778 kind: dep.kind,
779 });
780 }
781 };
782 dep_paths.push(DepPath {
783 name: dep.name.as_str().to_owned(),
784 path: canonical,
785 kind: dep.kind,
786 condition: dep.condition.clone(),
787 });
788 }
789
790 for DepPath {
793 name: dep_name,
794 path: dep_manifest_path,
795 ..
796 } in &dep_paths
797 {
798 let dep_parsed = parse_manifest(dep_manifest_path)?;
799 let actual = dep_parsed.package.as_ref().ok_or_else(|| {
800 WorkspaceError::LocalDependencyIsWorkspace {
801 dep_name: dep_name.clone(),
802 path: dep_manifest_path.clone(),
803 }
804 })?;
805 if actual.name.as_str() != dep_name {
806 return Err(WorkspaceError::DependencyNameMismatch {
807 dep_name: dep_name.clone(),
808 actual_name: actual.name.as_str().to_owned(),
809 path: dep_manifest_path.clone(),
810 });
811 }
812 }
813
814 let index = loader.packages.len();
815 loader.manifest_index.insert(manifest_path.clone(), index);
816 loader.packages.push(LoadedPackage {
817 package,
818 manifest_path: manifest_path.clone(),
819 manifest_dir,
820 dep_paths,
821 });
822 for dep in &loader.packages[index].dep_paths {
823 to_load.push(dep.path.clone());
824 }
825 }
826
827 {
830 let mut seen: HashMap<&str, &PathBuf> = HashMap::new();
831 for pkg in &loader.packages {
832 let name = pkg.package.name.as_str();
833 if let Some(prev) = seen.insert(name, &pkg.manifest_path) {
834 return Err(WorkspaceError::DuplicatePackageName {
835 name: name.to_owned(),
836 first: prev.clone(),
837 second: pkg.manifest_path.clone(),
838 });
839 }
840 }
841 }
842
843 let mut packages: Vec<WorkspacePackage> = Vec::with_capacity(loader.packages.len());
845 for pkg in &loader.packages {
846 let mut deps = Vec::with_capacity(pkg.dep_paths.len());
847 for dep in &pkg.dep_paths {
848 let idx = *loader
849 .manifest_index
850 .get(&dep.path)
851 .expect("dep manifest should have been loaded");
852 deps.push(DependencyEdge {
853 index: idx,
854 kind: dep.kind,
855 condition: dep.condition.clone(),
856 });
857 }
858 let kind = if patch_canonical_paths.contains(&pkg.manifest_path) {
859 PackageKind::Local
865 } else if port_canonical_paths.contains(&pkg.manifest_path) {
866 PackageKind::Local
870 } else if registry_canonical_paths.contains(&pkg.manifest_path) {
871 PackageKind::Registry
872 } else {
873 PackageKind::Local
874 };
875 packages.push(WorkspacePackage {
876 package: pkg.package.clone(),
877 manifest_path: pkg.manifest_path.clone(),
878 manifest_dir: pkg.manifest_dir.clone(),
879 deps,
880 kind,
881 });
882 }
883
884 let topo = topo_sort(&packages)?;
885
886 let new_position: HashMap<usize, usize> = topo
889 .iter()
890 .enumerate()
891 .map(|(new_idx, &old_idx)| (old_idx, new_idx))
892 .collect();
893
894 let mut sorted: Vec<WorkspacePackage> = topo
895 .iter()
896 .map(|&old_idx| packages[old_idx].clone())
897 .collect();
898 for pkg in &mut sorted {
899 for edge in &mut pkg.deps {
900 edge.index = new_position[&edge.index];
901 }
902 }
903
904 let primary_packages: Vec<usize> = primary_manifest_paths
905 .iter()
906 .map(|p| {
907 let old_idx = loader.manifest_index[p];
908 new_position[&old_idx]
909 })
910 .collect();
911
912 let root_package = if root_manifest.package.is_some() {
913 Some(new_position[&loader.manifest_index[&manifest_path]])
914 } else {
915 None
916 };
917
918 let mut default_members: Vec<usize> = Vec::new();
923 let mut seen_default: HashSet<usize> = HashSet::new();
924 for entry in &workspace_default_members {
925 validate_workspace_pattern("workspace.default-members", entry)?;
928 let dir = root_dir.join(entry);
929 let canonical_dir =
930 canonicalize(&dir).map_err(|_| WorkspaceError::DefaultMemberNotInMembers {
931 member: entry.clone(),
932 })?;
933 let manifest = canonical_dir.join("cabin.toml");
934 let idx = loader
935 .manifest_index
936 .get(&manifest)
937 .copied()
938 .ok_or_else(|| WorkspaceError::DefaultMemberNotInMembers {
939 member: entry.clone(),
940 })?;
941 let new_idx = new_position[&idx];
942 if !primary_packages.contains(&new_idx) {
943 return Err(WorkspaceError::DefaultMemberNotInMembers {
944 member: entry.clone(),
945 });
946 }
947 if seen_default.insert(new_idx) {
948 default_members.push(new_idx);
949 }
950 }
951
952 Ok(PackageGraph {
953 root_manifest_path: manifest_path,
954 root_dir,
955 is_workspace_root,
956 root_package,
957 root_settings: root_manifest.root_settings.into(),
958 primary_packages,
959 default_members,
960 excluded_members: excluded_member_paths,
961 packages: sorted,
962 })
963}
964
965struct Loader {
966 packages: Vec<LoadedPackage>,
967 manifest_index: HashMap<PathBuf, usize>,
969}
970
971struct LoadedPackage {
972 package: cabin_core::Package,
973 manifest_path: PathBuf,
974 manifest_dir: PathBuf,
975 dep_paths: Vec<DepPath>,
980}
981
982#[derive(Debug, Clone)]
983struct DepPath {
984 name: String,
985 path: PathBuf,
986 kind: cabin_core::DependencyKind,
987 condition: Option<cabin_core::Condition>,
992}
993
994fn project_alias_for(loader: &Loader, manifest_path: &Path) -> String {
999 for pkg in &loader.packages {
1000 for dep in &pkg.dep_paths {
1001 if dep.path == manifest_path {
1002 return dep.name.clone();
1003 }
1004 }
1005 }
1006 manifest_path.display().to_string()
1007}
1008
1009fn parse_manifest(path: &Path) -> Result<ParsedManifest, WorkspaceError> {
1010 cabin_manifest::load_manifest(path).map_err(|source| WorkspaceError::Manifest {
1011 path: path.to_path_buf(),
1012 source: Box::new(source),
1013 })
1014}
1015
1016fn canonicalize(path: &Path) -> Result<PathBuf, WorkspaceError> {
1017 std::fs::canonicalize(path).map_err(|source| classify_manifest_io(path, source))
1018}
1019
1020fn classify_manifest_io(path: &Path, source: std::io::Error) -> WorkspaceError {
1027 match source.kind() {
1028 std::io::ErrorKind::NotFound => WorkspaceError::ManifestNotFound {
1029 path: path.to_path_buf(),
1030 },
1031 _ => WorkspaceError::ManifestUnreadable {
1032 path: path.to_path_buf(),
1033 source,
1034 },
1035 }
1036}
1037
1038struct WorkspaceMembers {
1044 included: Vec<PathBuf>,
1045 excluded: Vec<PathBuf>,
1046}
1047
1048fn expand_workspace_members(
1049 workspace_dir: &Path,
1050 members: &[String],
1051 exclude: &[String],
1052) -> Result<WorkspaceMembers, WorkspaceError> {
1053 let mut included: BTreeSet<PathBuf> = BTreeSet::new();
1057 for pattern in members {
1058 let dirs = expand_member_pattern(workspace_dir, pattern)?;
1059 for dir in dirs {
1060 let manifest = dir.join("cabin.toml");
1061 if !manifest.is_file() {
1062 return Err(WorkspaceError::WorkspaceMemberMissing {
1063 pattern: pattern.clone(),
1064 root: workspace_dir.to_path_buf(),
1065 });
1066 }
1067 let canonical_dir = canonicalize(&dir)?;
1068 included.insert(canonical_dir);
1069 }
1070 }
1071
1072 let mut excluded: BTreeSet<PathBuf> = BTreeSet::new();
1079 let canonical_root = canonicalize(workspace_dir)?;
1080 for pattern in exclude {
1081 if pattern.is_empty() {
1082 return Err(WorkspaceError::UnsupportedWorkspacePattern {
1083 pattern: pattern.clone(),
1084 });
1085 }
1086 let dirs = expand_exclude_pattern(workspace_dir, pattern)?;
1087 let mut hit_any = false;
1088 for dir in dirs {
1089 if !dir.is_dir() {
1094 continue;
1095 }
1096 let Ok(canonical_dir) = canonicalize(&dir) else {
1097 continue;
1098 };
1099 if included.remove(&canonical_dir) {
1100 hit_any = true;
1101 if let Ok(rel) = canonical_dir.strip_prefix(&canonical_root) {
1102 excluded.insert(rel.to_path_buf());
1103 } else {
1104 excluded.insert(canonical_dir.clone());
1105 }
1106 }
1107 }
1108 if !hit_any {
1109 return Err(WorkspaceError::UnusedExcludePattern {
1110 pattern: pattern.clone(),
1111 root: workspace_dir.to_path_buf(),
1112 });
1113 }
1114 }
1115
1116 let mut out: Vec<PathBuf> = Vec::with_capacity(included.len());
1118 for dir in &included {
1119 let manifest = dir.join("cabin.toml");
1120 out.push(canonicalize(&manifest)?);
1121 }
1122 out.sort();
1123 let excluded_paths: Vec<PathBuf> = excluded.into_iter().collect();
1124 Ok(WorkspaceMembers {
1125 included: out,
1126 excluded: excluded_paths,
1127 })
1128}
1129
1130fn resolve_workspace_dependencies(
1137 mut package: cabin_core::Package,
1138 workspace_deps: &BTreeMap<DependencyKind, BTreeMap<String, DependencySource>>,
1139) -> Result<cabin_core::Package, WorkspaceError> {
1140 for dep in &mut package.dependencies {
1141 if !matches!(dep.source, DependencySource::Workspace) {
1142 continue;
1143 }
1144 let table = workspace_deps.get(&dep.kind);
1145 let resolved = table
1146 .and_then(|t| t.get(dep.name.as_str()))
1147 .ok_or_else(|| WorkspaceError::UnresolvedWorkspaceDependency {
1148 dep_name: dep.name.as_str().to_owned(),
1149 parent: package.name.as_str().to_owned(),
1150 kind: dep.kind,
1151 })?;
1152 dep.source = resolved.clone();
1153 }
1154 Ok(package)
1155}
1156
1157fn parse_workspace_dep_source(name: &str, req: &str) -> Result<DependencySource, WorkspaceError> {
1161 let manifest = format!(
1166 "[package]\nname = \"__workspace_root__\"\nversion = \"0.0.0\"\n[dependencies]\n{name} = \"{}\"\n",
1167 req.replace('"', "\\\""),
1168 );
1169 let parsed = cabin_manifest::parse_manifest_str(&manifest).map_err(|source| {
1170 WorkspaceError::InvalidWorkspaceDependency {
1171 name: name.to_owned(),
1172 source: Box::new(source),
1173 }
1174 })?;
1175 let package = parsed
1176 .package
1177 .expect("inline manifest always has [package]");
1178 let dep = package
1179 .dependencies
1180 .into_iter()
1181 .next()
1182 .expect("inline manifest declared exactly one dependency");
1183 Ok(dep.source)
1184}
1185
1186fn validate_workspace_pattern(field: &'static str, pattern: &str) -> Result<(), WorkspaceError> {
1191 if pattern.is_empty() {
1192 return Err(WorkspaceError::UnsupportedWorkspacePattern {
1193 pattern: pattern.to_owned(),
1194 });
1195 }
1196 let p = std::path::Path::new(pattern);
1197 if p.is_absolute() {
1198 return Err(WorkspaceError::WorkspacePatternEscapesRoot {
1199 field,
1200 pattern: pattern.to_owned(),
1201 });
1202 }
1203 for component in p.components() {
1204 if matches!(
1205 component,
1206 std::path::Component::ParentDir | std::path::Component::Prefix(_)
1207 ) {
1208 return Err(WorkspaceError::WorkspacePatternEscapesRoot {
1209 field,
1210 pattern: pattern.to_owned(),
1211 });
1212 }
1213 }
1214 Ok(())
1215}
1216
1217fn expand_member_pattern(
1223 workspace_dir: &Path,
1224 pattern: &str,
1225) -> Result<Vec<PathBuf>, WorkspaceError> {
1226 validate_workspace_pattern("workspace.members", pattern)?;
1227
1228 if !pattern.contains('*') {
1229 let dir = workspace_dir.join(pattern);
1230 return Ok(vec![dir]);
1231 }
1232
1233 let Some(trimmed) = pattern.strip_suffix("/*") else {
1235 return Err(WorkspaceError::UnsupportedWorkspacePattern {
1236 pattern: pattern.to_owned(),
1237 });
1238 };
1239 if trimmed.contains('*') {
1240 return Err(WorkspaceError::UnsupportedWorkspacePattern {
1241 pattern: pattern.to_owned(),
1242 });
1243 }
1244
1245 let prefix_dir = if trimmed.is_empty() {
1246 workspace_dir.to_path_buf()
1247 } else {
1248 workspace_dir.join(trimmed)
1249 };
1250 if !prefix_dir.is_dir() {
1251 return Err(WorkspaceError::WorkspaceMemberMissing {
1252 pattern: pattern.to_owned(),
1253 root: workspace_dir.to_path_buf(),
1254 });
1255 }
1256
1257 let entries = std::fs::read_dir(&prefix_dir).map_err(|source| WorkspaceError::Io {
1258 path: prefix_dir.clone(),
1259 source,
1260 })?;
1261 let mut out = Vec::new();
1262 for entry in entries {
1263 let entry = entry.map_err(|source| WorkspaceError::Io {
1264 path: prefix_dir.clone(),
1265 source,
1266 })?;
1267 let path = entry.path();
1268 if path.is_dir() && path.join("cabin.toml").is_file() {
1269 out.push(path);
1270 }
1271 }
1272 if out.is_empty() {
1273 return Err(WorkspaceError::WorkspaceMemberMissing {
1274 pattern: pattern.to_owned(),
1275 root: workspace_dir.to_path_buf(),
1276 });
1277 }
1278 out.sort();
1279 Ok(out)
1280}
1281
1282fn expand_exclude_pattern(
1288 workspace_dir: &Path,
1289 pattern: &str,
1290) -> Result<Vec<PathBuf>, WorkspaceError> {
1291 validate_workspace_pattern("workspace.exclude", pattern)?;
1292
1293 if !pattern.contains('*') {
1294 return Ok(vec![workspace_dir.join(pattern)]);
1295 }
1296
1297 let Some(trimmed) = pattern.strip_suffix("/*") else {
1298 return Err(WorkspaceError::UnsupportedWorkspacePattern {
1299 pattern: pattern.to_owned(),
1300 });
1301 };
1302 if trimmed.contains('*') {
1303 return Err(WorkspaceError::UnsupportedWorkspacePattern {
1304 pattern: pattern.to_owned(),
1305 });
1306 }
1307
1308 let prefix_dir = if trimmed.is_empty() {
1309 workspace_dir.to_path_buf()
1310 } else {
1311 workspace_dir.join(trimmed)
1312 };
1313 if !prefix_dir.is_dir() {
1314 return Ok(Vec::new());
1315 }
1316
1317 let entries = std::fs::read_dir(&prefix_dir).map_err(|source| WorkspaceError::Io {
1318 path: prefix_dir.clone(),
1319 source,
1320 })?;
1321 let mut out = Vec::new();
1322 for entry in entries {
1323 let entry = entry.map_err(|source| WorkspaceError::Io {
1324 path: prefix_dir.clone(),
1325 source,
1326 })?;
1327 let path = entry.path();
1328 if path.is_dir() {
1329 out.push(path);
1330 }
1331 }
1332 out.sort();
1333 Ok(out)
1334}
1335
1336fn topo_sort(packages: &[WorkspacePackage]) -> Result<Vec<usize>, WorkspaceError> {
1337 #[derive(Clone, Copy)]
1338 enum Color {
1339 Visiting,
1340 Done,
1341 }
1342
1343 fn visit(
1344 node: usize,
1345 packages: &[WorkspacePackage],
1346 state: &mut Vec<Option<Color>>,
1347 path: &mut Vec<usize>,
1348 order: &mut Vec<usize>,
1349 ) -> Result<(), WorkspaceError> {
1350 match state[node] {
1351 Some(Color::Done) => return Ok(()),
1352 Some(Color::Visiting) => {
1353 let start = path.iter().position(|n| *n == node).unwrap_or(0);
1354 let mut cycle: Vec<String> = path[start..]
1355 .iter()
1356 .map(|i| packages[*i].package.name.as_str().to_owned())
1357 .collect();
1358 cycle.push(packages[node].package.name.as_str().to_owned());
1359 return Err(WorkspaceError::PackageDependencyCycle(cycle));
1360 }
1361 None => {}
1362 }
1363 state[node] = Some(Color::Visiting);
1364 path.push(node);
1365 for edge in &packages[node].deps {
1366 visit(edge.index, packages, state, path, order)?;
1367 }
1368 path.pop();
1369 state[node] = Some(Color::Done);
1370 order.push(node);
1371 Ok(())
1372 }
1373
1374 let mut state: Vec<Option<Color>> = vec![None; packages.len()];
1375 let mut order = Vec::with_capacity(packages.len());
1376 let mut path = Vec::new();
1377
1378 for i in 0..packages.len() {
1381 if state[i].is_none() {
1382 visit(i, packages, &mut state, &mut path, &mut order)?;
1383 }
1384 }
1385 Ok(order)
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390 use super::*;
1391 use assert_fs::TempDir;
1392 use assert_fs::prelude::*;
1393
1394 #[test]
1395 fn loads_single_package_with_no_deps() {
1396 let dir = TempDir::new().unwrap();
1397 dir.child("cabin.toml")
1398 .write_str(
1399 r#"[package]
1400name = "solo"
1401version = "0.1.0"
1402"#,
1403 )
1404 .unwrap();
1405 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
1406 assert!(!graph.is_workspace_root);
1407 assert_eq!(graph.packages.len(), 1);
1408 assert_eq!(graph.packages[0].package.name.as_str(), "solo");
1409 assert_eq!(graph.packages[0].deps.len(), 0);
1410 assert_eq!(graph.primary_packages, vec![0]);
1411 assert_eq!(graph.root_package, Some(0));
1412 }
1413
1414 #[test]
1415 fn loads_package_with_local_path_dep() {
1416 let dir = TempDir::new().unwrap();
1417 dir.child("greet/cabin.toml")
1418 .write_str(
1419 r#"[package]
1420name = "greet"
1421version = "0.1.0"
1422"#,
1423 )
1424 .unwrap();
1425 dir.child("app/cabin.toml")
1426 .write_str(
1427 r#"[package]
1428name = "app"
1429version = "0.1.0"
1430
1431[dependencies]
1432greet = { path = "../greet" }
1433"#,
1434 )
1435 .unwrap();
1436 let graph = load_workspace(dir.path().join("app/cabin.toml")).unwrap();
1437 assert_eq!(graph.packages.len(), 2);
1438 assert_eq!(graph.packages[0].package.name.as_str(), "greet");
1440 assert_eq!(graph.packages[1].package.name.as_str(), "app");
1441 assert_eq!(
1442 graph.packages[1]
1443 .deps
1444 .iter()
1445 .map(|e| (e.index, e.kind))
1446 .collect::<Vec<_>>(),
1447 vec![(0, DependencyKind::Normal)]
1448 );
1449 assert_eq!(graph.primary_packages, vec![1]);
1450 }
1451
1452 #[test]
1453 fn loads_transitive_local_path_deps() {
1454 let dir = TempDir::new().unwrap();
1455 dir.child("c/cabin.toml")
1456 .write_str(
1457 r#"[package]
1458name = "c"
1459version = "0.1.0"
1460"#,
1461 )
1462 .unwrap();
1463 dir.child("b/cabin.toml")
1464 .write_str(
1465 r#"[package]
1466name = "b"
1467version = "0.1.0"
1468
1469[dependencies]
1470c = { path = "../c" }
1471"#,
1472 )
1473 .unwrap();
1474 dir.child("a/cabin.toml")
1475 .write_str(
1476 r#"[package]
1477name = "a"
1478version = "0.1.0"
1479
1480[dependencies]
1481b = { path = "../b" }
1482"#,
1483 )
1484 .unwrap();
1485 let graph = load_workspace(dir.path().join("a/cabin.toml")).unwrap();
1486 assert_eq!(graph.packages.len(), 3);
1487 let names: Vec<&str> = graph
1488 .packages
1489 .iter()
1490 .map(|p| p.package.name.as_str())
1491 .collect();
1492 let pos = |n: &str| names.iter().position(|x| *x == n).unwrap();
1494 assert!(pos("c") < pos("b"));
1495 assert!(pos("b") < pos("a"));
1496 }
1497
1498 #[test]
1499 fn detects_package_cycle() {
1500 let dir = TempDir::new().unwrap();
1501 dir.child("a/cabin.toml")
1502 .write_str(
1503 r#"[package]
1504name = "a"
1505version = "0.1.0"
1506
1507[dependencies]
1508b = { path = "../b" }
1509"#,
1510 )
1511 .unwrap();
1512 dir.child("b/cabin.toml")
1513 .write_str(
1514 r#"[package]
1515name = "b"
1516version = "0.1.0"
1517
1518[dependencies]
1519a = { path = "../a" }
1520"#,
1521 )
1522 .unwrap();
1523 let err = load_workspace(dir.path().join("a/cabin.toml")).unwrap_err();
1524 match err {
1525 WorkspaceError::PackageDependencyCycle(cycle) => {
1526 assert_eq!(cycle.first(), cycle.last());
1527 assert!(cycle.contains(&"a".to_owned()));
1528 assert!(cycle.contains(&"b".to_owned()));
1529 }
1530 other => panic!("expected PackageDependencyCycle, got {other:?}"),
1531 }
1532 }
1533
1534 #[test]
1535 fn loads_workspace_with_exact_member_path() {
1536 let dir = TempDir::new().unwrap();
1537 dir.child("cabin.toml")
1538 .write_str(
1539 r#"[workspace]
1540members = ["packages/greet"]
1541"#,
1542 )
1543 .unwrap();
1544 dir.child("packages/greet/cabin.toml")
1545 .write_str(
1546 r#"[package]
1547name = "greet"
1548version = "0.1.0"
1549"#,
1550 )
1551 .unwrap();
1552 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
1553 assert!(graph.is_workspace_root);
1554 assert!(graph.root_package.is_none());
1555 assert_eq!(graph.packages.len(), 1);
1556 assert_eq!(graph.packages[0].package.name.as_str(), "greet");
1557 }
1558
1559 #[test]
1560 fn pure_workspace_root_policy_is_available_on_graph() {
1561 let dir = TempDir::new().unwrap();
1562 dir.child("cabin.toml")
1563 .write_str(
1564 r#"[workspace]
1565members = ["packages/greet"]
1566
1567[profile.release]
1568opt-level = 0
1569
1570[toolchain]
1571cxx = "clang++"
1572
1573[profile.cache]
1574compiler-wrapper = "ccache"
1575
1576[patch]
1577fmt = { path = "../fmt" }
1578"#,
1579 )
1580 .unwrap();
1581 dir.child("packages/greet/cabin.toml")
1582 .write_str(
1583 r#"[package]
1584name = "greet"
1585version = "0.1.0"
1586"#,
1587 )
1588 .unwrap();
1589 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
1590 assert!(graph.is_workspace_root);
1591 assert!(graph.root_package.is_none());
1592
1593 let release = cabin_core::ProfileName::new("release").unwrap();
1594 assert_eq!(
1595 graph
1596 .root_settings
1597 .profiles
1598 .get(&release)
1599 .and_then(|p| p.opt_level),
1600 Some(cabin_core::OptLevel::O0)
1601 );
1602 assert_eq!(
1603 graph
1604 .root_settings
1605 .toolchain
1606 .general
1607 .get(cabin_core::ToolKind::CxxCompiler)
1608 .map(cabin_core::ToolSpec::display)
1609 .as_deref(),
1610 Some("clang++")
1611 );
1612 assert_eq!(
1613 graph.root_settings.compiler_wrapper.general,
1614 Some(cabin_core::CompilerWrapperRequest::Use {
1615 wrapper: cabin_core::CompilerWrapperKind::Ccache,
1616 })
1617 );
1618 assert_eq!(graph.root_settings.patches.entries.len(), 1);
1619 }
1620
1621 #[test]
1622 fn loads_workspace_with_glob_member_pattern() {
1623 let dir = TempDir::new().unwrap();
1624 dir.child("cabin.toml")
1625 .write_str(
1626 r#"[workspace]
1627members = ["packages/*"]
1628"#,
1629 )
1630 .unwrap();
1631 dir.child("packages/a/cabin.toml")
1632 .write_str(
1633 r#"[package]
1634name = "a"
1635version = "0.1.0"
1636"#,
1637 )
1638 .unwrap();
1639 dir.child("packages/b/cabin.toml")
1640 .write_str(
1641 r#"[package]
1642name = "b"
1643version = "0.1.0"
1644"#,
1645 )
1646 .unwrap();
1647 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
1648 assert_eq!(graph.packages.len(), 2);
1649 let names: Vec<&str> = graph
1650 .packages
1651 .iter()
1652 .map(|p| p.package.name.as_str())
1653 .collect();
1654 assert!(names.contains(&"a"));
1655 assert!(names.contains(&"b"));
1656 }
1657
1658 #[test]
1659 fn rejects_duplicate_package_names_in_workspace() {
1660 let dir = TempDir::new().unwrap();
1661 dir.child("cabin.toml")
1662 .write_str(
1663 r#"[workspace]
1664members = ["packages/*"]
1665"#,
1666 )
1667 .unwrap();
1668 dir.child("packages/a/cabin.toml")
1669 .write_str(
1670 r#"[package]
1671name = "shared"
1672version = "0.1.0"
1673"#,
1674 )
1675 .unwrap();
1676 dir.child("packages/b/cabin.toml")
1677 .write_str(
1678 r#"[package]
1679name = "shared"
1680version = "0.2.0"
1681"#,
1682 )
1683 .unwrap();
1684 let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
1685 match err {
1686 WorkspaceError::DuplicatePackageName { name, .. } => assert_eq!(name, "shared"),
1687 other => panic!("expected DuplicatePackageName, got {other:?}"),
1688 }
1689 }
1690
1691 #[test]
1692 fn missing_local_dependency_manifest_errors() {
1693 let dir = TempDir::new().unwrap();
1694 dir.child("app/cabin.toml")
1695 .write_str(
1696 r#"[package]
1697name = "app"
1698version = "0.1.0"
1699
1700[dependencies]
1701greet = { path = "../greet" }
1702"#,
1703 )
1704 .unwrap();
1705 let err = load_workspace(dir.path().join("app/cabin.toml")).unwrap_err();
1706 assert!(matches!(
1707 err,
1708 WorkspaceError::LocalDependencyManifestMissing { .. }
1709 ));
1710 }
1711
1712 #[test]
1713 fn dependency_name_mismatch_errors() {
1714 let dir = TempDir::new().unwrap();
1715 dir.child("greet/cabin.toml")
1716 .write_str(
1717 r#"[package]
1718name = "actually-hello"
1719version = "0.1.0"
1720"#,
1721 )
1722 .unwrap();
1723 dir.child("app/cabin.toml")
1724 .write_str(
1725 r#"[package]
1726name = "app"
1727version = "0.1.0"
1728
1729[dependencies]
1730greet = { path = "../greet" }
1731"#,
1732 )
1733 .unwrap();
1734 let err = load_workspace(dir.path().join("app/cabin.toml")).unwrap_err();
1735 match err {
1736 WorkspaceError::DependencyNameMismatch {
1737 dep_name,
1738 actual_name,
1739 ..
1740 } => {
1741 assert_eq!(dep_name, "greet");
1742 assert_eq!(actual_name, "actually-hello");
1743 }
1744 other => panic!("expected DependencyNameMismatch, got {other:?}"),
1745 }
1746 }
1747
1748 #[test]
1749 fn versioned_dependencies_are_preserved_but_not_traversed() {
1750 let dir = TempDir::new().unwrap();
1751 dir.child("app/cabin.toml")
1752 .write_str(
1753 r#"[package]
1754name = "app"
1755version = "0.1.0"
1756
1757[dependencies]
1758fmt = ">=10.0.0 <11.0.0"
1759"#,
1760 )
1761 .unwrap();
1762 let graph = load_workspace(dir.path().join("app/cabin.toml")).unwrap();
1763 assert_eq!(graph.packages.len(), 1);
1766 let app = &graph.packages[0];
1767 assert!(app.deps.is_empty());
1768 assert_eq!(app.package.dependencies.len(), 1);
1770 assert_eq!(app.package.dependencies[0].name.as_str(), "fmt");
1771 assert!(matches!(
1772 &app.package.dependencies[0].source,
1773 cabin_core::DependencySource::Version(_)
1774 ));
1775 }
1776
1777 #[test]
1778 fn unsupported_glob_pattern_errors() {
1779 let dir = TempDir::new().unwrap();
1780 dir.child("cabin.toml")
1781 .write_str(
1782 r#"[workspace]
1783members = ["packages/*/foo"]
1784"#,
1785 )
1786 .unwrap();
1787 dir.child("packages/a/foo/cabin.toml")
1788 .write_str(
1789 r#"[package]
1790name = "a"
1791version = "0.1.0"
1792"#,
1793 )
1794 .unwrap();
1795 let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
1796 assert!(matches!(
1797 err,
1798 WorkspaceError::UnsupportedWorkspacePattern { .. }
1799 ));
1800 }
1801
1802 #[test]
1803 fn missing_workspace_member_errors() {
1804 let dir = TempDir::new().unwrap();
1805 dir.child("cabin.toml")
1806 .write_str(
1807 r#"[workspace]
1808members = ["packages/missing"]
1809"#,
1810 )
1811 .unwrap();
1812 let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
1813 assert!(matches!(err, WorkspaceError::WorkspaceMemberMissing { .. }));
1814 }
1815
1816 fn pkg(name: &str) -> PackageName {
1821 PackageName::new(name).unwrap()
1822 }
1823
1824 fn ver(s: &str) -> semver::Version {
1825 semver::Version::parse(s).unwrap()
1826 }
1827
1828 #[test]
1829 fn loads_registry_package_via_versioned_dep() {
1830 let dir = TempDir::new().unwrap();
1831 dir.child("app/cabin.toml")
1833 .write_str(
1834 r#"[package]
1835name = "app"
1836version = "0.1.0"
1837
1838[dependencies]
1839fmt = ">=10.0.0 <11.0.0"
1840"#,
1841 )
1842 .unwrap();
1843 dir.child("registry/fmt/cabin.toml")
1845 .write_str(
1846 r#"[package]
1847name = "fmt"
1848version = "10.2.1"
1849"#,
1850 )
1851 .unwrap();
1852 let registry = vec![RegistryPackageSource {
1853 name: pkg("fmt"),
1854 version: ver("10.2.1"),
1855 manifest_path: dir.path().join("registry/fmt/cabin.toml"),
1856 }];
1857 let graph = load_workspace_with_options(
1858 dir.path().join("app/cabin.toml"),
1859 &WorkspaceLoadOptions {
1860 registry: ®istry,
1861 patches: &[],
1862 ports: &[],
1863 registry_policy: RegistryPolicy::Strict,
1864 include_dev_for: &BTreeSet::new(),
1865 port_policy: PortPolicy::Strict,
1866 },
1867 )
1868 .unwrap();
1869 assert_eq!(graph.packages.len(), 2);
1870 assert_eq!(graph.packages[0].package.name.as_str(), "fmt");
1872 assert_eq!(graph.packages[0].kind, PackageKind::Registry);
1873 assert_eq!(graph.packages[1].package.name.as_str(), "app");
1874 assert_eq!(graph.packages[1].kind, PackageKind::Local);
1875 assert_eq!(graph.primary_packages, vec![1]);
1877 let edges: Vec<(usize, DependencyKind)> = graph.packages[1]
1879 .deps
1880 .iter()
1881 .map(|e| (e.index, e.kind))
1882 .collect();
1883 assert_eq!(edges, vec![(0, DependencyKind::Normal)]);
1884 }
1885
1886 #[test]
1887 fn registry_package_declaring_path_dependency_is_rejected() {
1888 let dir = TempDir::new().unwrap();
1889 dir.child("app/cabin.toml")
1890 .write_str(
1891 r#"[package]
1892name = "app"
1893version = "0.1.0"
1894
1895[dependencies]
1896evil = ">=1.0.0 <2.0.0"
1897"#,
1898 )
1899 .unwrap();
1900 dir.child("registry/evil/cabin.toml")
1905 .write_str(
1906 r#"[package]
1907name = "evil"
1908version = "1.0.0"
1909
1910[dependencies]
1911inner = { path = "inner" }
1912"#,
1913 )
1914 .unwrap();
1915 dir.child("registry/evil/inner/cabin.toml")
1916 .write_str(
1917 r#"[package]
1918name = "inner"
1919version = "1.0.0"
1920
1921[profile]
1922cxxflags = ["-fplugin=evil.so"]
1923"#,
1924 )
1925 .unwrap();
1926 let registry = vec![RegistryPackageSource {
1927 name: pkg("evil"),
1928 version: ver("1.0.0"),
1929 manifest_path: dir.path().join("registry/evil/cabin.toml"),
1930 }];
1931 let err = load_workspace_with_options(
1932 dir.path().join("app/cabin.toml"),
1933 &WorkspaceLoadOptions {
1934 registry: ®istry,
1935 patches: &[],
1936 ports: &[],
1937 registry_policy: RegistryPolicy::Strict,
1938 include_dev_for: &BTreeSet::new(),
1939 port_policy: PortPolicy::Strict,
1940 },
1941 )
1942 .unwrap_err();
1943 assert!(
1944 matches!(
1945 err,
1946 WorkspaceError::RegistryPackageDeclaresPathDependency { .. }
1947 ),
1948 "expected RegistryPackageDeclaresPathDependency, got {err:?}"
1949 );
1950 }
1951
1952 #[test]
1953 fn registry_package_declaring_port_dependency_is_rejected() {
1954 let dir = TempDir::new().unwrap();
1955 dir.child("app/cabin.toml")
1956 .write_str(
1957 r#"[package]
1958name = "app"
1959version = "0.1.0"
1960
1961[dependencies]
1962evil = ">=1.0.0 <2.0.0"
1963"#,
1964 )
1965 .unwrap();
1966 dir.child("registry/evil/cabin.toml")
1970 .write_str(
1971 r#"[package]
1972name = "evil"
1973version = "1.0.0"
1974
1975[dependencies]
1976inner = { port-path = "ports/inner" }
1977"#,
1978 )
1979 .unwrap();
1980 let registry = vec![RegistryPackageSource {
1981 name: pkg("evil"),
1982 version: ver("1.0.0"),
1983 manifest_path: dir.path().join("registry/evil/cabin.toml"),
1984 }];
1985 let err = load_workspace_with_options(
1986 dir.path().join("app/cabin.toml"),
1987 &WorkspaceLoadOptions {
1988 registry: ®istry,
1989 patches: &[],
1990 ports: &[],
1991 registry_policy: RegistryPolicy::Strict,
1992 include_dev_for: &BTreeSet::new(),
1993 port_policy: PortPolicy::Strict,
1994 },
1995 )
1996 .unwrap_err();
1997 assert!(
1998 matches!(
1999 err,
2000 WorkspaceError::RegistryPackageDeclaresPortDependency { .. }
2001 ),
2002 "expected RegistryPackageDeclaresPortDependency, got {err:?}"
2003 );
2004 }
2005
2006 #[test]
2007 fn unresolved_registry_dep_errors() {
2008 let dir = TempDir::new().unwrap();
2009 dir.child("app/cabin.toml")
2010 .write_str(
2011 r#"[package]
2012name = "app"
2013version = "0.1.0"
2014
2015[dependencies]
2016fmt = ">=10"
2017spdlog = ">=1"
2018"#,
2019 )
2020 .unwrap();
2021 dir.child("registry/fmt/cabin.toml")
2022 .write_str(
2023 r#"[package]
2024name = "fmt"
2025version = "10.2.1"
2026"#,
2027 )
2028 .unwrap();
2029 let registry = vec![RegistryPackageSource {
2031 name: pkg("fmt"),
2032 version: ver("10.2.1"),
2033 manifest_path: dir.path().join("registry/fmt/cabin.toml"),
2034 }];
2035 let err = load_workspace_with_options(
2036 dir.path().join("app/cabin.toml"),
2037 &WorkspaceLoadOptions {
2038 registry: ®istry,
2039 patches: &[],
2040 ports: &[],
2041 registry_policy: RegistryPolicy::Strict,
2042 include_dev_for: &BTreeSet::new(),
2043 port_policy: PortPolicy::Strict,
2044 },
2045 )
2046 .unwrap_err();
2047 match err {
2048 WorkspaceError::UnresolvedRegistryDependency { dep_name, parent } => {
2049 assert_eq!(dep_name, "spdlog");
2050 assert_eq!(parent, "app");
2051 }
2052 other => panic!("expected UnresolvedRegistryDependency, got {other:?}"),
2053 }
2054 }
2055
2056 #[test]
2057 fn registry_dep_chained_through_extracted_manifest() {
2058 let dir = TempDir::new().unwrap();
2059 dir.child("app/cabin.toml")
2061 .write_str(
2062 r#"[package]
2063name = "app"
2064version = "0.1.0"
2065
2066[dependencies]
2067spdlog = ">=1"
2068"#,
2069 )
2070 .unwrap();
2071 dir.child("registry/spdlog/cabin.toml")
2072 .write_str(
2073 r#"[package]
2074name = "spdlog"
2075version = "1.13.0"
2076
2077[dependencies]
2078fmt = ">=10"
2079"#,
2080 )
2081 .unwrap();
2082 dir.child("registry/fmt/cabin.toml")
2083 .write_str(
2084 r#"[package]
2085name = "fmt"
2086version = "10.2.1"
2087"#,
2088 )
2089 .unwrap();
2090 let registry = vec![
2091 RegistryPackageSource {
2092 name: pkg("fmt"),
2093 version: ver("10.2.1"),
2094 manifest_path: dir.path().join("registry/fmt/cabin.toml"),
2095 },
2096 RegistryPackageSource {
2097 name: pkg("spdlog"),
2098 version: ver("1.13.0"),
2099 manifest_path: dir.path().join("registry/spdlog/cabin.toml"),
2100 },
2101 ];
2102 let graph = load_workspace_with_options(
2103 dir.path().join("app/cabin.toml"),
2104 &WorkspaceLoadOptions {
2105 registry: ®istry,
2106 patches: &[],
2107 ports: &[],
2108 registry_policy: RegistryPolicy::Strict,
2109 include_dev_for: &BTreeSet::new(),
2110 port_policy: PortPolicy::Strict,
2111 },
2112 )
2113 .unwrap();
2114 assert_eq!(graph.packages.len(), 3);
2115 let names: Vec<&str> = graph
2117 .packages
2118 .iter()
2119 .map(|p| p.package.name.as_str())
2120 .collect();
2121 let pos = |n: &str| names.iter().position(|x| *x == n).unwrap();
2122 assert!(pos("fmt") < pos("spdlog"));
2123 assert!(pos("spdlog") < pos("app"));
2124 }
2125
2126 #[test]
2127 fn registry_package_version_mismatch_errors() {
2128 let dir = TempDir::new().unwrap();
2129 dir.child("app/cabin.toml")
2130 .write_str(
2131 r#"[package]
2132name = "app"
2133version = "0.1.0"
2134
2135[dependencies]
2136fmt = ">=10"
2137"#,
2138 )
2139 .unwrap();
2140 dir.child("registry/fmt/cabin.toml")
2141 .write_str(
2142 r#"[package]
2143name = "fmt"
2144version = "10.1.0"
2145"#,
2146 )
2147 .unwrap();
2148 let registry = vec![RegistryPackageSource {
2149 name: pkg("fmt"),
2150 version: ver("10.2.1"),
2151 manifest_path: dir.path().join("registry/fmt/cabin.toml"),
2152 }];
2153 let err = load_workspace_with_options(
2154 dir.path().join("app/cabin.toml"),
2155 &WorkspaceLoadOptions {
2156 registry: ®istry,
2157 patches: &[],
2158 ports: &[],
2159 registry_policy: RegistryPolicy::Strict,
2160 include_dev_for: &BTreeSet::new(),
2161 port_policy: PortPolicy::Strict,
2162 },
2163 )
2164 .unwrap_err();
2165 assert!(matches!(
2166 err,
2167 WorkspaceError::RegistryPackageMismatch { .. }
2168 ));
2169 }
2170
2171 #[test]
2177 fn exclude_drops_member_from_primary_set() {
2178 let dir = TempDir::new().unwrap();
2179 dir.child("cabin.toml")
2180 .write_str(
2181 r#"[workspace]
2182members = ["packages/*"]
2183exclude = ["packages/skipme"]
2184"#,
2185 )
2186 .unwrap();
2187 dir.child("packages/keep/cabin.toml")
2188 .write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
2189 .unwrap();
2190 dir.child("packages/skipme/cabin.toml")
2191 .write_str("[package]\nname = \"skipme\"\nversion = \"0.1.0\"\n")
2192 .unwrap();
2193 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
2194 let names: Vec<&str> = graph
2195 .primary_packages
2196 .iter()
2197 .map(|i| graph.packages[*i].package.name.as_str())
2198 .collect();
2199 assert_eq!(names, vec!["keep"]);
2200 assert_eq!(graph.excluded_members.len(), 1);
2201 assert!(
2202 graph.excluded_members[0]
2203 .to_string_lossy()
2204 .ends_with("skipme")
2205 );
2206 }
2207
2208 #[test]
2209 fn unused_exclude_pattern_errors() {
2210 let dir = TempDir::new().unwrap();
2211 dir.child("cabin.toml")
2212 .write_str(
2213 r#"[workspace]
2214members = ["packages/keep"]
2215exclude = ["packages/missing"]
2216"#,
2217 )
2218 .unwrap();
2219 dir.child("packages/keep/cabin.toml")
2220 .write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
2221 .unwrap();
2222 let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
2223 match err {
2224 WorkspaceError::UnusedExcludePattern { pattern, .. } => {
2225 assert_eq!(pattern, "packages/missing");
2226 }
2227 other => panic!("expected UnusedExcludePattern, got {other:?}"),
2228 }
2229 }
2230
2231 #[test]
2232 fn default_members_must_be_workspace_members() {
2233 let dir = TempDir::new().unwrap();
2234 dir.child("cabin.toml")
2235 .write_str(
2236 r#"[workspace]
2237members = ["packages/keep"]
2238default-members = ["packages/missing"]
2239"#,
2240 )
2241 .unwrap();
2242 dir.child("packages/keep/cabin.toml")
2243 .write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
2244 .unwrap();
2245 let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
2246 match err {
2247 WorkspaceError::DefaultMemberNotInMembers { member } => {
2248 assert_eq!(member, "packages/missing");
2249 }
2250 other => panic!("expected DefaultMemberNotInMembers, got {other:?}"),
2251 }
2252 }
2253
2254 #[test]
2255 fn default_members_resolved_to_indices() {
2256 let dir = TempDir::new().unwrap();
2257 dir.child("cabin.toml")
2258 .write_str(
2259 r#"[workspace]
2260members = ["packages/*"]
2261default-members = ["packages/a"]
2262"#,
2263 )
2264 .unwrap();
2265 dir.child("packages/a/cabin.toml")
2266 .write_str("[package]\nname = \"a\"\nversion = \"0.1.0\"\n")
2267 .unwrap();
2268 dir.child("packages/b/cabin.toml")
2269 .write_str("[package]\nname = \"b\"\nversion = \"0.1.0\"\n")
2270 .unwrap();
2271 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
2272 assert_eq!(graph.default_members.len(), 1);
2273 let name = graph.packages[graph.default_members[0]]
2274 .package
2275 .name
2276 .as_str();
2277 assert_eq!(name, "a");
2278 }
2279
2280 #[test]
2281 fn workspace_dependency_inheritance() {
2282 let dir = TempDir::new().unwrap();
2283 dir.child("cabin.toml")
2284 .write_str(
2285 r#"[workspace]
2286members = ["packages/app"]
2287
2288[workspace.dependencies]
2289fmt = ">=10 <11"
2290"#,
2291 )
2292 .unwrap();
2293 dir.child("packages/app/cabin.toml")
2294 .write_str(
2295 r#"[package]
2296name = "app"
2297version = "0.1.0"
2298
2299[dependencies]
2300fmt = { workspace = true }
2301"#,
2302 )
2303 .unwrap();
2304 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
2305 let app = graph
2306 .packages
2307 .iter()
2308 .find(|p| p.package.name.as_str() == "app")
2309 .unwrap();
2310 assert_eq!(app.package.dependencies.len(), 1);
2311 match &app.package.dependencies[0].source {
2312 cabin_core::DependencySource::Version(req) => {
2313 assert!(req.to_string().contains(">=10"));
2314 }
2315 other => panic!("expected resolved Version, got {other:?}"),
2316 }
2317 }
2318
2319 #[test]
2320 fn workspace_dependency_inheritance_per_kind() {
2321 let dir = TempDir::new().unwrap();
2325 dir.child("cabin.toml")
2326 .write_str(
2327 r#"[workspace]
2328members = ["packages/app"]
2329
2330[workspace.dependencies]
2331fmt = ">=10"
2332
2333[workspace.dev-dependencies]
2334gtest = "^1.14"
2335"#,
2336 )
2337 .unwrap();
2338 dir.child("packages/app/cabin.toml")
2339 .write_str(
2340 r#"[package]
2341name = "app"
2342version = "0.1.0"
2343
2344[dependencies]
2345fmt = { workspace = true }
2346
2347[dev-dependencies]
2348gtest = { workspace = true }
2349"#,
2350 )
2351 .unwrap();
2352 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
2353 let app = graph
2354 .packages
2355 .iter()
2356 .find(|p| p.package.name.as_str() == "app")
2357 .unwrap();
2358 for (name, kind) in [
2359 ("fmt", DependencyKind::Normal),
2360 ("gtest", DependencyKind::Dev),
2361 ] {
2362 let dep = app
2363 .package
2364 .dependencies
2365 .iter()
2366 .find(|d| d.name.as_str() == name && d.kind == kind)
2367 .unwrap_or_else(|| panic!("expected {name} in {kind:?}"));
2368 assert!(
2369 matches!(dep.source, cabin_core::DependencySource::Version(_)),
2370 "workspace inheritance should rewrite {name} into a Version source"
2371 );
2372 }
2373 }
2374
2375 #[test]
2376 fn workspace_dependency_kind_does_not_cross_tables() {
2377 let dir = TempDir::new().unwrap();
2381 dir.child("cabin.toml")
2382 .write_str(
2383 r#"[workspace]
2384members = ["packages/app"]
2385
2386[workspace.dependencies]
2387fmt = ">=10"
2388"#,
2389 )
2390 .unwrap();
2391 dir.child("packages/app/cabin.toml")
2392 .write_str(
2393 r#"[package]
2394name = "app"
2395version = "0.1.0"
2396
2397[dev-dependencies]
2398fmt = { workspace = true }
2399"#,
2400 )
2401 .unwrap();
2402 let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
2403 match err {
2404 WorkspaceError::UnresolvedWorkspaceDependency {
2405 dep_name,
2406 parent,
2407 kind,
2408 } => {
2409 assert_eq!(dep_name, "fmt");
2410 assert_eq!(parent, "app");
2411 assert_eq!(kind, DependencyKind::Dev);
2412 }
2413 other => panic!("expected UnresolvedWorkspaceDependency for dev, got {other:?}"),
2414 }
2415 }
2416
2417 #[test]
2418 fn dev_path_dependency_is_not_loaded_into_graph() {
2419 let dir = TempDir::new().unwrap();
2424 dir.child("cabin.toml")
2425 .write_str(
2426 r#"[package]
2427name = "app"
2428version = "0.1.0"
2429
2430[dev-dependencies]
2431harness = { path = "../harness-that-does-not-exist" }
2432"#,
2433 )
2434 .unwrap();
2435 let graph = load_workspace(dir.path().join("cabin.toml"))
2436 .expect("dev path-dep should not be traversed by ordinary load");
2437 assert_eq!(graph.packages.len(), 1);
2439 let app = &graph.packages[0];
2441 assert_eq!(app.package.dependencies.len(), 1);
2442 assert_eq!(app.package.dependencies[0].kind, DependencyKind::Dev);
2443 }
2444
2445 #[test]
2446 fn unresolved_workspace_dependency_errors() {
2447 let dir = TempDir::new().unwrap();
2448 dir.child("cabin.toml")
2449 .write_str(
2450 r#"[workspace]
2451members = ["packages/app"]
2452"#,
2453 )
2454 .unwrap();
2455 dir.child("packages/app/cabin.toml")
2456 .write_str(
2457 r#"[package]
2458name = "app"
2459version = "0.1.0"
2460
2461[dependencies]
2462fmt = { workspace = true }
2463"#,
2464 )
2465 .unwrap();
2466 let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
2467 match err {
2468 WorkspaceError::UnresolvedWorkspaceDependency {
2469 dep_name,
2470 parent,
2471 kind,
2472 } => {
2473 assert_eq!(dep_name, "fmt");
2474 assert_eq!(parent, "app");
2475 assert_eq!(kind, DependencyKind::Normal);
2476 }
2477 other => panic!("expected UnresolvedWorkspaceDependency, got {other:?}"),
2478 }
2479 }
2480
2481 #[test]
2482 fn nested_workspace_rejected() {
2483 let dir = TempDir::new().unwrap();
2484 dir.child("cabin.toml")
2485 .write_str(
2486 r#"[workspace]
2487members = ["nested"]
2488"#,
2489 )
2490 .unwrap();
2491 dir.child("nested/cabin.toml")
2492 .write_str(
2493 r"[workspace]
2494members = []
2495",
2496 )
2497 .unwrap();
2498 let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
2499 match err {
2500 WorkspaceError::NestedWorkspace { path } => {
2501 assert!(path.to_string_lossy().contains("nested"));
2502 }
2503 other => panic!("expected NestedWorkspace, got {other:?}"),
2504 }
2505 }
2506
2507 #[test]
2508 fn member_expansion_is_deterministic() {
2509 let dir = TempDir::new().unwrap();
2510 dir.child("cabin.toml")
2511 .write_str(
2512 r#"[workspace]
2513members = ["packages/*"]
2514"#,
2515 )
2516 .unwrap();
2517 for name in ["zeta", "alpha", "mu", "kappa"] {
2518 dir.child(format!("packages/{name}/cabin.toml"))
2519 .write_str(&format!(
2520 "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"
2521 ))
2522 .unwrap();
2523 }
2524 let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
2525 let names: Vec<&str> = graph
2526 .primary_packages
2527 .iter()
2528 .map(|i| graph.packages[*i].package.name.as_str())
2529 .collect();
2530 assert_eq!(names, vec!["alpha", "kappa", "mu", "zeta"]);
2531 }
2532
2533 fn workspace_with_outside_member(pattern: &str) -> TempDir {
2539 let dir = TempDir::new().unwrap();
2540 dir.child("cabin.toml")
2541 .write_str(&format!("[workspace]\nmembers = [\"{pattern}\"]\n"))
2542 .unwrap();
2543 dir
2544 }
2545
2546 #[test]
2547 fn member_pattern_with_absolute_path_rejected() {
2548 let dir = workspace_with_outside_member("/tmp/outside");
2553 let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
2554 match err {
2555 WorkspaceError::WorkspacePatternEscapesRoot { field, pattern } => {
2556 assert_eq!(field, "workspace.members");
2557 assert_eq!(pattern, "/tmp/outside");
2558 }
2559 other => panic!("expected WorkspacePatternEscapesRoot, got {other:?}"),
2560 }
2561 }
2562
2563 #[test]
2564 fn member_pattern_with_parent_dir_rejected() {
2565 let dir = TempDir::new().unwrap();
2568 let workspace_dir = dir.child("ws");
2569 let outside_dir = dir.child("outside");
2570 workspace_dir
2571 .child("cabin.toml")
2572 .write_str(
2573 r#"[workspace]
2574members = ["../outside"]
2575"#,
2576 )
2577 .unwrap();
2578 outside_dir
2579 .child("cabin.toml")
2580 .write_str("[package]\nname = \"sneaky\"\nversion = \"0.1.0\"\n")
2581 .unwrap();
2582 let err = load_workspace(workspace_dir.path().join("cabin.toml")).unwrap_err();
2583 match err {
2584 WorkspaceError::WorkspacePatternEscapesRoot { field, pattern } => {
2585 assert_eq!(field, "workspace.members");
2586 assert_eq!(pattern, "../outside");
2587 }
2588 other => panic!("expected WorkspacePatternEscapesRoot, got {other:?}"),
2589 }
2590 }
2591
2592 #[test]
2593 fn exclude_pattern_with_parent_dir_rejected() {
2594 let dir = TempDir::new().unwrap();
2595 dir.child("cabin.toml")
2596 .write_str(
2597 r#"[workspace]
2598members = ["packages/keep"]
2599exclude = ["../outside"]
2600"#,
2601 )
2602 .unwrap();
2603 dir.child("packages/keep/cabin.toml")
2604 .write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
2605 .unwrap();
2606 let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
2607 match err {
2608 WorkspaceError::WorkspacePatternEscapesRoot { field, pattern } => {
2609 assert_eq!(field, "workspace.exclude");
2610 assert_eq!(pattern, "../outside");
2611 }
2612 other => panic!("expected WorkspacePatternEscapesRoot, got {other:?}"),
2613 }
2614 }
2615
2616 #[test]
2617 fn default_member_with_parent_dir_rejected() {
2618 let dir = TempDir::new().unwrap();
2619 dir.child("cabin.toml")
2620 .write_str(
2621 r#"[workspace]
2622members = ["packages/keep"]
2623default-members = ["../outside"]
2624"#,
2625 )
2626 .unwrap();
2627 dir.child("packages/keep/cabin.toml")
2628 .write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
2629 .unwrap();
2630 let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
2631 match err {
2632 WorkspaceError::WorkspacePatternEscapesRoot { field, pattern } => {
2633 assert_eq!(field, "workspace.default-members");
2634 assert_eq!(pattern, "../outside");
2635 }
2636 other => panic!("expected WorkspacePatternEscapesRoot, got {other:?}"),
2637 }
2638 }
2639
2640 #[test]
2645 fn for_selection_skips_versioned_deps_outside_strict_set() {
2646 let dir = TempDir::new().unwrap();
2650 dir.child("cabin.toml")
2651 .write_str(
2652 r#"[workspace]
2653members = ["packages/*"]
2654"#,
2655 )
2656 .unwrap();
2657 dir.child("packages/app/cabin.toml")
2658 .write_str(
2659 r#"[package]
2660name = "app"
2661version = "0.1.0"
2662
2663[dependencies]
2664fmt = ">=10 <11"
2665"#,
2666 )
2667 .unwrap();
2668 dir.child("packages/b/cabin.toml")
2669 .write_str(
2670 r#"[package]
2671name = "b"
2672version = "0.1.0"
2673
2674[dependencies]
2675spdlog = "^1"
2676"#,
2677 )
2678 .unwrap();
2679 dir.child("registry/fmt/cabin.toml")
2681 .write_str("[package]\nname = \"fmt\"\nversion = \"10.2.1\"\n")
2682 .unwrap();
2683 let registry = vec![RegistryPackageSource {
2684 name: PackageName::new("fmt").unwrap(),
2685 version: ver("10.2.1"),
2686 manifest_path: dir.path().join("registry/fmt/cabin.toml"),
2687 }];
2688 let mut strict: BTreeSet<String> = BTreeSet::new();
2689 strict.insert("app".into());
2690 let graph = load_workspace_with_options(
2691 dir.path().join("cabin.toml"),
2692 &WorkspaceLoadOptions {
2693 registry: ®istry,
2694 patches: &[],
2695 ports: &[],
2696 registry_policy: RegistryPolicy::StrictFor(&strict),
2697 include_dev_for: &BTreeSet::new(),
2698 port_policy: PortPolicy::Strict,
2699 },
2700 )
2701 .expect("selection-aware load should not require spdlog");
2702 let names: BTreeSet<&str> = graph
2704 .packages
2705 .iter()
2706 .map(|p| p.package.name.as_str())
2707 .collect();
2708 assert!(names.contains("app"));
2709 assert!(names.contains("b"));
2710 assert!(names.contains("fmt"));
2711 assert!(!names.contains("spdlog"));
2712 }
2713
2714 #[test]
2715 fn for_selection_still_errors_when_strict_dep_missing() {
2716 let dir = TempDir::new().unwrap();
2720 dir.child("cabin.toml")
2721 .write_str(
2722 r#"[workspace]
2723members = ["packages/*"]
2724"#,
2725 )
2726 .unwrap();
2727 dir.child("packages/app/cabin.toml")
2728 .write_str(
2729 r#"[package]
2730name = "app"
2731version = "0.1.0"
2732
2733[dependencies]
2734fmt = ">=10 <11"
2735"#,
2736 )
2737 .unwrap();
2738 dir.child("registry/other/cabin.toml")
2743 .write_str("[package]\nname = \"other\"\nversion = \"1.0.0\"\n")
2744 .unwrap();
2745 let registry = vec![RegistryPackageSource {
2746 name: PackageName::new("other").unwrap(),
2747 version: ver("1.0.0"),
2748 manifest_path: dir.path().join("registry/other/cabin.toml"),
2749 }];
2750 let mut strict: BTreeSet<String> = BTreeSet::new();
2751 strict.insert("app".into());
2752 let err = load_workspace_with_options(
2753 dir.path().join("cabin.toml"),
2754 &WorkspaceLoadOptions {
2755 registry: ®istry,
2756 patches: &[],
2757 ports: &[],
2758 registry_policy: RegistryPolicy::StrictFor(&strict),
2759 include_dev_for: &BTreeSet::new(),
2760 port_policy: PortPolicy::Strict,
2761 },
2762 )
2763 .expect_err("expected UnresolvedRegistryDependency for selected closure dep");
2764 match err {
2765 WorkspaceError::UnresolvedRegistryDependency { dep_name, parent } => {
2766 assert_eq!(dep_name, "fmt");
2767 assert_eq!(parent, "app");
2768 }
2769 other => panic!("expected UnresolvedRegistryDependency, got {other:?}"),
2770 }
2771 }
2772
2773 #[test]
2778 fn resolves_port_dep_via_supplied_source() {
2779 let tmp = TempDir::new().unwrap();
2780
2781 let port_dir = tmp.child("ports/zlib/1.3.1");
2785 port_dir.create_dir_all().unwrap();
2786
2787 let prepared = tmp.child("cache/sources/sha256/abc");
2791 prepared
2792 .child("cabin.toml")
2793 .write_str(
2794 "[package]\nname = \"zlib\"\nversion = \"1.3.1\"\n\n[target.zlib]\ntype = \"library\"\nsources = [\"zlib.c\"]\n",
2795 )
2796 .unwrap();
2797 prepared
2798 .child("zlib.c")
2799 .write_str("int zlib_dummy(void){return 0;}\n")
2800 .unwrap();
2801
2802 let consumer = tmp.child("consumer");
2805 consumer
2806 .child("cabin.toml")
2807 .write_str(
2808 r#"
2809[package]
2810name = "consumer"
2811version = "0.1.0"
2812
2813[dependencies]
2814zlib = { port-path = "../ports/zlib/1.3.1" }
2815
2816[target.consumer]
2817type = "executable"
2818sources = ["src/main.c"]
2819deps = ["zlib"]
2820"#,
2821 )
2822 .unwrap();
2823 consumer
2824 .child("src/main.c")
2825 .write_str("int main(void){return 0;}\n")
2826 .unwrap();
2827
2828 let port_sources = vec![PortPackageSource {
2829 name: PackageName::new("zlib").unwrap(),
2830 version: semver::Version::new(1, 3, 1),
2831 manifest_path: prepared.path().join("cabin.toml"),
2832 origin: cabin_port::PortOrigin::PortDir(port_dir.to_path_buf()),
2833 }];
2834 let graph = load_workspace_with_options(
2835 consumer.path().join("cabin.toml"),
2836 &WorkspaceLoadOptions {
2837 registry: &[],
2838 patches: &[],
2839 ports: &port_sources,
2840 registry_policy: RegistryPolicy::Strict,
2841 include_dev_for: &BTreeSet::new(),
2842 port_policy: PortPolicy::Strict,
2843 },
2844 )
2845 .unwrap();
2846 assert_eq!(graph.packages.len(), 2);
2848 let zlib = graph
2849 .packages
2850 .iter()
2851 .find(|p| p.package.name.as_str() == "zlib")
2852 .unwrap();
2853 assert_eq!(
2854 zlib.manifest_dir,
2855 std::fs::canonicalize(prepared.path()).unwrap()
2856 );
2857 assert_eq!(zlib.kind, PackageKind::Local);
2860 }
2861
2862 #[test]
2863 fn resolves_builtin_port_dep_by_name() {
2864 let tmp = TempDir::new().unwrap();
2865
2866 let prepared = tmp.child("cache/sources/sha256/abc");
2870 prepared
2871 .child("cabin.toml")
2872 .write_str(
2873 "[package]\nname = \"zlib\"\nversion = \"1.3.1\"\n\n[target.zlib]\ntype = \"library\"\nsources = [\"zlib.c\"]\n",
2874 )
2875 .unwrap();
2876 prepared
2877 .child("zlib.c")
2878 .write_str("int zlib_dummy(void){return 0;}\n")
2879 .unwrap();
2880
2881 let consumer = tmp.child("consumer");
2882 consumer
2883 .child("cabin.toml")
2884 .write_str(
2885 r#"
2886[package]
2887name = "consumer"
2888version = "0.1.0"
2889
2890[dependencies]
2891zlib = { port = true, version = "^1.3" }
2892
2893[target.consumer]
2894type = "executable"
2895sources = ["src/main.c"]
2896deps = ["zlib"]
2897"#,
2898 )
2899 .unwrap();
2900 consumer
2901 .child("src/main.c")
2902 .write_str("int main(void){return 0;}\n")
2903 .unwrap();
2904
2905 let port_sources = vec![PortPackageSource {
2906 name: PackageName::new("zlib").unwrap(),
2907 version: semver::Version::new(1, 3, 1),
2908 manifest_path: prepared.path().join("cabin.toml"),
2909 origin: cabin_port::PortOrigin::Builtin("zlib"),
2910 }];
2911 let graph = load_workspace_with_options(
2912 consumer.path().join("cabin.toml"),
2913 &WorkspaceLoadOptions {
2914 registry: &[],
2915 patches: &[],
2916 ports: &port_sources,
2917 registry_policy: RegistryPolicy::Strict,
2918 include_dev_for: &BTreeSet::new(),
2919 port_policy: PortPolicy::Strict,
2920 },
2921 )
2922 .unwrap();
2923 assert_eq!(graph.packages.len(), 2);
2924 let zlib = graph
2925 .packages
2926 .iter()
2927 .find(|p| p.package.name.as_str() == "zlib")
2928 .unwrap();
2929 assert_eq!(zlib.kind, PackageKind::Local);
2930 }
2931
2932 #[test]
2933 fn rejects_port_dep_without_prepared_source() {
2934 let tmp = TempDir::new().unwrap();
2935 let port_dir = tmp.child("ports/zlib/1.3.1");
2936 port_dir.create_dir_all().unwrap();
2937
2938 let consumer = tmp.child("consumer");
2939 consumer
2940 .child("cabin.toml")
2941 .write_str(
2942 r#"
2943[package]
2944name = "consumer"
2945version = "0.1.0"
2946
2947[dependencies]
2948zlib = { port-path = "../ports/zlib/1.3.1" }
2949"#,
2950 )
2951 .unwrap();
2952
2953 let err = load_workspace_with_options(
2954 consumer.path().join("cabin.toml"),
2955 &WorkspaceLoadOptions {
2956 registry: &[],
2957 patches: &[],
2958 ports: &[],
2959 registry_policy: RegistryPolicy::Strict,
2960 include_dev_for: &BTreeSet::new(),
2961 port_policy: PortPolicy::Strict,
2962 },
2963 )
2964 .unwrap_err();
2965 assert!(
2966 matches!(err, WorkspaceError::PortDependencyNotPrepared { .. }),
2967 "{err:?}"
2968 );
2969 }
2970
2971 #[test]
2972 fn rejects_port_dep_with_missing_port_directory() {
2973 let tmp = TempDir::new().unwrap();
2974
2975 let consumer = tmp.child("consumer");
2976 consumer
2977 .child("cabin.toml")
2978 .write_str(
2979 r#"
2980[package]
2981name = "consumer"
2982version = "0.1.0"
2983
2984[dependencies]
2985zlib = { port-path = "../nonexistent/zlib" }
2986"#,
2987 )
2988 .unwrap();
2989
2990 let err = load_workspace_with_options(
2991 consumer.path().join("cabin.toml"),
2992 &WorkspaceLoadOptions {
2993 registry: &[],
2994 patches: &[],
2995 ports: &[],
2996 registry_policy: RegistryPolicy::Strict,
2997 include_dev_for: &BTreeSet::new(),
2998 port_policy: PortPolicy::Strict,
2999 },
3000 )
3001 .unwrap_err();
3002 assert!(
3003 matches!(err, WorkspaceError::PortDirectoryMissing { .. }),
3004 "{err:?}"
3005 );
3006 }
3007}