use crate::error::WorkspaceError;
use crate::graph::{DependencyEdge, PackageGraph, PackageKind, WorkspacePackage};
use cabin_core::{DependencyKind, DependencySource, PackageName, PortDepSource};
use cabin_manifest::ParsedManifest;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
mod members;
#[cfg(test)]
mod tests;
mod topo;
use self::members::{
WorkspaceMembers, expand_workspace_members, parse_workspace_dep_source,
resolve_workspace_dependencies, validate_workspace_pattern,
};
use self::topo::topo_sort;
#[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 {
to_load.push(canonicalize(&entry.manifest_path)?);
}
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
};
let is_port = port_canonical_paths.contains(&pkg.manifest_path);
packages.push(WorkspacePackage {
package: pkg.package.clone(),
manifest_path: pkg.manifest_path.clone(),
manifest_dir: pkg.manifest_dir.clone(),
deps,
kind,
is_port,
});
}
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),
})
}
pub(super) fn canonicalize(path: &Path) -> Result<PathBuf, WorkspaceError> {
cabin_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,
},
}
}