use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
use cabin_core::{DependencyKind, DependencySource, PackageName, PortDepSource};
use cabin_manifest::ParsedManifest;
use crate::error::WorkspaceError;
use crate::graph::{DependencyEdge, PackageGraph, PackageKind, WorkspacePackage};
#[derive(Debug, Clone)]
pub struct RegistryPackageSource {
pub name: PackageName,
pub version: semver::Version,
pub manifest_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct PatchedPackageSource {
pub name: PackageName,
pub version: semver::Version,
pub manifest_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct PortPackageSource {
pub name: PackageName,
pub version: semver::Version,
pub manifest_path: PathBuf,
pub origin: cabin_port::PortOrigin,
}
pub fn load_workspace(manifest_path: impl AsRef<Path>) -> Result<PackageGraph, WorkspaceError> {
load_workspace_inner(
manifest_path,
&[],
&[],
&[],
&RegistryEnforcement::strict(),
&BTreeSet::new(),
&PortMode::Strict,
)
}
pub fn load_workspace_skip_ports(
manifest_path: impl AsRef<Path>,
) -> Result<PackageGraph, WorkspaceError> {
load_workspace_inner(
manifest_path,
&[],
&[],
&[],
&RegistryEnforcement::strict(),
&BTreeSet::new(),
&PortMode::SkipAll,
)
}
#[derive(Debug, Clone)]
pub struct WorkspaceLoadOptions<'a> {
pub registry: &'a [RegistryPackageSource],
pub patches: &'a [PatchedPackageSource],
pub ports: &'a [PortPackageSource],
pub registry_policy: RegistryPolicy<'a>,
pub include_dev_for: &'a BTreeSet<String>,
pub port_policy: PortPolicy<'a>,
}
#[derive(Debug, Clone, Default)]
pub enum PortPolicy<'a> {
#[default]
Strict,
TolerateExcept(&'a BTreeSet<String>),
}
#[derive(Debug, Clone, Default)]
pub enum RegistryPolicy<'a> {
#[default]
Strict,
StrictFor(&'a BTreeSet<String>),
}
pub fn load_workspace_with_options(
manifest_path: impl AsRef<Path>,
options: &WorkspaceLoadOptions<'_>,
) -> Result<PackageGraph, WorkspaceError> {
let policy = match &options.registry_policy {
RegistryPolicy::Strict => RegistryEnforcement::strict(),
RegistryPolicy::StrictFor(set) => RegistryEnforcement::scoped((*set).clone()),
};
let port_mode = match &options.port_policy {
PortPolicy::Strict => PortMode::Strict,
PortPolicy::TolerateExcept(strict) => PortMode::TolerateExcept((*strict).clone()),
};
load_workspace_inner(
manifest_path,
options.registry,
options.patches,
options.ports,
&policy,
options.include_dev_for,
&port_mode,
)
}
#[derive(Debug, Clone)]
struct RegistryEnforcement {
strict_packages: Option<BTreeSet<String>>,
}
impl RegistryEnforcement {
fn strict() -> Self {
Self {
strict_packages: None,
}
}
fn scoped(strict_packages: BTreeSet<String>) -> Self {
Self {
strict_packages: Some(strict_packages),
}
}
fn requires_registry_for(&self, parent_name: &str) -> bool {
match &self.strict_packages {
None => true,
Some(set) => set.contains(parent_name),
}
}
}
#[derive(Debug, Clone)]
enum PortMode {
Strict,
SkipAll,
TolerateExcept(BTreeSet<String>),
}
fn load_workspace_inner(
manifest_path: impl AsRef<Path>,
registry: &[RegistryPackageSource],
patches: &[PatchedPackageSource],
ports: &[PortPackageSource],
policy: &RegistryEnforcement,
include_dev_for: &BTreeSet<String>,
port_mode: &PortMode,
) -> Result<PackageGraph, WorkspaceError> {
let skip_port_edges = matches!(port_mode, PortMode::SkipAll);
let tolerate_strict_set: Option<&BTreeSet<String>> = match port_mode {
PortMode::TolerateExcept(set) => Some(set),
_ => None,
};
let manifest_path = canonicalize(manifest_path.as_ref())?;
let root_dir = manifest_path
.parent()
.ok_or_else(|| WorkspaceError::Io {
path: manifest_path.clone(),
source: std::io::Error::other("manifest path has no parent directory"),
})?
.to_path_buf();
let root_manifest = parse_manifest(&manifest_path)?;
if root_manifest.package.is_none() && root_manifest.workspace.is_none() {
return Err(WorkspaceError::EmptyManifest {
path: manifest_path,
});
}
let host_platform = cabin_core::TargetPlatform::current();
let is_workspace_root = root_manifest.workspace.is_some();
let mut loader = Loader {
packages: Vec::new(),
manifest_index: HashMap::new(),
};
let mut primary_manifest_paths: Vec<PathBuf> = Vec::new();
if root_manifest.package.is_some() {
primary_manifest_paths.push(manifest_path.clone());
}
let mut workspace_default_members: Vec<String> = Vec::new();
let mut workspace_deps: BTreeMap<DependencyKind, BTreeMap<String, DependencySource>> =
BTreeMap::new();
let mut excluded_member_paths: Vec<PathBuf> = Vec::new();
if let Some(workspace) = &root_manifest.workspace {
let WorkspaceMembers { included, excluded } =
expand_workspace_members(&root_dir, &workspace.members, &workspace.exclude)?;
for canonical in included {
let parsed = parse_manifest(&canonical)?;
if parsed.workspace.is_some() {
return Err(WorkspaceError::NestedWorkspace { path: canonical });
}
primary_manifest_paths.push(canonical);
}
excluded_member_paths = excluded;
workspace_default_members.clone_from(&workspace.default_members);
for (kind, table) in [
(DependencyKind::Normal, &workspace.dependencies),
(DependencyKind::Dev, &workspace.dev_dependencies),
] {
if table.is_empty() {
continue;
}
let entry = workspace_deps.entry(kind).or_default();
for (name, req) in table {
entry.insert(name.clone(), parse_workspace_dep_source(name, req)?);
}
}
}
let mut port_by_canonical_dir: HashMap<PathBuf, PathBuf> = HashMap::new();
let mut port_by_name: HashMap<String, PathBuf> = HashMap::new();
let mut port_canonical_paths: HashSet<PathBuf> = HashSet::new();
for entry in ports {
match &entry.origin {
cabin_port::PortOrigin::PortDir(port_dir) => {
let port_dir_canonical = canonicalize(port_dir)?;
if let Some(previous) =
port_by_canonical_dir.insert(port_dir_canonical, entry.manifest_path.clone())
{
return Err(WorkspaceError::DuplicatePackageName {
name: entry.name.as_str().to_owned(),
first: previous,
second: entry.manifest_path.clone(),
});
}
}
cabin_port::PortOrigin::Builtin(name) => {
if let Some(previous) =
port_by_name.insert((*name).to_owned(), entry.manifest_path.clone())
{
return Err(WorkspaceError::DuplicatePackageName {
name: entry.name.as_str().to_owned(),
first: previous,
second: entry.manifest_path.clone(),
});
}
}
}
port_canonical_paths.insert(canonicalize(&entry.manifest_path)?);
}
let mut registry_by_name: HashMap<&str, PathBuf> = HashMap::new();
let mut registry_canonical_names: HashMap<PathBuf, &PackageName> = HashMap::new();
let mut registry_canonical_versions: HashMap<PathBuf, &semver::Version> = HashMap::new();
let mut registry_canonical_paths: HashSet<PathBuf> = HashSet::new();
let mut patch_canonical_paths: HashSet<PathBuf> = HashSet::new();
for entry in registry {
let canonical = canonicalize(&entry.manifest_path)?;
registry_by_name.insert(entry.name.as_str(), canonical.clone());
registry_canonical_names.insert(canonical.clone(), &entry.name);
registry_canonical_versions.insert(canonical.clone(), &entry.version);
registry_canonical_paths.insert(canonical);
}
for entry in patches {
let canonical = canonicalize(&entry.manifest_path)?;
if registry_canonical_paths.contains(&canonical) {
return Err(WorkspaceError::PatchConflictsWithRegistry {
package: entry.name.as_str().to_owned(),
path: canonical,
});
}
if let Some(existing) = registry_by_name.insert(entry.name.as_str(), canonical.clone()) {
return Err(WorkspaceError::DuplicatePackageName {
name: entry.name.as_str().to_owned(),
first: existing,
second: canonical,
});
}
registry_canonical_names.insert(canonical.clone(), &entry.name);
registry_canonical_versions.insert(canonical.clone(), &entry.version);
registry_canonical_paths.insert(canonical.clone());
patch_canonical_paths.insert(canonical);
}
let mut to_load: Vec<PathBuf> = primary_manifest_paths.clone();
for entry in registry {
let canonical = canonicalize(&entry.manifest_path)?;
to_load.push(canonical);
}
for entry in patches {
let canonical = canonicalize(&entry.manifest_path)?;
to_load.push(canonical);
}
for entry in ports {
let canonical = canonicalize(&entry.manifest_path)?;
to_load.push(canonical);
}
let root_manifest_path = manifest_path.clone();
while let Some(manifest_path) = to_load.pop() {
if loader.manifest_index.contains_key(&manifest_path) {
continue;
}
let parsed = parse_manifest(&manifest_path)?;
let package = parsed.package.ok_or_else(|| {
WorkspaceError::LocalDependencyIsWorkspace {
dep_name: project_alias_for(&loader, &manifest_path),
path: manifest_path.clone(),
}
})?;
if manifest_path != root_manifest_path && !package.profiles.is_empty() {
return Err(WorkspaceError::MemberDeclaresProfiles {
package: package.name.as_str().to_owned(),
path: manifest_path,
});
}
if manifest_path != root_manifest_path && !package.toolchain.is_empty() {
return Err(WorkspaceError::MemberDeclaresToolchain {
package: package.name.as_str().to_owned(),
path: manifest_path,
});
}
if manifest_path != root_manifest_path && !package.compiler_wrapper.is_empty() {
return Err(WorkspaceError::MemberDeclaresCompilerWrapper {
package: package.name.as_str().to_owned(),
path: manifest_path,
});
}
if manifest_path != root_manifest_path && !package.patches.is_empty() {
return Err(WorkspaceError::MemberDeclaresPatches {
package: package.name.as_str().to_owned(),
path: manifest_path,
});
}
if let Some(expected_version) = registry_canonical_versions.get(&manifest_path) {
let expected_name = registry_canonical_names.get(&manifest_path).copied();
let version_ok = &package.version == *expected_version;
let name_ok = expected_name.is_none_or(|n| n.as_str() == package.name.as_str());
if !name_ok {
return Err(WorkspaceError::RegistryPackageNameMismatch {
name: expected_name
.map(|n| n.as_str().to_owned())
.unwrap_or_default(),
actual_name: package.name.as_str().to_owned(),
path: manifest_path.clone(),
});
}
if !version_ok {
return Err(WorkspaceError::RegistryPackageMismatch {
name: expected_name
.map(|n| n.as_str().to_owned())
.unwrap_or_default(),
version: expected_version.to_string(),
actual_name: package.name.as_str().to_owned(),
actual_version: package.version.to_string(),
path: manifest_path.clone(),
});
}
}
let manifest_dir = manifest_path
.parent()
.expect("canonicalized manifest path has a parent")
.to_path_buf();
let resolved_project = resolve_workspace_dependencies(package.clone(), &workspace_deps)?;
let package = resolved_project;
let dev_active_for_this_pkg = include_dev_for.contains(package.name.as_str());
let parent_is_registry = registry_canonical_paths.contains(&manifest_path)
&& !patch_canonical_paths.contains(&manifest_path)
&& !port_canonical_paths.contains(&manifest_path);
let mut dep_paths: Vec<DepPath> = Vec::with_capacity(package.dependencies.len());
for dep in &package.dependencies {
let kind_active = dep.kind.is_resolved_by_default()
|| (dev_active_for_this_pkg && dep.kind == DependencyKind::Dev);
if !kind_active {
continue;
}
if !dep.matches_platform(&host_platform) {
continue;
}
if parent_is_registry {
match &dep.source {
DependencySource::Path(_) => {
return Err(WorkspaceError::RegistryPackageDeclaresPathDependency {
package: package.name.as_str().to_owned(),
dep_name: dep.name.as_str().to_owned(),
path: manifest_path.clone(),
});
}
DependencySource::Port(_) => {
return Err(WorkspaceError::RegistryPackageDeclaresPortDependency {
package: package.name.as_str().to_owned(),
dep_name: dep.name.as_str().to_owned(),
path: manifest_path.clone(),
});
}
DependencySource::Version(_) | DependencySource::Workspace => {}
}
}
let canonical = match &dep.source {
DependencySource::Path(rel) => {
let candidate = manifest_dir.join(rel).join("cabin.toml");
if !candidate.is_file() {
return Err(WorkspaceError::LocalDependencyManifestMissing {
dep_name: dep.name.as_str().to_owned(),
expected: candidate,
});
}
canonicalize(&candidate)?
}
DependencySource::Port(PortDepSource::Path(rel)) => {
if skip_port_edges {
continue;
}
let tolerate =
tolerate_strict_set.is_some_and(|set| !set.contains(package.name.as_str()));
let port_dir = manifest_dir.join(rel);
if !port_dir.is_dir() {
if tolerate {
continue;
}
return Err(WorkspaceError::PortDirectoryMissing {
dep_name: dep.name.as_str().to_owned(),
parent: package.name.as_str().to_owned(),
port_dir,
});
}
let port_dir_canonical = canonicalize(&port_dir)?;
if let Some(manifest_path) = port_by_canonical_dir.get(&port_dir_canonical) {
canonicalize(manifest_path)?
} else {
if tolerate {
continue;
}
return Err(WorkspaceError::PortDependencyNotPrepared {
dep_name: dep.name.as_str().to_owned(),
parent: package.name.as_str().to_owned(),
port_dir: port_dir_canonical,
});
}
}
DependencySource::Port(PortDepSource::Builtin { name, .. }) => {
if skip_port_edges {
continue;
}
let tolerate =
tolerate_strict_set.is_some_and(|set| !set.contains(package.name.as_str()));
if let Some(manifest_path) = port_by_name.get(name.as_str()) {
canonicalize(manifest_path)?
} else {
if tolerate {
continue;
}
return Err(WorkspaceError::BuiltinPortDependencyNotPrepared {
dep_name: dep.name.as_str().to_owned(),
parent: package.name.as_str().to_owned(),
});
}
}
DependencySource::Version(_) => {
if registry_by_name.is_empty() {
continue;
}
if let Some(path) = registry_by_name.get(dep.name.as_str()) {
path.clone()
} else {
if !policy.requires_registry_for(package.name.as_str()) {
continue;
}
return Err(WorkspaceError::UnresolvedRegistryDependency {
dep_name: dep.name.as_str().to_owned(),
parent: package.name.as_str().to_owned(),
});
}
}
DependencySource::Workspace => {
return Err(WorkspaceError::UnresolvedWorkspaceDependency {
dep_name: dep.name.as_str().to_owned(),
parent: package.name.as_str().to_owned(),
kind: dep.kind,
});
}
};
dep_paths.push(DepPath {
name: dep.name.as_str().to_owned(),
path: canonical,
kind: dep.kind,
condition: dep.condition.clone(),
});
}
for DepPath {
name: dep_name,
path: dep_manifest_path,
..
} in &dep_paths
{
let dep_parsed = parse_manifest(dep_manifest_path)?;
let actual = dep_parsed.package.as_ref().ok_or_else(|| {
WorkspaceError::LocalDependencyIsWorkspace {
dep_name: dep_name.clone(),
path: dep_manifest_path.clone(),
}
})?;
if actual.name.as_str() != dep_name {
return Err(WorkspaceError::DependencyNameMismatch {
dep_name: dep_name.clone(),
actual_name: actual.name.as_str().to_owned(),
path: dep_manifest_path.clone(),
});
}
}
let index = loader.packages.len();
loader.manifest_index.insert(manifest_path.clone(), index);
loader.packages.push(LoadedPackage {
package,
manifest_path: manifest_path.clone(),
manifest_dir,
dep_paths,
});
for dep in &loader.packages[index].dep_paths {
to_load.push(dep.path.clone());
}
}
{
let mut seen: HashMap<&str, &PathBuf> = HashMap::new();
for pkg in &loader.packages {
let name = pkg.package.name.as_str();
if let Some(prev) = seen.insert(name, &pkg.manifest_path) {
return Err(WorkspaceError::DuplicatePackageName {
name: name.to_owned(),
first: prev.clone(),
second: pkg.manifest_path.clone(),
});
}
}
}
let mut packages: Vec<WorkspacePackage> = Vec::with_capacity(loader.packages.len());
for pkg in &loader.packages {
let mut deps = Vec::with_capacity(pkg.dep_paths.len());
for dep in &pkg.dep_paths {
let idx = *loader
.manifest_index
.get(&dep.path)
.expect("dep manifest should have been loaded");
deps.push(DependencyEdge {
index: idx,
kind: dep.kind,
condition: dep.condition.clone(),
});
}
let kind = if patch_canonical_paths.contains(&pkg.manifest_path) {
PackageKind::Local
} else if port_canonical_paths.contains(&pkg.manifest_path) {
PackageKind::Local
} else if registry_canonical_paths.contains(&pkg.manifest_path) {
PackageKind::Registry
} else {
PackageKind::Local
};
packages.push(WorkspacePackage {
package: pkg.package.clone(),
manifest_path: pkg.manifest_path.clone(),
manifest_dir: pkg.manifest_dir.clone(),
deps,
kind,
});
}
let topo = topo_sort(&packages)?;
let new_position: HashMap<usize, usize> = topo
.iter()
.enumerate()
.map(|(new_idx, &old_idx)| (old_idx, new_idx))
.collect();
let mut sorted: Vec<WorkspacePackage> = topo
.iter()
.map(|&old_idx| packages[old_idx].clone())
.collect();
for pkg in &mut sorted {
for edge in &mut pkg.deps {
edge.index = new_position[&edge.index];
}
}
let primary_packages: Vec<usize> = primary_manifest_paths
.iter()
.map(|p| {
let old_idx = loader.manifest_index[p];
new_position[&old_idx]
})
.collect();
let root_package = if root_manifest.package.is_some() {
Some(new_position[&loader.manifest_index[&manifest_path]])
} else {
None
};
let mut default_members: Vec<usize> = Vec::new();
let mut seen_default: HashSet<usize> = HashSet::new();
for entry in &workspace_default_members {
validate_workspace_pattern("workspace.default-members", entry)?;
let dir = root_dir.join(entry);
let canonical_dir =
canonicalize(&dir).map_err(|_| WorkspaceError::DefaultMemberNotInMembers {
member: entry.clone(),
})?;
let manifest = canonical_dir.join("cabin.toml");
let idx = loader
.manifest_index
.get(&manifest)
.copied()
.ok_or_else(|| WorkspaceError::DefaultMemberNotInMembers {
member: entry.clone(),
})?;
let new_idx = new_position[&idx];
if !primary_packages.contains(&new_idx) {
return Err(WorkspaceError::DefaultMemberNotInMembers {
member: entry.clone(),
});
}
if seen_default.insert(new_idx) {
default_members.push(new_idx);
}
}
Ok(PackageGraph {
root_manifest_path: manifest_path,
root_dir,
is_workspace_root,
root_package,
root_settings: root_manifest.root_settings.into(),
primary_packages,
default_members,
excluded_members: excluded_member_paths,
packages: sorted,
})
}
struct Loader {
packages: Vec<LoadedPackage>,
manifest_index: HashMap<PathBuf, usize>,
}
struct LoadedPackage {
package: cabin_core::Package,
manifest_path: PathBuf,
manifest_dir: PathBuf,
dep_paths: Vec<DepPath>,
}
#[derive(Debug, Clone)]
struct DepPath {
name: String,
path: PathBuf,
kind: cabin_core::DependencyKind,
condition: Option<cabin_core::Condition>,
}
fn project_alias_for(loader: &Loader, manifest_path: &Path) -> String {
for pkg in &loader.packages {
for dep in &pkg.dep_paths {
if dep.path == manifest_path {
return dep.name.clone();
}
}
}
manifest_path.display().to_string()
}
fn parse_manifest(path: &Path) -> Result<ParsedManifest, WorkspaceError> {
cabin_manifest::load_manifest(path).map_err(|source| WorkspaceError::Manifest {
path: path.to_path_buf(),
source: Box::new(source),
})
}
fn canonicalize(path: &Path) -> Result<PathBuf, WorkspaceError> {
std::fs::canonicalize(path).map_err(|source| classify_manifest_io(path, source))
}
fn classify_manifest_io(path: &Path, source: std::io::Error) -> WorkspaceError {
match source.kind() {
std::io::ErrorKind::NotFound => WorkspaceError::ManifestNotFound {
path: path.to_path_buf(),
},
_ => WorkspaceError::ManifestUnreadable {
path: path.to_path_buf(),
source,
},
}
}
struct WorkspaceMembers {
included: Vec<PathBuf>,
excluded: Vec<PathBuf>,
}
fn expand_workspace_members(
workspace_dir: &Path,
members: &[String],
exclude: &[String],
) -> Result<WorkspaceMembers, WorkspaceError> {
let mut included: BTreeSet<PathBuf> = BTreeSet::new();
for pattern in members {
let dirs = expand_member_pattern(workspace_dir, pattern)?;
for dir in dirs {
let manifest = dir.join("cabin.toml");
if !manifest.is_file() {
return Err(WorkspaceError::WorkspaceMemberMissing {
pattern: pattern.clone(),
root: workspace_dir.to_path_buf(),
});
}
let canonical_dir = canonicalize(&dir)?;
included.insert(canonical_dir);
}
}
let mut excluded: BTreeSet<PathBuf> = BTreeSet::new();
let canonical_root = canonicalize(workspace_dir)?;
for pattern in exclude {
if pattern.is_empty() {
return Err(WorkspaceError::UnsupportedWorkspacePattern {
pattern: pattern.clone(),
});
}
let dirs = expand_exclude_pattern(workspace_dir, pattern)?;
let mut hit_any = false;
for dir in dirs {
if !dir.is_dir() {
continue;
}
let Ok(canonical_dir) = canonicalize(&dir) else {
continue;
};
if included.remove(&canonical_dir) {
hit_any = true;
if let Ok(rel) = canonical_dir.strip_prefix(&canonical_root) {
excluded.insert(rel.to_path_buf());
} else {
excluded.insert(canonical_dir.clone());
}
}
}
if !hit_any {
return Err(WorkspaceError::UnusedExcludePattern {
pattern: pattern.clone(),
root: workspace_dir.to_path_buf(),
});
}
}
let mut out: Vec<PathBuf> = Vec::with_capacity(included.len());
for dir in &included {
let manifest = dir.join("cabin.toml");
out.push(canonicalize(&manifest)?);
}
out.sort();
let excluded_paths: Vec<PathBuf> = excluded.into_iter().collect();
Ok(WorkspaceMembers {
included: out,
excluded: excluded_paths,
})
}
fn resolve_workspace_dependencies(
mut package: cabin_core::Package,
workspace_deps: &BTreeMap<DependencyKind, BTreeMap<String, DependencySource>>,
) -> Result<cabin_core::Package, WorkspaceError> {
for dep in &mut package.dependencies {
if !matches!(dep.source, DependencySource::Workspace) {
continue;
}
let table = workspace_deps.get(&dep.kind);
let resolved = table
.and_then(|t| t.get(dep.name.as_str()))
.ok_or_else(|| WorkspaceError::UnresolvedWorkspaceDependency {
dep_name: dep.name.as_str().to_owned(),
parent: package.name.as_str().to_owned(),
kind: dep.kind,
})?;
dep.source = resolved.clone();
}
Ok(package)
}
fn parse_workspace_dep_source(name: &str, req: &str) -> Result<DependencySource, WorkspaceError> {
let manifest = format!(
"[package]\nname = \"__workspace_root__\"\nversion = \"0.0.0\"\n[dependencies]\n{name} = \"{}\"\n",
req.replace('"', "\\\""),
);
let parsed = cabin_manifest::parse_manifest_str(&manifest).map_err(|source| {
WorkspaceError::InvalidWorkspaceDependency {
name: name.to_owned(),
source: Box::new(source),
}
})?;
let package = parsed
.package
.expect("inline manifest always has [package]");
let dep = package
.dependencies
.into_iter()
.next()
.expect("inline manifest declared exactly one dependency");
Ok(dep.source)
}
fn validate_workspace_pattern(field: &'static str, pattern: &str) -> Result<(), WorkspaceError> {
if pattern.is_empty() {
return Err(WorkspaceError::UnsupportedWorkspacePattern {
pattern: pattern.to_owned(),
});
}
let p = std::path::Path::new(pattern);
if p.is_absolute() {
return Err(WorkspaceError::WorkspacePatternEscapesRoot {
field,
pattern: pattern.to_owned(),
});
}
for component in p.components() {
if matches!(
component,
std::path::Component::ParentDir | std::path::Component::Prefix(_)
) {
return Err(WorkspaceError::WorkspacePatternEscapesRoot {
field,
pattern: pattern.to_owned(),
});
}
}
Ok(())
}
fn expand_member_pattern(
workspace_dir: &Path,
pattern: &str,
) -> Result<Vec<PathBuf>, WorkspaceError> {
validate_workspace_pattern("workspace.members", pattern)?;
if !pattern.contains('*') {
let dir = workspace_dir.join(pattern);
return Ok(vec![dir]);
}
let Some(trimmed) = pattern.strip_suffix("/*") else {
return Err(WorkspaceError::UnsupportedWorkspacePattern {
pattern: pattern.to_owned(),
});
};
if trimmed.contains('*') {
return Err(WorkspaceError::UnsupportedWorkspacePattern {
pattern: pattern.to_owned(),
});
}
let prefix_dir = if trimmed.is_empty() {
workspace_dir.to_path_buf()
} else {
workspace_dir.join(trimmed)
};
if !prefix_dir.is_dir() {
return Err(WorkspaceError::WorkspaceMemberMissing {
pattern: pattern.to_owned(),
root: workspace_dir.to_path_buf(),
});
}
let entries = std::fs::read_dir(&prefix_dir).map_err(|source| WorkspaceError::Io {
path: prefix_dir.clone(),
source,
})?;
let mut out = Vec::new();
for entry in entries {
let entry = entry.map_err(|source| WorkspaceError::Io {
path: prefix_dir.clone(),
source,
})?;
let path = entry.path();
if path.is_dir() && path.join("cabin.toml").is_file() {
out.push(path);
}
}
if out.is_empty() {
return Err(WorkspaceError::WorkspaceMemberMissing {
pattern: pattern.to_owned(),
root: workspace_dir.to_path_buf(),
});
}
out.sort();
Ok(out)
}
fn expand_exclude_pattern(
workspace_dir: &Path,
pattern: &str,
) -> Result<Vec<PathBuf>, WorkspaceError> {
validate_workspace_pattern("workspace.exclude", pattern)?;
if !pattern.contains('*') {
return Ok(vec![workspace_dir.join(pattern)]);
}
let Some(trimmed) = pattern.strip_suffix("/*") else {
return Err(WorkspaceError::UnsupportedWorkspacePattern {
pattern: pattern.to_owned(),
});
};
if trimmed.contains('*') {
return Err(WorkspaceError::UnsupportedWorkspacePattern {
pattern: pattern.to_owned(),
});
}
let prefix_dir = if trimmed.is_empty() {
workspace_dir.to_path_buf()
} else {
workspace_dir.join(trimmed)
};
if !prefix_dir.is_dir() {
return Ok(Vec::new());
}
let entries = std::fs::read_dir(&prefix_dir).map_err(|source| WorkspaceError::Io {
path: prefix_dir.clone(),
source,
})?;
let mut out = Vec::new();
for entry in entries {
let entry = entry.map_err(|source| WorkspaceError::Io {
path: prefix_dir.clone(),
source,
})?;
let path = entry.path();
if path.is_dir() {
out.push(path);
}
}
out.sort();
Ok(out)
}
fn topo_sort(packages: &[WorkspacePackage]) -> Result<Vec<usize>, WorkspaceError> {
#[derive(Clone, Copy)]
enum Color {
Visiting,
Done,
}
fn visit(
node: usize,
packages: &[WorkspacePackage],
state: &mut Vec<Option<Color>>,
path: &mut Vec<usize>,
order: &mut Vec<usize>,
) -> Result<(), WorkspaceError> {
match state[node] {
Some(Color::Done) => return Ok(()),
Some(Color::Visiting) => {
let start = path.iter().position(|n| *n == node).unwrap_or(0);
let mut cycle: Vec<String> = path[start..]
.iter()
.map(|i| packages[*i].package.name.as_str().to_owned())
.collect();
cycle.push(packages[node].package.name.as_str().to_owned());
return Err(WorkspaceError::PackageDependencyCycle(cycle));
}
None => {}
}
state[node] = Some(Color::Visiting);
path.push(node);
for edge in &packages[node].deps {
visit(edge.index, packages, state, path, order)?;
}
path.pop();
state[node] = Some(Color::Done);
order.push(node);
Ok(())
}
let mut state: Vec<Option<Color>> = vec![None; packages.len()];
let mut order = Vec::with_capacity(packages.len());
let mut path = Vec::new();
for i in 0..packages.len() {
if state[i].is_none() {
visit(i, packages, &mut state, &mut path, &mut order)?;
}
}
Ok(order)
}
#[cfg(test)]
mod tests {
use super::*;
use assert_fs::TempDir;
use assert_fs::prelude::*;
#[test]
fn loads_single_package_with_no_deps() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "solo"
version = "0.1.0"
"#,
)
.unwrap();
let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
assert!(!graph.is_workspace_root);
assert_eq!(graph.packages.len(), 1);
assert_eq!(graph.packages[0].package.name.as_str(), "solo");
assert_eq!(graph.packages[0].deps.len(), 0);
assert_eq!(graph.primary_packages, vec![0]);
assert_eq!(graph.root_package, Some(0));
}
#[test]
fn loads_package_with_local_path_dep() {
let dir = TempDir::new().unwrap();
dir.child("greet/cabin.toml")
.write_str(
r#"[package]
name = "greet"
version = "0.1.0"
"#,
)
.unwrap();
dir.child("app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
greet = { path = "../greet" }
"#,
)
.unwrap();
let graph = load_workspace(dir.path().join("app/cabin.toml")).unwrap();
assert_eq!(graph.packages.len(), 2);
assert_eq!(graph.packages[0].package.name.as_str(), "greet");
assert_eq!(graph.packages[1].package.name.as_str(), "app");
assert_eq!(
graph.packages[1]
.deps
.iter()
.map(|e| (e.index, e.kind))
.collect::<Vec<_>>(),
vec![(0, DependencyKind::Normal)]
);
assert_eq!(graph.primary_packages, vec![1]);
}
#[test]
fn loads_transitive_local_path_deps() {
let dir = TempDir::new().unwrap();
dir.child("c/cabin.toml")
.write_str(
r#"[package]
name = "c"
version = "0.1.0"
"#,
)
.unwrap();
dir.child("b/cabin.toml")
.write_str(
r#"[package]
name = "b"
version = "0.1.0"
[dependencies]
c = { path = "../c" }
"#,
)
.unwrap();
dir.child("a/cabin.toml")
.write_str(
r#"[package]
name = "a"
version = "0.1.0"
[dependencies]
b = { path = "../b" }
"#,
)
.unwrap();
let graph = load_workspace(dir.path().join("a/cabin.toml")).unwrap();
assert_eq!(graph.packages.len(), 3);
let names: Vec<&str> = graph
.packages
.iter()
.map(|p| p.package.name.as_str())
.collect();
let pos = |n: &str| names.iter().position(|x| *x == n).unwrap();
assert!(pos("c") < pos("b"));
assert!(pos("b") < pos("a"));
}
#[test]
fn detects_package_cycle() {
let dir = TempDir::new().unwrap();
dir.child("a/cabin.toml")
.write_str(
r#"[package]
name = "a"
version = "0.1.0"
[dependencies]
b = { path = "../b" }
"#,
)
.unwrap();
dir.child("b/cabin.toml")
.write_str(
r#"[package]
name = "b"
version = "0.1.0"
[dependencies]
a = { path = "../a" }
"#,
)
.unwrap();
let err = load_workspace(dir.path().join("a/cabin.toml")).unwrap_err();
match err {
WorkspaceError::PackageDependencyCycle(cycle) => {
assert_eq!(cycle.first(), cycle.last());
assert!(cycle.contains(&"a".to_owned()));
assert!(cycle.contains(&"b".to_owned()));
}
other => panic!("expected PackageDependencyCycle, got {other:?}"),
}
}
#[test]
fn loads_workspace_with_exact_member_path() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/greet"]
"#,
)
.unwrap();
dir.child("packages/greet/cabin.toml")
.write_str(
r#"[package]
name = "greet"
version = "0.1.0"
"#,
)
.unwrap();
let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
assert!(graph.is_workspace_root);
assert!(graph.root_package.is_none());
assert_eq!(graph.packages.len(), 1);
assert_eq!(graph.packages[0].package.name.as_str(), "greet");
}
#[test]
fn pure_workspace_root_policy_is_available_on_graph() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/greet"]
[profile.release]
opt-level = 0
[toolchain]
cxx = "clang++"
[profile.cache]
compiler-wrapper = "ccache"
[patch]
fmt = { path = "../fmt" }
"#,
)
.unwrap();
dir.child("packages/greet/cabin.toml")
.write_str(
r#"[package]
name = "greet"
version = "0.1.0"
"#,
)
.unwrap();
let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
assert!(graph.is_workspace_root);
assert!(graph.root_package.is_none());
let release = cabin_core::ProfileName::new("release").unwrap();
assert_eq!(
graph
.root_settings
.profiles
.get(&release)
.and_then(|p| p.opt_level),
Some(cabin_core::OptLevel::O0)
);
assert_eq!(
graph
.root_settings
.toolchain
.general
.get(cabin_core::ToolKind::CxxCompiler)
.map(cabin_core::ToolSpec::display)
.as_deref(),
Some("clang++")
);
assert_eq!(
graph.root_settings.compiler_wrapper.general,
Some(cabin_core::CompilerWrapperRequest::Use {
wrapper: cabin_core::CompilerWrapperKind::Ccache,
})
);
assert_eq!(graph.root_settings.patches.entries.len(), 1);
}
#[test]
fn loads_workspace_with_glob_member_pattern() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
dir.child("packages/a/cabin.toml")
.write_str(
r#"[package]
name = "a"
version = "0.1.0"
"#,
)
.unwrap();
dir.child("packages/b/cabin.toml")
.write_str(
r#"[package]
name = "b"
version = "0.1.0"
"#,
)
.unwrap();
let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
assert_eq!(graph.packages.len(), 2);
let names: Vec<&str> = graph
.packages
.iter()
.map(|p| p.package.name.as_str())
.collect();
assert!(names.contains(&"a"));
assert!(names.contains(&"b"));
}
#[test]
fn rejects_duplicate_package_names_in_workspace() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
dir.child("packages/a/cabin.toml")
.write_str(
r#"[package]
name = "shared"
version = "0.1.0"
"#,
)
.unwrap();
dir.child("packages/b/cabin.toml")
.write_str(
r#"[package]
name = "shared"
version = "0.2.0"
"#,
)
.unwrap();
let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
match err {
WorkspaceError::DuplicatePackageName { name, .. } => assert_eq!(name, "shared"),
other => panic!("expected DuplicatePackageName, got {other:?}"),
}
}
#[test]
fn missing_local_dependency_manifest_errors() {
let dir = TempDir::new().unwrap();
dir.child("app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
greet = { path = "../greet" }
"#,
)
.unwrap();
let err = load_workspace(dir.path().join("app/cabin.toml")).unwrap_err();
assert!(matches!(
err,
WorkspaceError::LocalDependencyManifestMissing { .. }
));
}
#[test]
fn dependency_name_mismatch_errors() {
let dir = TempDir::new().unwrap();
dir.child("greet/cabin.toml")
.write_str(
r#"[package]
name = "actually-hello"
version = "0.1.0"
"#,
)
.unwrap();
dir.child("app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
greet = { path = "../greet" }
"#,
)
.unwrap();
let err = load_workspace(dir.path().join("app/cabin.toml")).unwrap_err();
match err {
WorkspaceError::DependencyNameMismatch {
dep_name,
actual_name,
..
} => {
assert_eq!(dep_name, "greet");
assert_eq!(actual_name, "actually-hello");
}
other => panic!("expected DependencyNameMismatch, got {other:?}"),
}
}
#[test]
fn versioned_dependencies_are_preserved_but_not_traversed() {
let dir = TempDir::new().unwrap();
dir.child("app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
"#,
)
.unwrap();
let graph = load_workspace(dir.path().join("app/cabin.toml")).unwrap();
assert_eq!(graph.packages.len(), 1);
let app = &graph.packages[0];
assert!(app.deps.is_empty());
assert_eq!(app.package.dependencies.len(), 1);
assert_eq!(app.package.dependencies[0].name.as_str(), "fmt");
assert!(matches!(
&app.package.dependencies[0].source,
cabin_core::DependencySource::Version(_)
));
}
#[test]
fn unsupported_glob_pattern_errors() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*/foo"]
"#,
)
.unwrap();
dir.child("packages/a/foo/cabin.toml")
.write_str(
r#"[package]
name = "a"
version = "0.1.0"
"#,
)
.unwrap();
let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
assert!(matches!(
err,
WorkspaceError::UnsupportedWorkspacePattern { .. }
));
}
#[test]
fn missing_workspace_member_errors() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/missing"]
"#,
)
.unwrap();
let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
assert!(matches!(err, WorkspaceError::WorkspaceMemberMissing { .. }));
}
fn pkg(name: &str) -> PackageName {
PackageName::new(name).unwrap()
}
fn ver(s: &str) -> semver::Version {
semver::Version::parse(s).unwrap()
}
#[test]
fn loads_registry_package_via_versioned_dep() {
let dir = TempDir::new().unwrap();
dir.child("app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10.0.0 <11.0.0"
"#,
)
.unwrap();
dir.child("registry/fmt/cabin.toml")
.write_str(
r#"[package]
name = "fmt"
version = "10.2.1"
"#,
)
.unwrap();
let registry = vec![RegistryPackageSource {
name: pkg("fmt"),
version: ver("10.2.1"),
manifest_path: dir.path().join("registry/fmt/cabin.toml"),
}];
let graph = load_workspace_with_options(
dir.path().join("app/cabin.toml"),
&WorkspaceLoadOptions {
registry: ®istry,
patches: &[],
ports: &[],
registry_policy: RegistryPolicy::Strict,
include_dev_for: &BTreeSet::new(),
port_policy: PortPolicy::Strict,
},
)
.unwrap();
assert_eq!(graph.packages.len(), 2);
assert_eq!(graph.packages[0].package.name.as_str(), "fmt");
assert_eq!(graph.packages[0].kind, PackageKind::Registry);
assert_eq!(graph.packages[1].package.name.as_str(), "app");
assert_eq!(graph.packages[1].kind, PackageKind::Local);
assert_eq!(graph.primary_packages, vec![1]);
let edges: Vec<(usize, DependencyKind)> = graph.packages[1]
.deps
.iter()
.map(|e| (e.index, e.kind))
.collect();
assert_eq!(edges, vec![(0, DependencyKind::Normal)]);
}
#[test]
fn registry_package_declaring_path_dependency_is_rejected() {
let dir = TempDir::new().unwrap();
dir.child("app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
evil = ">=1.0.0 <2.0.0"
"#,
)
.unwrap();
dir.child("registry/evil/cabin.toml")
.write_str(
r#"[package]
name = "evil"
version = "1.0.0"
[dependencies]
inner = { path = "inner" }
"#,
)
.unwrap();
dir.child("registry/evil/inner/cabin.toml")
.write_str(
r#"[package]
name = "inner"
version = "1.0.0"
[profile]
cxxflags = ["-fplugin=evil.so"]
"#,
)
.unwrap();
let registry = vec![RegistryPackageSource {
name: pkg("evil"),
version: ver("1.0.0"),
manifest_path: dir.path().join("registry/evil/cabin.toml"),
}];
let err = load_workspace_with_options(
dir.path().join("app/cabin.toml"),
&WorkspaceLoadOptions {
registry: ®istry,
patches: &[],
ports: &[],
registry_policy: RegistryPolicy::Strict,
include_dev_for: &BTreeSet::new(),
port_policy: PortPolicy::Strict,
},
)
.unwrap_err();
assert!(
matches!(
err,
WorkspaceError::RegistryPackageDeclaresPathDependency { .. }
),
"expected RegistryPackageDeclaresPathDependency, got {err:?}"
);
}
#[test]
fn registry_package_declaring_port_dependency_is_rejected() {
let dir = TempDir::new().unwrap();
dir.child("app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
evil = ">=1.0.0 <2.0.0"
"#,
)
.unwrap();
dir.child("registry/evil/cabin.toml")
.write_str(
r#"[package]
name = "evil"
version = "1.0.0"
[dependencies]
inner = { port-path = "ports/inner" }
"#,
)
.unwrap();
let registry = vec![RegistryPackageSource {
name: pkg("evil"),
version: ver("1.0.0"),
manifest_path: dir.path().join("registry/evil/cabin.toml"),
}];
let err = load_workspace_with_options(
dir.path().join("app/cabin.toml"),
&WorkspaceLoadOptions {
registry: ®istry,
patches: &[],
ports: &[],
registry_policy: RegistryPolicy::Strict,
include_dev_for: &BTreeSet::new(),
port_policy: PortPolicy::Strict,
},
)
.unwrap_err();
assert!(
matches!(
err,
WorkspaceError::RegistryPackageDeclaresPortDependency { .. }
),
"expected RegistryPackageDeclaresPortDependency, got {err:?}"
);
}
#[test]
fn unresolved_registry_dep_errors() {
let dir = TempDir::new().unwrap();
dir.child("app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10"
spdlog = ">=1"
"#,
)
.unwrap();
dir.child("registry/fmt/cabin.toml")
.write_str(
r#"[package]
name = "fmt"
version = "10.2.1"
"#,
)
.unwrap();
let registry = vec![RegistryPackageSource {
name: pkg("fmt"),
version: ver("10.2.1"),
manifest_path: dir.path().join("registry/fmt/cabin.toml"),
}];
let err = load_workspace_with_options(
dir.path().join("app/cabin.toml"),
&WorkspaceLoadOptions {
registry: ®istry,
patches: &[],
ports: &[],
registry_policy: RegistryPolicy::Strict,
include_dev_for: &BTreeSet::new(),
port_policy: PortPolicy::Strict,
},
)
.unwrap_err();
match err {
WorkspaceError::UnresolvedRegistryDependency { dep_name, parent } => {
assert_eq!(dep_name, "spdlog");
assert_eq!(parent, "app");
}
other => panic!("expected UnresolvedRegistryDependency, got {other:?}"),
}
}
#[test]
fn registry_dep_chained_through_extracted_manifest() {
let dir = TempDir::new().unwrap();
dir.child("app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
spdlog = ">=1"
"#,
)
.unwrap();
dir.child("registry/spdlog/cabin.toml")
.write_str(
r#"[package]
name = "spdlog"
version = "1.13.0"
[dependencies]
fmt = ">=10"
"#,
)
.unwrap();
dir.child("registry/fmt/cabin.toml")
.write_str(
r#"[package]
name = "fmt"
version = "10.2.1"
"#,
)
.unwrap();
let registry = vec![
RegistryPackageSource {
name: pkg("fmt"),
version: ver("10.2.1"),
manifest_path: dir.path().join("registry/fmt/cabin.toml"),
},
RegistryPackageSource {
name: pkg("spdlog"),
version: ver("1.13.0"),
manifest_path: dir.path().join("registry/spdlog/cabin.toml"),
},
];
let graph = load_workspace_with_options(
dir.path().join("app/cabin.toml"),
&WorkspaceLoadOptions {
registry: ®istry,
patches: &[],
ports: &[],
registry_policy: RegistryPolicy::Strict,
include_dev_for: &BTreeSet::new(),
port_policy: PortPolicy::Strict,
},
)
.unwrap();
assert_eq!(graph.packages.len(), 3);
let names: Vec<&str> = graph
.packages
.iter()
.map(|p| p.package.name.as_str())
.collect();
let pos = |n: &str| names.iter().position(|x| *x == n).unwrap();
assert!(pos("fmt") < pos("spdlog"));
assert!(pos("spdlog") < pos("app"));
}
#[test]
fn registry_package_version_mismatch_errors() {
let dir = TempDir::new().unwrap();
dir.child("app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10"
"#,
)
.unwrap();
dir.child("registry/fmt/cabin.toml")
.write_str(
r#"[package]
name = "fmt"
version = "10.1.0"
"#,
)
.unwrap();
let registry = vec![RegistryPackageSource {
name: pkg("fmt"),
version: ver("10.2.1"),
manifest_path: dir.path().join("registry/fmt/cabin.toml"),
}];
let err = load_workspace_with_options(
dir.path().join("app/cabin.toml"),
&WorkspaceLoadOptions {
registry: ®istry,
patches: &[],
ports: &[],
registry_policy: RegistryPolicy::Strict,
include_dev_for: &BTreeSet::new(),
port_policy: PortPolicy::Strict,
},
)
.unwrap_err();
assert!(matches!(
err,
WorkspaceError::RegistryPackageMismatch { .. }
));
}
#[test]
fn exclude_drops_member_from_primary_set() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
exclude = ["packages/skipme"]
"#,
)
.unwrap();
dir.child("packages/keep/cabin.toml")
.write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
.unwrap();
dir.child("packages/skipme/cabin.toml")
.write_str("[package]\nname = \"skipme\"\nversion = \"0.1.0\"\n")
.unwrap();
let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
let names: Vec<&str> = graph
.primary_packages
.iter()
.map(|i| graph.packages[*i].package.name.as_str())
.collect();
assert_eq!(names, vec!["keep"]);
assert_eq!(graph.excluded_members.len(), 1);
assert!(
graph.excluded_members[0]
.to_string_lossy()
.ends_with("skipme")
);
}
#[test]
fn unused_exclude_pattern_errors() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/keep"]
exclude = ["packages/missing"]
"#,
)
.unwrap();
dir.child("packages/keep/cabin.toml")
.write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
.unwrap();
let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
match err {
WorkspaceError::UnusedExcludePattern { pattern, .. } => {
assert_eq!(pattern, "packages/missing");
}
other => panic!("expected UnusedExcludePattern, got {other:?}"),
}
}
#[test]
fn default_members_must_be_workspace_members() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/keep"]
default-members = ["packages/missing"]
"#,
)
.unwrap();
dir.child("packages/keep/cabin.toml")
.write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
.unwrap();
let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
match err {
WorkspaceError::DefaultMemberNotInMembers { member } => {
assert_eq!(member, "packages/missing");
}
other => panic!("expected DefaultMemberNotInMembers, got {other:?}"),
}
}
#[test]
fn default_members_resolved_to_indices() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
default-members = ["packages/a"]
"#,
)
.unwrap();
dir.child("packages/a/cabin.toml")
.write_str("[package]\nname = \"a\"\nversion = \"0.1.0\"\n")
.unwrap();
dir.child("packages/b/cabin.toml")
.write_str("[package]\nname = \"b\"\nversion = \"0.1.0\"\n")
.unwrap();
let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
assert_eq!(graph.default_members.len(), 1);
let name = graph.packages[graph.default_members[0]]
.package
.name
.as_str();
assert_eq!(name, "a");
}
#[test]
fn workspace_dependency_inheritance() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/app"]
[workspace.dependencies]
fmt = ">=10 <11"
"#,
)
.unwrap();
dir.child("packages/app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = { workspace = true }
"#,
)
.unwrap();
let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
let app = graph
.packages
.iter()
.find(|p| p.package.name.as_str() == "app")
.unwrap();
assert_eq!(app.package.dependencies.len(), 1);
match &app.package.dependencies[0].source {
cabin_core::DependencySource::Version(req) => {
assert!(req.to_string().contains(">=10"));
}
other => panic!("expected resolved Version, got {other:?}"),
}
}
#[test]
fn workspace_dependency_inheritance_per_kind() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/app"]
[workspace.dependencies]
fmt = ">=10"
[workspace.dev-dependencies]
gtest = "^1.14"
"#,
)
.unwrap();
dir.child("packages/app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = { workspace = true }
[dev-dependencies]
gtest = { workspace = true }
"#,
)
.unwrap();
let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
let app = graph
.packages
.iter()
.find(|p| p.package.name.as_str() == "app")
.unwrap();
for (name, kind) in [
("fmt", DependencyKind::Normal),
("gtest", DependencyKind::Dev),
] {
let dep = app
.package
.dependencies
.iter()
.find(|d| d.name.as_str() == name && d.kind == kind)
.unwrap_or_else(|| panic!("expected {name} in {kind:?}"));
assert!(
matches!(dep.source, cabin_core::DependencySource::Version(_)),
"workspace inheritance should rewrite {name} into a Version source"
);
}
}
#[test]
fn workspace_dependency_kind_does_not_cross_tables() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/app"]
[workspace.dependencies]
fmt = ">=10"
"#,
)
.unwrap();
dir.child("packages/app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dev-dependencies]
fmt = { workspace = true }
"#,
)
.unwrap();
let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
match err {
WorkspaceError::UnresolvedWorkspaceDependency {
dep_name,
parent,
kind,
} => {
assert_eq!(dep_name, "fmt");
assert_eq!(parent, "app");
assert_eq!(kind, DependencyKind::Dev);
}
other => panic!("expected UnresolvedWorkspaceDependency for dev, got {other:?}"),
}
}
#[test]
fn dev_path_dependency_is_not_loaded_into_graph() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dev-dependencies]
harness = { path = "../harness-that-does-not-exist" }
"#,
)
.unwrap();
let graph = load_workspace(dir.path().join("cabin.toml"))
.expect("dev path-dep should not be traversed by ordinary load");
assert_eq!(graph.packages.len(), 1);
let app = &graph.packages[0];
assert_eq!(app.package.dependencies.len(), 1);
assert_eq!(app.package.dependencies[0].kind, DependencyKind::Dev);
}
#[test]
fn unresolved_workspace_dependency_errors() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/app"]
"#,
)
.unwrap();
dir.child("packages/app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = { workspace = true }
"#,
)
.unwrap();
let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
match err {
WorkspaceError::UnresolvedWorkspaceDependency {
dep_name,
parent,
kind,
} => {
assert_eq!(dep_name, "fmt");
assert_eq!(parent, "app");
assert_eq!(kind, DependencyKind::Normal);
}
other => panic!("expected UnresolvedWorkspaceDependency, got {other:?}"),
}
}
#[test]
fn nested_workspace_rejected() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["nested"]
"#,
)
.unwrap();
dir.child("nested/cabin.toml")
.write_str(
r"[workspace]
members = []
",
)
.unwrap();
let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
match err {
WorkspaceError::NestedWorkspace { path } => {
assert!(path.to_string_lossy().contains("nested"));
}
other => panic!("expected NestedWorkspace, got {other:?}"),
}
}
#[test]
fn member_expansion_is_deterministic() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
for name in ["zeta", "alpha", "mu", "kappa"] {
dir.child(format!("packages/{name}/cabin.toml"))
.write_str(&format!(
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"
))
.unwrap();
}
let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
let names: Vec<&str> = graph
.primary_packages
.iter()
.map(|i| graph.packages[*i].package.name.as_str())
.collect();
assert_eq!(names, vec!["alpha", "kappa", "mu", "zeta"]);
}
fn workspace_with_outside_member(pattern: &str) -> TempDir {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(&format!("[workspace]\nmembers = [\"{pattern}\"]\n"))
.unwrap();
dir
}
#[test]
fn member_pattern_with_absolute_path_rejected() {
let dir = workspace_with_outside_member("/tmp/outside");
let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
match err {
WorkspaceError::WorkspacePatternEscapesRoot { field, pattern } => {
assert_eq!(field, "workspace.members");
assert_eq!(pattern, "/tmp/outside");
}
other => panic!("expected WorkspacePatternEscapesRoot, got {other:?}"),
}
}
#[test]
fn member_pattern_with_parent_dir_rejected() {
let dir = TempDir::new().unwrap();
let workspace_dir = dir.child("ws");
let outside_dir = dir.child("outside");
workspace_dir
.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["../outside"]
"#,
)
.unwrap();
outside_dir
.child("cabin.toml")
.write_str("[package]\nname = \"sneaky\"\nversion = \"0.1.0\"\n")
.unwrap();
let err = load_workspace(workspace_dir.path().join("cabin.toml")).unwrap_err();
match err {
WorkspaceError::WorkspacePatternEscapesRoot { field, pattern } => {
assert_eq!(field, "workspace.members");
assert_eq!(pattern, "../outside");
}
other => panic!("expected WorkspacePatternEscapesRoot, got {other:?}"),
}
}
#[test]
fn exclude_pattern_with_parent_dir_rejected() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/keep"]
exclude = ["../outside"]
"#,
)
.unwrap();
dir.child("packages/keep/cabin.toml")
.write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
.unwrap();
let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
match err {
WorkspaceError::WorkspacePatternEscapesRoot { field, pattern } => {
assert_eq!(field, "workspace.exclude");
assert_eq!(pattern, "../outside");
}
other => panic!("expected WorkspacePatternEscapesRoot, got {other:?}"),
}
}
#[test]
fn default_member_with_parent_dir_rejected() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/keep"]
default-members = ["../outside"]
"#,
)
.unwrap();
dir.child("packages/keep/cabin.toml")
.write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
.unwrap();
let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
match err {
WorkspaceError::WorkspacePatternEscapesRoot { field, pattern } => {
assert_eq!(field, "workspace.default-members");
assert_eq!(pattern, "../outside");
}
other => panic!("expected WorkspacePatternEscapesRoot, got {other:?}"),
}
}
#[test]
fn for_selection_skips_versioned_deps_outside_strict_set() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
dir.child("packages/app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10 <11"
"#,
)
.unwrap();
dir.child("packages/b/cabin.toml")
.write_str(
r#"[package]
name = "b"
version = "0.1.0"
[dependencies]
spdlog = "^1"
"#,
)
.unwrap();
dir.child("registry/fmt/cabin.toml")
.write_str("[package]\nname = \"fmt\"\nversion = \"10.2.1\"\n")
.unwrap();
let registry = vec![RegistryPackageSource {
name: PackageName::new("fmt").unwrap(),
version: ver("10.2.1"),
manifest_path: dir.path().join("registry/fmt/cabin.toml"),
}];
let mut strict: BTreeSet<String> = BTreeSet::new();
strict.insert("app".into());
let graph = load_workspace_with_options(
dir.path().join("cabin.toml"),
&WorkspaceLoadOptions {
registry: ®istry,
patches: &[],
ports: &[],
registry_policy: RegistryPolicy::StrictFor(&strict),
include_dev_for: &BTreeSet::new(),
port_policy: PortPolicy::Strict,
},
)
.expect("selection-aware load should not require spdlog");
let names: BTreeSet<&str> = graph
.packages
.iter()
.map(|p| p.package.name.as_str())
.collect();
assert!(names.contains("app"));
assert!(names.contains("b"));
assert!(names.contains("fmt"));
assert!(!names.contains("spdlog"));
}
#[test]
fn for_selection_still_errors_when_strict_dep_missing() {
let dir = TempDir::new().unwrap();
dir.child("cabin.toml")
.write_str(
r#"[workspace]
members = ["packages/*"]
"#,
)
.unwrap();
dir.child("packages/app/cabin.toml")
.write_str(
r#"[package]
name = "app"
version = "0.1.0"
[dependencies]
fmt = ">=10 <11"
"#,
)
.unwrap();
dir.child("registry/other/cabin.toml")
.write_str("[package]\nname = \"other\"\nversion = \"1.0.0\"\n")
.unwrap();
let registry = vec![RegistryPackageSource {
name: PackageName::new("other").unwrap(),
version: ver("1.0.0"),
manifest_path: dir.path().join("registry/other/cabin.toml"),
}];
let mut strict: BTreeSet<String> = BTreeSet::new();
strict.insert("app".into());
let err = load_workspace_with_options(
dir.path().join("cabin.toml"),
&WorkspaceLoadOptions {
registry: ®istry,
patches: &[],
ports: &[],
registry_policy: RegistryPolicy::StrictFor(&strict),
include_dev_for: &BTreeSet::new(),
port_policy: PortPolicy::Strict,
},
)
.expect_err("expected UnresolvedRegistryDependency for selected closure dep");
match err {
WorkspaceError::UnresolvedRegistryDependency { dep_name, parent } => {
assert_eq!(dep_name, "fmt");
assert_eq!(parent, "app");
}
other => panic!("expected UnresolvedRegistryDependency, got {other:?}"),
}
}
#[test]
fn resolves_port_dep_via_supplied_source() {
let tmp = TempDir::new().unwrap();
let port_dir = tmp.child("ports/zlib/1.3.1");
port_dir.create_dir_all().unwrap();
let prepared = tmp.child("cache/sources/sha256/abc");
prepared
.child("cabin.toml")
.write_str(
"[package]\nname = \"zlib\"\nversion = \"1.3.1\"\n\n[target.zlib]\ntype = \"library\"\nsources = [\"zlib.c\"]\n",
)
.unwrap();
prepared
.child("zlib.c")
.write_str("int zlib_dummy(void){return 0;}\n")
.unwrap();
let consumer = tmp.child("consumer");
consumer
.child("cabin.toml")
.write_str(
r#"
[package]
name = "consumer"
version = "0.1.0"
[dependencies]
zlib = { port-path = "../ports/zlib/1.3.1" }
[target.consumer]
type = "executable"
sources = ["src/main.c"]
deps = ["zlib"]
"#,
)
.unwrap();
consumer
.child("src/main.c")
.write_str("int main(void){return 0;}\n")
.unwrap();
let port_sources = vec![PortPackageSource {
name: PackageName::new("zlib").unwrap(),
version: semver::Version::new(1, 3, 1),
manifest_path: prepared.path().join("cabin.toml"),
origin: cabin_port::PortOrigin::PortDir(port_dir.to_path_buf()),
}];
let graph = load_workspace_with_options(
consumer.path().join("cabin.toml"),
&WorkspaceLoadOptions {
registry: &[],
patches: &[],
ports: &port_sources,
registry_policy: RegistryPolicy::Strict,
include_dev_for: &BTreeSet::new(),
port_policy: PortPolicy::Strict,
},
)
.unwrap();
assert_eq!(graph.packages.len(), 2);
let zlib = graph
.packages
.iter()
.find(|p| p.package.name.as_str() == "zlib")
.unwrap();
assert_eq!(
zlib.manifest_dir,
std::fs::canonicalize(prepared.path()).unwrap()
);
assert_eq!(zlib.kind, PackageKind::Local);
}
#[test]
fn resolves_builtin_port_dep_by_name() {
let tmp = TempDir::new().unwrap();
let prepared = tmp.child("cache/sources/sha256/abc");
prepared
.child("cabin.toml")
.write_str(
"[package]\nname = \"zlib\"\nversion = \"1.3.1\"\n\n[target.zlib]\ntype = \"library\"\nsources = [\"zlib.c\"]\n",
)
.unwrap();
prepared
.child("zlib.c")
.write_str("int zlib_dummy(void){return 0;}\n")
.unwrap();
let consumer = tmp.child("consumer");
consumer
.child("cabin.toml")
.write_str(
r#"
[package]
name = "consumer"
version = "0.1.0"
[dependencies]
zlib = { port = true, version = "^1.3" }
[target.consumer]
type = "executable"
sources = ["src/main.c"]
deps = ["zlib"]
"#,
)
.unwrap();
consumer
.child("src/main.c")
.write_str("int main(void){return 0;}\n")
.unwrap();
let port_sources = vec![PortPackageSource {
name: PackageName::new("zlib").unwrap(),
version: semver::Version::new(1, 3, 1),
manifest_path: prepared.path().join("cabin.toml"),
origin: cabin_port::PortOrigin::Builtin("zlib"),
}];
let graph = load_workspace_with_options(
consumer.path().join("cabin.toml"),
&WorkspaceLoadOptions {
registry: &[],
patches: &[],
ports: &port_sources,
registry_policy: RegistryPolicy::Strict,
include_dev_for: &BTreeSet::new(),
port_policy: PortPolicy::Strict,
},
)
.unwrap();
assert_eq!(graph.packages.len(), 2);
let zlib = graph
.packages
.iter()
.find(|p| p.package.name.as_str() == "zlib")
.unwrap();
assert_eq!(zlib.kind, PackageKind::Local);
}
#[test]
fn rejects_port_dep_without_prepared_source() {
let tmp = TempDir::new().unwrap();
let port_dir = tmp.child("ports/zlib/1.3.1");
port_dir.create_dir_all().unwrap();
let consumer = tmp.child("consumer");
consumer
.child("cabin.toml")
.write_str(
r#"
[package]
name = "consumer"
version = "0.1.0"
[dependencies]
zlib = { port-path = "../ports/zlib/1.3.1" }
"#,
)
.unwrap();
let err = load_workspace_with_options(
consumer.path().join("cabin.toml"),
&WorkspaceLoadOptions {
registry: &[],
patches: &[],
ports: &[],
registry_policy: RegistryPolicy::Strict,
include_dev_for: &BTreeSet::new(),
port_policy: PortPolicy::Strict,
},
)
.unwrap_err();
assert!(
matches!(err, WorkspaceError::PortDependencyNotPrepared { .. }),
"{err:?}"
);
}
#[test]
fn rejects_port_dep_with_missing_port_directory() {
let tmp = TempDir::new().unwrap();
let consumer = tmp.child("consumer");
consumer
.child("cabin.toml")
.write_str(
r#"
[package]
name = "consumer"
version = "0.1.0"
[dependencies]
zlib = { port-path = "../nonexistent/zlib" }
"#,
)
.unwrap();
let err = load_workspace_with_options(
consumer.path().join("cabin.toml"),
&WorkspaceLoadOptions {
registry: &[],
patches: &[],
ports: &[],
registry_policy: RegistryPolicy::Strict,
include_dev_for: &BTreeSet::new(),
port_policy: PortPolicy::Strict,
},
)
.unwrap_err();
assert!(
matches!(err, WorkspaceError::PortDirectoryMissing { .. }),
"{err:?}"
);
}
}