use std::collections::{BTreeMap, BTreeSet};
use std::fmt::{self, Debug, Display};
use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use eyre::{Result, bail};
use serde::{Deserialize, Serialize};
use super::{
Task, TaskCacheConfig,
task_source_checker::{build_source_matcher, is_source},
task_sources::TaskOutputs,
};
pub(crate) mod cargo;
pub(crate) mod git;
pub(crate) mod go;
pub(crate) mod node;
pub(crate) mod uv;
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub(crate) struct ProjectId(String);
impl ProjectId {
pub(crate) fn new(provider: &str, local_id: &str) -> Result<Self> {
validate_id_part("provider", provider)?;
validate_id_part("project", local_id)?;
Ok(Self(format!("{provider}:{local_id}")))
}
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
impl Display for ProjectId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
Display::fmt(&self.0, f)
}
}
impl FromStr for ProjectId {
type Err = eyre::Report;
fn from_str(value: &str) -> Result<Self> {
let Some((provider, local_id)) = value.split_once(':') else {
bail!("workspace project ID {value:?} must include a provider namespace");
};
Self::new(provider, local_id)
}
}
fn validate_id_part(kind: &str, value: &str) -> Result<()> {
if value.is_empty() {
bail!("workspace {kind} ID cannot be empty");
}
if value.trim() != value || value.chars().any(char::is_control) {
bail!(
"workspace {kind} ID {value:?} contains surrounding whitespace or control characters"
);
}
if kind == "provider" && value.contains(':') {
bail!("workspace provider ID {value:?} cannot contain ':'");
}
Ok(())
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub(crate) struct WorkspaceProject {
pub id: ProjectId,
pub root: PathBuf,
pub metadata: BTreeMap<String, String>,
pub dependencies: BTreeSet<ProjectId>,
pub provenance: WorkspaceProvenance,
pub dependency_provenance: BTreeMap<ProjectId, WorkspaceProvenance>,
pub tasks: BTreeMap<String, WorkspaceTask>,
}
impl WorkspaceProject {
pub(crate) fn new(id: ProjectId, root: impl Into<PathBuf>) -> Self {
Self {
id,
root: root.into(),
metadata: BTreeMap::new(),
dependencies: BTreeSet::new(),
provenance: WorkspaceProvenance::default(),
dependency_provenance: BTreeMap::new(),
tasks: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub(crate) struct WorkspaceProvenance {
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<PathBuf>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub(crate) struct WorkspaceTask {
pub command: String,
pub description: String,
pub source: PathBuf,
pub provenance: WorkspaceProvenance,
pub suggestions: WorkspaceTaskSuggestions,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub(crate) struct WorkspaceTaskSuggestions {
pub inputs: Vec<String>,
pub outputs: Option<Vec<String>>,
pub cache: Option<bool>,
pub depends: Option<Vec<String>>,
#[serde(skip)]
pub config_sources: Vec<PathBuf>,
pub provenance: WorkspaceTaskSuggestionProvenance,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub(crate) struct WorkspaceTaskSuggestionProvenance {
#[serde(skip_serializing_if = "Option::is_none")]
pub inputs: Option<WorkspaceProvenance>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outputs: Option<WorkspaceProvenance>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache: Option<WorkspaceProvenance>,
#[serde(skip_serializing_if = "Option::is_none")]
pub depends: Option<WorkspaceProvenance>,
}
impl WorkspaceTaskSuggestions {
pub(crate) fn apply_before_defaults(&self, task: &mut Task) {
if !self.inputs.is_empty() {
task.sources.clone_from(&self.inputs);
}
if let Some(outputs) = &self.outputs {
task.outputs = if outputs.is_empty() {
TaskOutputs::NoFiles
} else {
TaskOutputs::Files(outputs.clone())
};
}
if let Some(depends) = &self.depends
&& !depends.is_empty()
{
task.depends = depends.iter().cloned().map(Into::into).collect();
}
task.additional_config_sources
.extend(self.config_sources.iter().cloned());
}
pub(crate) fn apply_after_defaults(&self, task: &mut Task) {
if self.outputs.as_ref().is_some_and(Vec::is_empty) {
task.outputs = TaskOutputs::NoFiles;
}
if self.depends.as_ref().is_some_and(Vec::is_empty) {
task.depends.clear();
}
if let Some(enabled) = self.cache {
task.cache
.get_or_insert_with(TaskCacheConfig::default)
.enabled = enabled;
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct WorkspaceProjectOverride {
pub remove: bool,
pub root: Option<PathBuf>,
pub metadata: Option<BTreeMap<String, String>>,
pub depends: Option<BTreeSet<String>>,
pub depends_add: BTreeSet<String>,
pub depends_remove: BTreeSet<String>,
}
pub(crate) trait WorkspaceProvider: Debug + Send + Sync {
fn id(&self) -> &str;
fn discover(&self, workspace_root: &Path) -> Result<Vec<WorkspaceProject>>;
fn discover_with_context(
&self,
workspace_root: &Path,
_context: &WorkspaceDiscoveryContext,
) -> Result<Vec<WorkspaceProject>> {
self.discover(workspace_root)
}
fn discover_project_tasks(
&self,
_workspace_root: &Path,
_project_root: &Path,
) -> Result<BTreeMap<String, WorkspaceTask>> {
Ok(BTreeMap::new())
}
fn discover_project_tasks_with_context(
&self,
workspace_root: &Path,
project_root: &Path,
_context: &WorkspaceDiscoveryContext,
) -> Result<BTreeMap<String, WorkspaceTask>> {
self.discover_project_tasks(workspace_root, project_root)
}
fn affected_projects_for_lockfile(
&self,
_lockfile_path: &Path,
_before: Option<&str>,
_after: Option<&str>,
_graph: &WorkspaceProjectGraph,
) -> Result<Option<BTreeSet<ProjectId>>> {
Ok(None)
}
}
#[derive(Debug, Default)]
pub(crate) struct WorkspaceDiscoveryContext {
cache: Mutex<WorkspaceDiscoveryCache>,
#[cfg(test)]
physical_accesses: WorkspaceDiscoveryAccessCounts,
}
#[derive(Debug, Default)]
struct WorkspaceDiscoveryCache {
canonical_paths: BTreeMap<PathBuf, CachedIo<PathBuf>>,
file_kinds: BTreeMap<PathBuf, Option<CachedFileKind>>,
file_contents: BTreeMap<PathBuf, CachedIo<Arc<str>>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CachedFileKind {
File,
Directory,
Other,
}
#[derive(Clone, Debug)]
enum CachedIo<T> {
Ok(T),
Err(io::ErrorKind, String),
}
#[cfg(test)]
#[derive(Debug, Default)]
struct WorkspaceDiscoveryAccessCounts {
canonicalize: std::sync::atomic::AtomicUsize,
metadata: std::sync::atomic::AtomicUsize,
read: std::sync::atomic::AtomicUsize,
}
impl WorkspaceDiscoveryContext {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
let key = self.cache_key(path);
let mut cache = self.cache.lock().expect("workspace discovery cache lock");
cache
.canonical_paths
.entry(key.clone())
.or_insert_with(|| {
#[cfg(test)]
self.physical_accesses
.canonicalize
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
CachedIo::from_result(fs::canonicalize(key))
})
.to_result()
}
pub(crate) fn is_file(&self, path: &Path) -> bool {
self.file_kind(path) == Some(CachedFileKind::File)
}
pub(crate) fn is_dir(&self, path: &Path) -> bool {
self.file_kind(path) == Some(CachedFileKind::Directory)
}
pub(crate) fn exists(&self, path: &Path) -> bool {
self.file_kind(path).is_some()
}
pub(crate) fn read_to_string(&self, path: &Path) -> io::Result<Arc<str>> {
let key = self.cache_key(path);
let mut cache = self.cache.lock().expect("workspace discovery cache lock");
cache
.file_contents
.entry(key.clone())
.or_insert_with(|| {
#[cfg(test)]
self.physical_accesses
.read
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
CachedIo::from_result(fs::read_to_string(key).map(Arc::<str>::from))
})
.to_result()
}
fn file_kind(&self, path: &Path) -> Option<CachedFileKind> {
let key = self.cache_key(path);
let mut cache = self.cache.lock().expect("workspace discovery cache lock");
*cache.file_kinds.entry(key.clone()).or_insert_with(|| {
#[cfg(test)]
self.physical_accesses
.metadata
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
fs::metadata(key).ok().map(|metadata| {
if metadata.is_file() {
CachedFileKind::File
} else if metadata.is_dir() {
CachedFileKind::Directory
} else {
CachedFileKind::Other
}
})
})
}
fn cache_key(&self, path: &Path) -> PathBuf {
normalize_filesystem_path(path)
}
}
impl<T: Clone> CachedIo<T> {
fn from_result(result: io::Result<T>) -> Self {
match result {
Ok(value) => Self::Ok(value),
Err(error) => Self::Err(error.kind(), error.to_string()),
}
}
fn to_result(&self) -> io::Result<T> {
match self {
Self::Ok(value) => Ok(value.clone()),
Self::Err(kind, message) => Err(io::Error::new(*kind, message.clone())),
}
}
}
fn normalize_filesystem_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
component => normalized.push(component.as_os_str()),
}
}
if normalized.as_os_str().is_empty() && !path.as_os_str().is_empty() {
normalized.push(".");
}
normalized
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(crate) struct WorkspaceProjectGraph {
projects: BTreeMap<ProjectId, WorkspaceProject>,
provider_errors: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(crate) struct WorkspaceProjectPathMap {
pub projects_by_path: BTreeMap<PathBuf, BTreeSet<ProjectId>>,
pub unowned_paths: BTreeSet<PathBuf>,
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub(crate) enum AffectedProjectReason {
ChangedPath { path: PathBuf },
GlobalPath { path: PathBuf },
Lockfile { path: PathBuf },
Dependent { dependency: ProjectId },
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub(crate) struct AffectedProjects {
projects: BTreeMap<ProjectId, BTreeSet<AffectedProjectReason>>,
}
impl AffectedProjects {
pub(crate) fn projects(
&self,
) -> impl ExactSizeIterator<Item = (&ProjectId, &BTreeSet<AffectedProjectReason>)> {
self.projects.iter()
}
pub(crate) fn is_empty(&self) -> bool {
self.projects.is_empty()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct WorkspaceProjectCycleError {
path: Vec<ProjectId>,
}
impl WorkspaceProjectCycleError {
pub(crate) fn path(&self) -> &[ProjectId] {
&self.path
}
}
impl Display for WorkspaceProjectCycleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let path = self
.path
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(" -> ");
let project = format!(
"{:?}",
self.path.first().expect("cycle path is not empty").as_str()
);
write!(
f,
"workspace project dependency cycle detected: {path}; adjust [monorepo.projects.{project}] depends, depends_add, or depends_remove to break the cycle"
)
}
}
impl std::error::Error for WorkspaceProjectCycleError {}
impl WorkspaceProjectGraph {
pub(crate) fn discover(
provider: &dyn WorkspaceProvider,
workspace_root: &Path,
) -> Result<WorkspaceProjectGraph> {
Self::discover_all(&[provider], workspace_root)
}
pub(crate) fn discover_all(
providers: &[&dyn WorkspaceProvider],
workspace_root: &Path,
) -> Result<WorkspaceProjectGraph> {
let context = WorkspaceDiscoveryContext::new();
let graph = Self::collect_provider_projects(providers, workspace_root, &context, false)?;
graph.validate()?;
Ok(graph)
}
pub(crate) fn discover_all_with_overrides(
providers: &[&dyn WorkspaceProvider],
workspace_root: &Path,
overrides: &BTreeMap<String, WorkspaceProjectOverride>,
) -> Result<WorkspaceProjectGraph> {
Self::discover_all_with_overrides_inner(providers, workspace_root, overrides, false)
}
pub(crate) fn discover_all_with_overrides_lenient(
providers: &[&dyn WorkspaceProvider],
workspace_root: &Path,
overrides: &BTreeMap<String, WorkspaceProjectOverride>,
) -> Result<WorkspaceProjectGraph> {
Self::discover_all_with_overrides_inner(providers, workspace_root, overrides, true)
}
fn discover_all_with_overrides_inner(
providers: &[&dyn WorkspaceProvider],
workspace_root: &Path,
overrides: &BTreeMap<String, WorkspaceProjectOverride>,
skip_provider_errors: bool,
) -> Result<WorkspaceProjectGraph> {
let context = WorkspaceDiscoveryContext::new();
let mut graph = Self::collect_provider_projects(
providers,
workspace_root,
&context,
skip_provider_errors,
)?
.with_overrides(overrides)?;
for (raw_id, config) in overrides {
if config.remove || config.root.is_none() {
continue;
}
let id = raw_id.parse::<ProjectId>()?;
let Some((provider_id, _)) = id.as_str().split_once(':') else {
continue;
};
let Some(provider) = providers
.iter()
.find(|provider| provider.id() == provider_id)
else {
continue;
};
let project = graph
.projects
.get_mut(&id)
.expect("non-removed override project exists");
match provider.discover_project_tasks_with_context(
workspace_root,
project.root.as_path(),
&context,
) {
Ok(mut tasks) => {
for task in tasks.values_mut() {
attach_task_provenance(task, provider_id);
}
project.tasks = tasks;
}
Err(error) => {
warn!(
"failed to infer workspace tasks for {id} at {}: {error:#}",
project.root.display()
);
project.tasks.clear();
}
}
}
Ok(graph)
}
fn collect_provider_projects(
providers: &[&dyn WorkspaceProvider],
workspace_root: &Path,
context: &WorkspaceDiscoveryContext,
skip_provider_errors: bool,
) -> Result<WorkspaceProjectGraph> {
let mut providers = providers
.iter()
.map(|provider| (provider.id().to_string(), *provider))
.collect::<Vec<_>>();
providers.sort_by(|(left, _), (right, _)| left.cmp(right));
for (index, (provider_id, _)) in providers.iter().enumerate() {
validate_id_part("provider", provider_id)?;
if index > 0 && providers[index - 1].0 == *provider_id {
bail!("duplicate workspace provider ID {provider_id:?}");
}
}
let mut projects = BTreeMap::new();
let mut provider_errors = BTreeMap::new();
for (provider_id, provider) in providers {
let discovered = match provider.discover_with_context(workspace_root, context) {
Ok(projects) => projects,
Err(error) if skip_provider_errors => {
let error = format!("{error:#}");
warn!(
"failed to discover {provider_id} workspace projects at {}; inferred tasks \
and upstream task dependencies from this provider are unavailable: {error}",
workspace_root.display(),
);
provider_errors.insert(provider_id, error);
continue;
}
Err(error) => return Err(error),
};
for mut project in discovered {
let expected_prefix = format!("{provider_id}:");
let Some(local_id) = project.id.as_str().strip_prefix(&expected_prefix) else {
bail!(
"workspace provider {provider_id:?} returned project ID {:?}; IDs must use the {expected_prefix:?} namespace",
project.id
);
};
validate_id_part("project", local_id)?;
project.root = normalize_project_root(&project.id, &project.root)?;
attach_provider_provenance(&mut project, &provider_id);
let id = project.id.clone();
if projects.insert(id.clone(), project).is_some() {
bail!(
"workspace provider {provider_id:?} returned duplicate project ID {id:?}"
);
}
}
}
Ok(Self {
projects,
provider_errors,
})
}
pub(crate) fn with_overrides(
mut self,
overrides: &BTreeMap<String, WorkspaceProjectOverride>,
) -> Result<Self> {
let mut removed = BTreeSet::new();
for (raw_id, config) in overrides {
let id = raw_id.parse::<ProjectId>()?;
validate_override(&id, config)?;
if config.remove {
self.projects.remove(&id);
removed.insert(id);
continue;
}
if !self.projects.contains_key(&id) {
let root = config.root.clone().ok_or_else(|| {
eyre::eyre!(
"workspace project {id:?} is not inferred and requires an explicit root"
)
})?;
self.projects
.insert(id.clone(), WorkspaceProject::new(id.clone(), root));
}
let project = self.projects.get_mut(&id).expect("project was inserted");
if let Some(root) = &config.root {
let root = normalize_project_root(&id, root)?;
if root != project.root {
project.tasks.clear();
for provenance in project.dependency_provenance.values_mut() {
provenance.source = None;
}
project.root = root;
}
project.provenance = WorkspaceProvenance::default();
}
if let Some(metadata) = &config.metadata {
project.metadata.clone_from(metadata);
project.provenance = WorkspaceProvenance::default();
}
if let Some(depends) = &config.depends {
project.dependencies = parse_dependency_ids(&id, "depends", depends)?;
project.dependency_provenance.clear();
}
let depends_remove =
parse_dependency_ids(&id, "depends_remove", &config.depends_remove)?;
project
.dependencies
.retain(|dependency| !depends_remove.contains(dependency));
project
.dependency_provenance
.retain(|dependency, _| !depends_remove.contains(dependency));
let depends_add = parse_dependency_ids(&id, "depends_add", &config.depends_add)?;
for dependency in depends_add {
if project.dependencies.insert(dependency.clone()) {
project.dependency_provenance.remove(&dependency);
}
}
}
if !removed.is_empty() {
for project in self.projects.values_mut() {
project
.dependencies
.retain(|dependency| !removed.contains(dependency));
project
.dependency_provenance
.retain(|dependency, _| !removed.contains(dependency));
}
}
self.validate()?;
Ok(self)
}
pub(crate) fn projects(&self) -> impl ExactSizeIterator<Item = &WorkspaceProject> {
self.projects.values()
}
pub(crate) fn get(&self, id: &ProjectId) -> Option<&WorkspaceProject> {
self.projects.get(id)
}
pub(crate) fn map_paths_to_projects(
&self,
paths: impl IntoIterator<Item = impl AsRef<Path>>,
) -> Result<WorkspaceProjectPathMap> {
let mut projects_by_root = BTreeMap::<&Path, BTreeSet<ProjectId>>::new();
for project in self.projects() {
projects_by_root
.entry(project.root.as_path())
.or_default()
.insert(project.id.clone());
}
let mut mapped = WorkspaceProjectPathMap::default();
for path in paths {
let path = normalize_workspace_path(path.as_ref())?;
let owners = path.ancestors().find_map(|ancestor| {
let root = if ancestor.as_os_str().is_empty() {
Path::new(".")
} else {
ancestor
};
projects_by_root.get(root)
});
if let Some(owners) = owners {
mapped.projects_by_path.insert(path, owners.clone());
} else {
mapped.unowned_paths.insert(path);
}
}
Ok(mapped)
}
pub(crate) fn affected_projects<'a>(
&self,
changed: impl IntoIterator<Item = &'a ProjectId>,
) -> Result<BTreeSet<ProjectId>> {
let mut dependents = BTreeMap::<ProjectId, BTreeSet<ProjectId>>::new();
for project in self.projects() {
for dependency in &project.dependencies {
dependents
.entry(dependency.clone())
.or_default()
.insert(project.id.clone());
}
}
let mut affected = BTreeSet::new();
let mut pending = Vec::new();
for id in changed {
if !self.projects.contains_key(id) {
bail!("unknown workspace project {id:?}");
}
if affected.insert(id.clone()) {
pending.push(id.clone());
}
}
while let Some(id) = pending.pop() {
for dependent in dependents.get(&id).into_iter().flatten() {
if affected.insert(dependent.clone()) {
pending.push(dependent.clone());
}
}
}
Ok(affected)
}
pub(crate) fn affected_projects_for_paths(
&self,
workspace_root: &Path,
paths: impl IntoIterator<Item = impl AsRef<Path>>,
resolved_global_inputs: &[String],
) -> Result<BTreeSet<ProjectId>> {
Ok(self
.affected_projects_for_changes(
workspace_root,
paths,
resolved_global_inputs,
&BTreeMap::new(),
)?
.projects
.into_keys()
.collect())
}
pub(crate) fn affected_projects_for_changes(
&self,
workspace_root: &Path,
paths: impl IntoIterator<Item = impl AsRef<Path>>,
resolved_global_inputs: &[String],
lockfile_projects: &BTreeMap<PathBuf, BTreeSet<ProjectId>>,
) -> Result<AffectedProjects> {
let mapped = self.map_paths_to_projects(paths)?;
let global_matcher =
build_source_matcher(workspace_root, workspace_root, resolved_global_inputs);
let global_paths = mapped
.projects_by_path
.keys()
.chain(&mapped.unowned_paths)
.filter(|path| {
mapped.unowned_paths.contains(*path)
|| is_source(&global_matcher, &workspace_root.join(path))
})
.cloned()
.collect::<BTreeSet<_>>();
let mut projects = BTreeMap::<ProjectId, BTreeSet<AffectedProjectReason>>::new();
if global_paths.is_empty() {
for (path, owners) in mapped.projects_by_path {
for id in owners {
projects
.entry(id)
.or_default()
.insert(AffectedProjectReason::ChangedPath { path: path.clone() });
}
}
} else {
for id in self.projects.keys() {
projects.entry(id.clone()).or_default().extend(
global_paths
.iter()
.cloned()
.map(|path| AffectedProjectReason::GlobalPath { path }),
);
}
}
for (path, ids) in lockfile_projects {
for id in ids {
if !self.projects.contains_key(id) {
bail!(
"lockfile {:?} attributed to unknown workspace project {id:?}",
path
);
}
projects
.entry(id.clone())
.or_default()
.insert(AffectedProjectReason::Lockfile { path: path.clone() });
}
}
let mut dependents = BTreeMap::<ProjectId, BTreeSet<ProjectId>>::new();
for project in self.projects() {
for dependency in &project.dependencies {
dependents
.entry(dependency.clone())
.or_default()
.insert(project.id.clone());
}
}
let mut pending = projects.keys().rev().cloned().collect::<Vec<_>>();
while let Some(dependency) = pending.pop() {
for dependent in dependents.get(&dependency).into_iter().flatten() {
let is_new = !projects.contains_key(dependent);
projects.entry(dependent.clone()).or_default().insert(
AffectedProjectReason::Dependent {
dependency: dependency.clone(),
},
);
if is_new {
pending.push(dependent.clone());
}
}
}
Ok(AffectedProjects { projects })
}
pub(crate) fn affected_projects_for_lockfile(
&self,
providers: &[&dyn WorkspaceProvider],
lockfile_path: &Path,
before: Option<&str>,
after: Option<&str>,
) -> Result<Option<BTreeSet<ProjectId>>> {
let mut providers = providers.to_vec();
providers.sort_by(|left, right| left.id().cmp(right.id()));
let mut recognized = false;
let mut affected = BTreeSet::new();
for provider in providers {
let Some(projects) =
provider.affected_projects_for_lockfile(lockfile_path, before, after, self)?
else {
continue;
};
recognized = true;
let expected_prefix = format!("{}:", provider.id());
for id in projects {
if !id.as_str().starts_with(&expected_prefix) {
bail!(
"workspace provider {:?} attributed lockfile {:?} to foreign project {id:?}",
provider.id(),
lockfile_path
);
}
if !self.projects.contains_key(&id) {
bail!(
"workspace provider {:?} attributed lockfile {:?} to unknown project {id:?}",
provider.id(),
lockfile_path
);
}
affected.insert(id);
}
}
Ok(recognized.then_some(affected))
}
pub(crate) fn provider_discovery_error(&self) -> Option<String> {
(!self.provider_errors.is_empty()).then(|| {
self.provider_errors
.iter()
.map(|(provider, error)| format!("{provider}: {error}"))
.collect::<Vec<_>>()
.join("; ")
})
}
pub(crate) fn matching_dependency_projects(
&self,
id: &ProjectId,
mut matches: impl FnMut(&WorkspaceProject) -> bool,
) -> Result<Vec<&WorkspaceProject>> {
if !self.projects.contains_key(id) {
bail!("unknown workspace project {id:?}");
}
let mut visited = BTreeSet::new();
let mut matching = Vec::new();
self.collect_matching_dependencies(id, &mut visited, &mut matches, &mut matching);
Ok(matching)
}
fn collect_matching_dependencies<'a>(
&'a self,
id: &ProjectId,
visited: &mut BTreeSet<ProjectId>,
matches: &mut impl FnMut(&WorkspaceProject) -> bool,
matching: &mut Vec<&'a WorkspaceProject>,
) {
let project = self
.projects
.get(id)
.expect("workspace project graph is validated");
for dependency_id in &project.dependencies {
if !visited.insert(dependency_id.clone()) {
continue;
}
self.collect_matching_dependencies(dependency_id, visited, matches, matching);
let dependency = self
.projects
.get(dependency_id)
.expect("workspace project graph is validated");
if matches(dependency) {
matching.push(dependency);
}
}
}
fn validate(&self) -> Result<()> {
for project in self.projects() {
for dependency in &project.dependencies {
if self.get(dependency).is_none() {
bail!(
"workspace project {:?} depends on unknown project {:?}",
project.id,
dependency
);
}
}
}
if let Some(path) = self.find_cycle() {
return Err(WorkspaceProjectCycleError { path }.into());
}
Ok(())
}
fn find_cycle(&self) -> Option<Vec<ProjectId>> {
let mut visited = BTreeSet::new();
let mut active = BTreeMap::new();
let mut path = Vec::new();
for id in self.projects.keys() {
if let Some(cycle) = self.find_cycle_from(id, &mut visited, &mut active, &mut path) {
return Some(cycle);
}
}
None
}
fn find_cycle_from(
&self,
id: &ProjectId,
visited: &mut BTreeSet<ProjectId>,
active: &mut BTreeMap<ProjectId, usize>,
path: &mut Vec<ProjectId>,
) -> Option<Vec<ProjectId>> {
if visited.contains(id) {
return None;
}
if let Some(start) = active.get(id) {
let mut cycle = path[*start..].to_vec();
cycle.push(id.clone());
return Some(cycle);
}
active.insert(id.clone(), path.len());
path.push(id.clone());
for dependency in &self.projects.get(id)?.dependencies {
if let Some(cycle) = self.find_cycle_from(dependency, visited, active, path) {
return Some(cycle);
}
}
path.pop();
active.remove(id);
visited.insert(id.clone());
None
}
}
fn attach_provider_provenance(project: &mut WorkspaceProject, provider: &str) {
project
.provenance
.provider
.get_or_insert_with(|| provider.to_string());
for dependency in &project.dependencies {
project
.dependency_provenance
.entry(dependency.clone())
.or_default()
.provider
.get_or_insert_with(|| provider.to_string());
}
for task in project.tasks.values_mut() {
attach_task_provenance(task, provider);
}
}
fn attach_task_provenance(task: &mut WorkspaceTask, provider: &str) {
task.provenance
.provider
.get_or_insert_with(|| provider.to_string());
task.provenance
.source
.get_or_insert_with(|| task.source.clone());
let suggestion_source = task.suggestions.config_sources.first().cloned();
let attach_suggestion = |provenance: &mut Option<WorkspaceProvenance>| {
let provenance = provenance.get_or_insert_default();
provenance
.provider
.get_or_insert_with(|| provider.to_string());
if let Some(source) = &suggestion_source {
provenance.source.get_or_insert_with(|| source.clone());
}
};
if !task.suggestions.inputs.is_empty() {
attach_suggestion(&mut task.suggestions.provenance.inputs);
}
if task.suggestions.outputs.is_some() {
attach_suggestion(&mut task.suggestions.provenance.outputs);
}
if task.suggestions.cache.is_some() {
attach_suggestion(&mut task.suggestions.provenance.cache);
}
if task.suggestions.depends.is_some() {
attach_suggestion(&mut task.suggestions.provenance.depends);
}
}
fn validate_override(id: &ProjectId, config: &WorkspaceProjectOverride) -> Result<()> {
if config.remove
&& (config.root.is_some()
|| config.metadata.is_some()
|| config.depends.is_some()
|| !config.depends_add.is_empty()
|| !config.depends_remove.is_empty())
{
bail!("removed workspace project {id:?} cannot define other overrides");
}
if let Some(dependency) = config
.depends_add
.intersection(&config.depends_remove)
.next()
{
bail!("workspace project {id:?} cannot both add and remove dependency {dependency:?}");
}
Ok(())
}
fn parse_dependency_ids(
project_id: &ProjectId,
field: &str,
dependencies: &BTreeSet<String>,
) -> Result<BTreeSet<ProjectId>> {
dependencies
.iter()
.map(|dependency| {
dependency.parse::<ProjectId>().map_err(|err| {
eyre::eyre!(
"workspace project {project_id:?} has invalid {field} entry {dependency:?}: {err}"
)
})
})
.collect()
}
fn normalize_project_root(id: &ProjectId, root: &Path) -> Result<PathBuf> {
if root.is_absolute() {
bail!(
"workspace project {id:?} has absolute root {root:?}; roots must be workspace-relative"
);
}
let mut normalized = PathBuf::new();
for component in root.components() {
match component {
Component::CurDir => {}
Component::Normal(component) => normalized.push(component),
Component::ParentDir => {
if !normalized.pop() {
bail!(
"workspace project {id:?} has root {root:?} that escapes the workspace root"
);
}
}
Component::RootDir | Component::Prefix(_) => {
bail!(
"workspace project {id:?} has absolute root {root:?}; roots must be workspace-relative"
);
}
}
}
if normalized.as_os_str().is_empty() {
normalized.push(".");
}
Ok(normalized)
}
fn normalize_workspace_path(path: &Path) -> Result<PathBuf> {
if path.is_absolute() {
bail!("workspace path {path:?} is absolute; paths must be workspace-relative");
}
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Normal(component) => normalized.push(component),
Component::ParentDir => {
if !normalized.pop() {
bail!("workspace path {path:?} escapes the workspace root");
}
}
Component::RootDir | Component::Prefix(_) => {
bail!("workspace path {path:?} is absolute; paths must be workspace-relative");
}
}
}
if normalized.as_os_str().is_empty() {
normalized.push(".");
}
Ok(normalized)
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug)]
struct TestProvider {
id: &'static str,
projects: Vec<WorkspaceProject>,
}
#[derive(Debug)]
struct LockfileProvider {
id: &'static str,
projects: BTreeSet<ProjectId>,
}
impl WorkspaceProvider for LockfileProvider {
fn id(&self) -> &str {
self.id
}
fn discover(&self, _workspace_root: &Path) -> Result<Vec<WorkspaceProject>> {
Ok(Vec::new())
}
fn affected_projects_for_lockfile(
&self,
_lockfile_path: &Path,
_before: Option<&str>,
_after: Option<&str>,
_graph: &WorkspaceProjectGraph,
) -> Result<Option<BTreeSet<ProjectId>>> {
Ok(Some(self.projects.clone()))
}
}
impl WorkspaceProvider for TestProvider {
fn id(&self) -> &str {
self.id
}
fn discover(&self, _workspace_root: &Path) -> Result<Vec<WorkspaceProject>> {
Ok(self.projects.clone())
}
}
#[derive(Debug)]
struct FailingTaskProvider;
impl WorkspaceProvider for FailingTaskProvider {
fn id(&self) -> &str {
"test"
}
fn discover(&self, _workspace_root: &Path) -> Result<Vec<WorkspaceProject>> {
let mut project = WorkspaceProject::new(ProjectId::new("test", "app").unwrap(), "old");
project.tasks.insert(
"build".to_string(),
WorkspaceTask {
command: "old build".to_string(),
description: "old build".to_string(),
source: "old/manifest".into(),
provenance: WorkspaceProvenance::default(),
suggestions: WorkspaceTaskSuggestions::default(),
},
);
Ok(vec![project])
}
fn discover_project_tasks(
&self,
_workspace_root: &Path,
_project_root: &Path,
) -> Result<BTreeMap<String, WorkspaceTask>> {
bail!("task discovery failed")
}
}
#[derive(Debug)]
struct FailingDiscoveryProvider;
impl WorkspaceProvider for FailingDiscoveryProvider {
fn id(&self) -> &str {
"broken"
}
fn discover(&self, _workspace_root: &Path) -> Result<Vec<WorkspaceProject>> {
bail!("broken workspace metadata")
}
}
#[derive(Debug)]
struct ContextObservingProvider {
id: &'static str,
contents: Arc<Mutex<Vec<Arc<str>>>>,
}
impl WorkspaceProvider for ContextObservingProvider {
fn id(&self) -> &str {
self.id
}
fn discover(&self, _workspace_root: &Path) -> Result<Vec<WorkspaceProject>> {
bail!("graph discovery did not provide a shared context")
}
fn discover_with_context(
&self,
workspace_root: &Path,
context: &WorkspaceDiscoveryContext,
) -> Result<Vec<WorkspaceProject>> {
self.contents
.lock()
.unwrap()
.push(context.read_to_string(&workspace_root.join("shared.txt"))?);
Ok(Vec::new())
}
}
fn project(provider: &str, name: &str, root: &str) -> WorkspaceProject {
WorkspaceProject::new(ProjectId::new(provider, name).unwrap(), root)
}
fn test_provider(projects: Vec<WorkspaceProject>) -> TestProvider {
TestProvider {
id: "test",
projects,
}
}
#[test]
fn discovery_context_deduplicates_filesystem_accesses() {
use std::sync::atomic::Ordering;
let temp = tempfile::tempdir().unwrap();
let manifest = temp.path().join("manifest.txt");
std::fs::write(&manifest, "workspace").unwrap();
let context = WorkspaceDiscoveryContext::new();
assert!(context.is_file(&manifest));
assert!(context.exists(&temp.path().join("./manifest.txt")));
let first = context.read_to_string(&manifest).unwrap();
let second = context
.read_to_string(&temp.path().join("./manifest.txt"))
.unwrap();
assert!(Arc::ptr_eq(&first, &second));
assert_eq!(
context.canonicalize(temp.path()).unwrap(),
context.canonicalize(temp.path()).unwrap()
);
assert_eq!(
context.physical_accesses.metadata.load(Ordering::Relaxed),
1
);
assert_eq!(context.physical_accesses.read.load(Ordering::Relaxed), 1);
assert_eq!(
context
.physical_accesses
.canonicalize
.load(Ordering::Relaxed),
1
);
}
#[test]
fn discovery_context_preserves_current_and_parent_components() {
let context = WorkspaceDiscoveryContext::new();
assert_eq!(
context.canonicalize(Path::new(".")).unwrap(),
std::fs::canonicalize(".").unwrap()
);
assert_eq!(
normalize_filesystem_path(Path::new("package/../manifest.txt")),
Path::new("package/../manifest.txt")
);
}
#[cfg(unix)]
#[test]
fn discovery_context_preserves_symlink_parent_traversal() {
let temp = tempfile::tempdir().unwrap();
let real = temp.path().join("real");
std::fs::create_dir_all(real.join("nested")).unwrap();
std::fs::write(real.join("manifest.txt"), "workspace").unwrap();
std::os::unix::fs::symlink(real.join("nested"), temp.path().join("link")).unwrap();
let path = temp.path().join("link/../manifest.txt");
let context = WorkspaceDiscoveryContext::new();
assert!(context.is_file(&path));
assert_eq!(
context.canonicalize(&path).unwrap(),
std::fs::canonicalize(real.join("manifest.txt")).unwrap()
);
}
#[test]
fn graph_discovery_shares_context_between_providers() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("shared.txt"), "workspace").unwrap();
let contents = Arc::new(Mutex::new(Vec::new()));
let first = ContextObservingProvider {
id: "first",
contents: contents.clone(),
};
let second = ContextObservingProvider {
id: "second",
contents: contents.clone(),
};
WorkspaceProjectGraph::discover_all(&[&first, &second], temp.path()).unwrap();
let contents = contents.lock().unwrap();
assert_eq!(contents.len(), 2);
assert!(Arc::ptr_eq(&contents[0], &contents[1]));
}
#[test]
fn project_ids_are_provider_namespaced() {
assert_eq!(
ProjectId::new("node", "@scope/app").unwrap().as_str(),
"node:@scope/app"
);
assert!(ProjectId::new("", "app").is_err());
assert!(ProjectId::new("node", " app").is_err());
}
#[test]
fn lockfile_attribution_rejects_foreign_project_ids() {
let node = TestProvider {
id: "node",
projects: vec![project("node", "app", "app")],
};
let graph = WorkspaceProjectGraph::discover(&node, Path::new("/workspace")).unwrap();
let cargo = LockfileProvider {
id: "cargo",
projects: BTreeSet::from([ProjectId::new("node", "app").unwrap()]),
};
let err = graph
.affected_projects_for_lockfile(
&[&cargo],
Path::new("Cargo.lock"),
Some("before"),
Some("after"),
)
.unwrap_err();
assert!(err.to_string().contains("to foreign project"));
}
#[test]
fn task_suggestions_map_to_inferred_task_configuration() {
let suggestions = WorkspaceTaskSuggestions {
inputs: vec!["src/**".to_string(), "package.json".to_string()],
outputs: Some(vec!["dist/**".to_string()]),
cache: Some(true),
depends: Some(vec!["prepare".to_string(), "^build".to_string()]),
config_sources: vec![PathBuf::from("turbo.json")],
provenance: WorkspaceTaskSuggestionProvenance::default(),
};
let mut task = Task::default();
suggestions.apply_before_defaults(&mut task);
suggestions.apply_after_defaults(&mut task);
assert_eq!(task.sources, vec!["src/**", "package.json"]);
assert_eq!(
task.outputs,
TaskOutputs::Files(vec!["dist/**".to_string()])
);
assert!(task.cache.as_ref().is_some_and(|cache| cache.enabled));
assert_eq!(
task.depends
.iter()
.map(|dependency| dependency.task.as_str())
.collect::<Vec<_>>(),
vec!["prepare", "^build"]
);
assert_eq!(
task.additional_config_sources,
[PathBuf::from("turbo.json")]
);
}
#[test]
fn task_suggestions_distinguish_empty_values_from_no_suggestion() {
let mut no_outputs = Task::default();
let no_output_suggestions = WorkspaceTaskSuggestions {
outputs: Some(Vec::new()),
cache: Some(false),
..Default::default()
};
no_output_suggestions.apply_before_defaults(&mut no_outputs);
no_outputs.merge_template(&super::super::TaskTemplate {
outputs: TaskOutputs::Files(vec!["default".to_string()]),
..Default::default()
});
no_output_suggestions.apply_after_defaults(&mut no_outputs);
assert_eq!(no_outputs.outputs, TaskOutputs::NoFiles);
assert!(
no_outputs
.cache
.as_ref()
.is_some_and(|cache| !cache.enabled)
);
let mut unspecified = Task {
sources: vec!["existing".to_string()],
outputs: TaskOutputs::Files(vec!["existing".to_string()]),
cache: Some(TaskCacheConfig {
enabled: true,
..Default::default()
}),
depends: vec!["existing".to_string().into()],
..Default::default()
};
WorkspaceTaskSuggestions::default().apply_before_defaults(&mut unspecified);
WorkspaceTaskSuggestions::default().apply_after_defaults(&mut unspecified);
assert_eq!(unspecified.sources, vec!["existing"]);
assert_eq!(
unspecified.outputs,
TaskOutputs::Files(vec!["existing".to_string()])
);
assert_eq!(unspecified.depends[0].task, "existing");
assert!(
unspecified
.cache
.as_ref()
.is_some_and(|cache| cache.enabled)
);
let suggestions = WorkspaceTaskSuggestions {
depends: Some(Vec::new()),
..Default::default()
};
let mut no_dependencies = Task::default();
suggestions.apply_before_defaults(&mut no_dependencies);
no_dependencies.merge_template(&super::super::TaskTemplate {
depends: vec!["default".to_string().into()],
..Default::default()
});
suggestions.apply_after_defaults(&mut no_dependencies);
assert!(no_dependencies.depends.is_empty());
}
#[test]
fn cache_suggestion_preserves_default_cache_inputs() {
let suggestions = WorkspaceTaskSuggestions {
cache: Some(true),
..Default::default()
};
let mut task = Task::default();
suggestions.apply_before_defaults(&mut task);
task.merge_template(&super::super::TaskTemplate {
cache: Some(TaskCacheConfig {
env: vec!["NODE_ENV".to_string()],
command_inputs: vec!["node --version".to_string()],
..Default::default()
}),
..Default::default()
});
suggestions.apply_after_defaults(&mut task);
assert_eq!(
task.cache,
Some(TaskCacheConfig {
enabled: true,
audit: false,
env: vec!["NODE_ENV".to_string()],
command_inputs: vec!["node --version".to_string()],
})
);
}
#[test]
fn discovery_is_ordered_and_normalizes_roots() {
let provider = test_provider(vec![
project("test", "z", "./packages/z"),
project("test", "a", ""),
]);
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace")).unwrap();
let projects = graph.projects().collect::<Vec<_>>();
assert_eq!(projects[0].id.as_str(), "test:a");
assert_eq!(projects[0].root, Path::new("."));
assert_eq!(projects[0].provenance.provider.as_deref(), Some("test"));
assert_eq!(projects[1].id.as_str(), "test:z");
assert_eq!(projects[1].root, Path::new("packages/z"));
}
#[test]
fn discovery_rejects_duplicate_ids() {
let provider = test_provider(vec![
project("test", "app", "a"),
project("test", "app", "b"),
]);
let err = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace")).unwrap_err();
assert!(err.to_string().contains("duplicate project ID"));
}
#[test]
fn discovery_rejects_invalid_roots() {
let absolute = test_provider(vec![project("test", "app", "/outside")]);
let escaping = test_provider(vec![project("test", "app", "../outside")]);
assert!(
WorkspaceProjectGraph::discover(&absolute, Path::new("/workspace"))
.unwrap_err()
.to_string()
.contains("absolute root")
);
assert!(
WorkspaceProjectGraph::discover(&escaping, Path::new("/workspace"))
.unwrap_err()
.to_string()
.contains("escapes the workspace root")
);
}
#[test]
fn discovery_normalizes_internal_parent_components() {
let provider = test_provider(vec![project("test", "app", "packages/tmp/../app")]);
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace")).unwrap();
assert_eq!(
graph.projects().next().unwrap().root,
Path::new("packages/app")
);
}
#[test]
fn paths_map_to_projects_at_the_deepest_root() {
let first = TestProvider {
id: "first",
projects: vec![
project("first", "root", "."),
project("first", "app", "apps/app"),
project("first", "nested", "apps/app/packages/nested"),
],
};
let second = TestProvider {
id: "second",
projects: vec![project("second", "app", "apps/app")],
};
let graph =
WorkspaceProjectGraph::discover_all(&[&first, &second], Path::new("/workspace"))
.unwrap();
let mapped = graph
.map_paths_to_projects([
"README.md",
"apps/app/src/main.rs",
"apps/app/packages/nested/src/lib.rs",
])
.unwrap();
assert_eq!(
mapped.projects_by_path,
BTreeMap::from([
(
PathBuf::from("README.md"),
BTreeSet::from([ProjectId::new("first", "root").unwrap()]),
),
(
PathBuf::from("apps/app/packages/nested/src/lib.rs"),
BTreeSet::from([ProjectId::new("first", "nested").unwrap()]),
),
(
PathBuf::from("apps/app/src/main.rs"),
BTreeSet::from([
ProjectId::new("first", "app").unwrap(),
ProjectId::new("second", "app").unwrap(),
]),
),
])
);
assert!(mapped.unowned_paths.is_empty());
}
#[test]
fn path_mapping_normalizes_deduplicates_and_retains_unowned_paths() {
let provider = test_provider(vec![project("test", "app", "apps/app")]);
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace")).unwrap();
let mapped = graph
.map_paths_to_projects([
"./apps/app/src/main.rs",
"apps/tmp/../app/src/main.rs",
"workspace.toml",
"./workspace.toml",
])
.unwrap();
assert_eq!(
mapped.projects_by_path,
BTreeMap::from([(
PathBuf::from("apps/app/src/main.rs"),
BTreeSet::from([ProjectId::new("test", "app").unwrap()]),
)])
);
assert_eq!(
mapped.unowned_paths,
BTreeSet::from([PathBuf::from("workspace.toml")])
);
}
#[test]
fn path_mapping_rejects_paths_outside_the_workspace() {
let graph = WorkspaceProjectGraph::default();
assert!(
graph
.map_paths_to_projects(["/outside"])
.unwrap_err()
.to_string()
.contains("must be workspace-relative")
);
assert!(
graph
.map_paths_to_projects(["../outside"])
.unwrap_err()
.to_string()
.contains("escapes the workspace root")
);
}
#[test]
fn discovery_rejects_dangling_dependency_edges() {
let mut app = project("test", "app", "app");
app.dependencies
.insert(ProjectId::new("test", "missing").unwrap());
let provider = test_provider(vec![app]);
let err = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace")).unwrap_err();
assert!(err.to_string().contains("depends on unknown project"));
}
#[test]
fn discovery_rejects_ids_from_another_provider() {
let provider = TestProvider {
id: "test",
projects: vec![WorkspaceProject::new(
ProjectId::new("other", "app").unwrap(),
"app",
)],
};
let err = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace")).unwrap_err();
assert!(err.to_string().contains("must use the \"test:\" namespace"));
}
#[test]
fn discovery_merges_providers_and_cross_provider_edges() {
let cargo = TestProvider {
id: "cargo",
projects: vec![project("cargo", "core", "crates/core")],
};
let mut app = project("node", "app", "apps/app");
app.dependencies
.insert(ProjectId::new("cargo", "core").unwrap());
let node = TestProvider {
id: "node",
projects: vec![app],
};
let graph =
WorkspaceProjectGraph::discover_all(&[&node, &cargo], Path::new("/workspace")).unwrap();
let ids = graph
.projects()
.map(|project| project.id.as_str())
.collect::<Vec<_>>();
assert_eq!(ids, vec!["cargo:core", "node:app"]);
assert_eq!(
graph
.get(&ProjectId::new("node", "app").unwrap())
.unwrap()
.dependencies,
BTreeSet::from([ProjectId::new("cargo", "core").unwrap()])
);
}
#[test]
fn discovery_is_independent_of_provider_order() {
let cargo = TestProvider {
id: "cargo",
projects: vec![project("cargo", "core", ".")],
};
let node = TestProvider {
id: "node",
projects: vec![project("node", "app", ".")],
};
let forward =
WorkspaceProjectGraph::discover_all(&[&cargo, &node], Path::new("/workspace")).unwrap();
let reverse =
WorkspaceProjectGraph::discover_all(&[&node, &cargo], Path::new("/workspace")).unwrap();
assert_eq!(forward, reverse);
}
#[test]
fn lenient_discovery_isolates_provider_errors() {
let working = TestProvider {
id: "node",
projects: vec![project("node", "app", "apps/app")],
};
assert!(
WorkspaceProjectGraph::discover_all_with_overrides(
&[&FailingDiscoveryProvider, &working],
Path::new("/workspace"),
&BTreeMap::new(),
)
.unwrap_err()
.to_string()
.contains("broken workspace metadata")
);
let graph = WorkspaceProjectGraph::discover_all_with_overrides_lenient(
&[&FailingDiscoveryProvider, &working],
Path::new("/workspace"),
&BTreeMap::new(),
)
.unwrap();
assert!(graph.get(&ProjectId::new("node", "app").unwrap()).is_some());
assert_eq!(
graph.provider_discovery_error().as_deref(),
Some("broken: broken workspace metadata")
);
}
#[test]
fn discovery_rejects_duplicate_provider_ids() {
let first = test_provider(vec![project("test", "first", "first")]);
let second = test_provider(vec![project("test", "second", "second")]);
let err = WorkspaceProjectGraph::discover_all(&[&first, &second], Path::new("/workspace"))
.unwrap_err();
assert!(err.to_string().contains("duplicate workspace provider ID"));
}
#[test]
fn overrides_add_remove_and_modify_projects() {
let lib_id = ProjectId::new("node", "lib").unwrap();
let old_id = ProjectId::new("node", "old").unwrap();
let mut app = project("node", "app", "apps/app");
app.dependencies = BTreeSet::from([lib_id.clone(), old_id.clone()]);
app.tasks.insert(
"build".to_string(),
WorkspaceTask {
command: "npm run build --".to_string(),
description: "vite build".to_string(),
source: "apps/app/package.json".into(),
provenance: WorkspaceProvenance::default(),
suggestions: WorkspaceTaskSuggestions::default(),
},
);
let node = TestProvider {
id: "node",
projects: vec![
app,
project("node", "lib", "packages/lib"),
project("node", "old", "packages/old"),
],
};
let cargo = TestProvider {
id: "cargo",
projects: vec![project("cargo", "core", "crates/core")],
};
let overrides = BTreeMap::from([
(
"custom:docs".to_string(),
WorkspaceProjectOverride {
root: Some("docs".into()),
..Default::default()
},
),
(
"node:app".to_string(),
WorkspaceProjectOverride {
root: Some("apps/web".into()),
metadata: Some(BTreeMap::from([(
"kind".to_string(),
"frontend".to_string(),
)])),
depends_add: BTreeSet::from([
"cargo:core".to_string(),
"custom:docs".to_string(),
]),
depends_remove: BTreeSet::from(["node:old".to_string()]),
..Default::default()
},
),
(
"node:lib".to_string(),
WorkspaceProjectOverride {
remove: true,
..Default::default()
},
),
]);
let graph = WorkspaceProjectGraph::discover_all(&[&node, &cargo], Path::new("/workspace"))
.unwrap()
.with_overrides(&overrides)
.unwrap();
let app = graph.get(&ProjectId::new("node", "app").unwrap()).unwrap();
assert!(graph.get(&lib_id).is_none());
assert_eq!(app.root, Path::new("apps/web"));
assert!(app.tasks.is_empty());
assert_eq!(
app.metadata,
BTreeMap::from([("kind".to_string(), "frontend".to_string())])
);
assert_eq!(
app.dependencies,
BTreeSet::from([
ProjectId::new("cargo", "core").unwrap(),
ProjectId::new("custom", "docs").unwrap(),
])
);
}
#[test]
fn overrides_can_replace_dependencies() {
let mut app = project("node", "app", "app");
app.dependencies
.insert(ProjectId::new("node", "inferred").unwrap());
let provider = TestProvider {
id: "node",
projects: vec![
app,
project("node", "inferred", "inferred"),
project("node", "explicit", "explicit"),
],
};
let overrides = BTreeMap::from([(
"node:app".to_string(),
WorkspaceProjectOverride {
depends: Some(BTreeSet::from(["node:explicit".to_string()])),
..Default::default()
},
)]);
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace"))
.unwrap()
.with_overrides(&overrides)
.unwrap();
assert_eq!(
graph
.get(&ProjectId::new("node", "app").unwrap())
.unwrap()
.dependencies,
BTreeSet::from([ProjectId::new("node", "explicit").unwrap()])
);
}
#[test]
fn depends_add_preserves_existing_provider_provenance() {
let dependency_id = ProjectId::new("node", "lib").unwrap();
let mut app = project("node", "app", "app");
app.dependencies.insert(dependency_id.clone());
let provider = TestProvider {
id: "node",
projects: vec![app, project("node", "lib", "lib")],
};
let overrides = BTreeMap::from([(
"node:app".to_string(),
WorkspaceProjectOverride {
depends_add: BTreeSet::from(["node:lib".to_string()]),
..Default::default()
},
)]);
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace"))
.unwrap()
.with_overrides(&overrides)
.unwrap();
let app = graph.get(&ProjectId::new("node", "app").unwrap()).unwrap();
assert_eq!(
app.dependency_provenance[&dependency_id]
.provider
.as_deref(),
Some("node")
);
}
#[test]
fn root_overrides_clear_stale_sources_and_retain_edge_provider() {
let dependency_id = ProjectId::new("node", "lib").unwrap();
let mut app = project("node", "app", "packages/app");
app.dependencies.insert(dependency_id.clone());
app.provenance.source = Some("packages/app/package.json".into());
app.dependency_provenance.insert(
dependency_id.clone(),
WorkspaceProvenance {
source: Some("packages/app/package.json".into()),
..Default::default()
},
);
let provider = TestProvider {
id: "node",
projects: vec![app, project("node", "lib", "packages/lib")],
};
let overrides = BTreeMap::from([(
"node:app".to_string(),
WorkspaceProjectOverride {
root: Some("overrides/app".into()),
..Default::default()
},
)]);
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace"))
.unwrap()
.with_overrides(&overrides)
.unwrap();
let app = graph.get(&ProjectId::new("node", "app").unwrap()).unwrap();
assert_eq!(app.provenance, WorkspaceProvenance::default());
assert_eq!(
app.dependency_provenance[&dependency_id]
.provider
.as_deref(),
Some("node")
);
assert_eq!(app.dependency_provenance[&dependency_id].source, None);
}
#[test]
fn equal_root_overrides_attribute_project_to_configuration() {
let dependency_id = ProjectId::new("node", "lib").unwrap();
let mut app = project("node", "app", "packages/app");
app.dependencies.insert(dependency_id.clone());
let provider = TestProvider {
id: "node",
projects: vec![app, project("node", "lib", "packages/lib")],
};
let overrides = BTreeMap::from([(
"node:app".to_string(),
WorkspaceProjectOverride {
root: Some("packages/app".into()),
..Default::default()
},
)]);
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace"))
.unwrap()
.with_overrides(&overrides)
.unwrap();
let app = graph.get(&ProjectId::new("node", "app").unwrap()).unwrap();
assert_eq!(app.provenance, WorkspaceProvenance::default());
assert_eq!(
app.dependency_provenance[&dependency_id]
.provider
.as_deref(),
Some("node")
);
}
#[test]
fn metadata_overrides_clear_provider_provenance() {
let provider = TestProvider {
id: "node",
projects: vec![project("node", "app", "packages/app")],
};
let overrides = BTreeMap::from([(
"node:app".to_string(),
WorkspaceProjectOverride {
metadata: Some(BTreeMap::from([("kind".to_string(), "web".to_string())])),
..Default::default()
},
)]);
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace"))
.unwrap()
.with_overrides(&overrides)
.unwrap();
let app = graph.get(&ProjectId::new("node", "app").unwrap()).unwrap();
assert_eq!(app.provenance, WorkspaceProvenance::default());
}
#[test]
fn override_task_inference_errors_are_isolated() {
let overrides = BTreeMap::from([(
"test:app".to_string(),
WorkspaceProjectOverride {
root: Some("new".into()),
..Default::default()
},
)]);
let graph = WorkspaceProjectGraph::discover_all_with_overrides(
&[&FailingTaskProvider],
Path::new("/workspace"),
&overrides,
)
.unwrap();
let project = graph.get(&ProjectId::new("test", "app").unwrap()).unwrap();
assert_eq!(project.root, Path::new("new"));
assert!(project.tasks.is_empty());
}
#[test]
fn overrides_reject_invalid_combinations_and_dangling_edges() {
let graph = WorkspaceProjectGraph::default();
let missing_root = BTreeMap::from([(
"custom:new".to_string(),
WorkspaceProjectOverride::default(),
)]);
assert!(
graph
.clone()
.with_overrides(&missing_root)
.unwrap_err()
.to_string()
.contains("requires an explicit root")
);
let remove_with_root = BTreeMap::from([(
"custom:old".to_string(),
WorkspaceProjectOverride {
remove: true,
root: Some("old".into()),
..Default::default()
},
)]);
assert!(
graph
.clone()
.with_overrides(&remove_with_root)
.unwrap_err()
.to_string()
.contains("cannot define other overrides")
);
let dangling = BTreeMap::from([(
"custom:new".to_string(),
WorkspaceProjectOverride {
root: Some("new".into()),
depends_add: BTreeSet::from(["custom:missing".to_string()]),
..Default::default()
},
)]);
assert!(
graph
.with_overrides(&dangling)
.unwrap_err()
.to_string()
.contains("depends on unknown project")
);
}
#[test]
fn configured_discovery_can_repair_dangling_inferred_edges() {
let missing = ProjectId::new("node", "missing").unwrap();
let mut app = project("node", "app", "app");
app.dependencies.insert(missing);
let provider = TestProvider {
id: "node",
projects: vec![app],
};
let overrides = BTreeMap::from([(
"node:app".to_string(),
WorkspaceProjectOverride {
depends_remove: BTreeSet::from(["node:missing".to_string()]),
..Default::default()
},
)]);
assert!(
WorkspaceProjectGraph::discover(&provider, Path::new("/workspace"))
.unwrap_err()
.to_string()
.contains("depends on unknown project")
);
let graph = WorkspaceProjectGraph::discover_all_with_overrides(
&[&provider],
Path::new("/workspace"),
&overrides,
)
.unwrap();
assert!(
graph
.get(&ProjectId::new("node", "app").unwrap())
.unwrap()
.dependencies
.is_empty()
);
}
#[test]
fn discovery_reports_self_cycles_with_override_guidance() {
let app_id = ProjectId::new("node", "app").unwrap();
let mut app = project("node", "app", "app");
app.dependencies.insert(app_id.clone());
let provider = TestProvider {
id: "node",
projects: vec![app],
};
let err = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace")).unwrap_err();
let cycle = err.downcast_ref::<WorkspaceProjectCycleError>().unwrap();
assert_eq!(cycle.path(), &[app_id.clone(), app_id]);
assert_eq!(
err.to_string(),
"workspace project dependency cycle detected: node:app -> node:app; adjust [monorepo.projects.\"node:app\"] depends, depends_add, or depends_remove to break the cycle"
);
}
#[test]
fn cross_provider_cycle_diagnostics_are_order_independent() {
let cargo_id = ProjectId::new("cargo", "core").unwrap();
let node_id = ProjectId::new("node", "app").unwrap();
let mut core = project("cargo", "core", "core");
core.dependencies.insert(node_id.clone());
let cargo = TestProvider {
id: "cargo",
projects: vec![core],
};
let mut app = project("node", "app", "app");
app.dependencies.insert(cargo_id.clone());
let node = TestProvider {
id: "node",
projects: vec![app],
};
let forward =
WorkspaceProjectGraph::discover_all(&[&cargo, &node], Path::new("/workspace"))
.unwrap_err();
let reverse =
WorkspaceProjectGraph::discover_all(&[&node, &cargo], Path::new("/workspace"))
.unwrap_err();
assert_eq!(forward.to_string(), reverse.to_string());
assert_eq!(
forward
.downcast_ref::<WorkspaceProjectCycleError>()
.unwrap()
.path(),
&[cargo_id.clone(), node_id, cargo_id]
);
}
#[test]
fn configured_discovery_can_repair_inferred_cycles() {
let app_id = ProjectId::new("node", "app").unwrap();
let lib_id = ProjectId::new("node", "lib").unwrap();
let mut app = project("node", "app", "app");
app.dependencies.insert(lib_id.clone());
let mut lib = project("node", "lib", "lib");
lib.dependencies.insert(app_id.clone());
let provider = TestProvider {
id: "node",
projects: vec![app, lib],
};
let overrides = BTreeMap::from([(
"node:lib".to_string(),
WorkspaceProjectOverride {
depends_remove: BTreeSet::from(["node:app".to_string()]),
..Default::default()
},
)]);
assert!(
WorkspaceProjectGraph::discover(&provider, Path::new("/workspace"))
.unwrap_err()
.downcast_ref::<WorkspaceProjectCycleError>()
.is_some()
);
let graph = WorkspaceProjectGraph::discover_all_with_overrides(
&[&provider],
Path::new("/workspace"),
&overrides,
)
.unwrap();
assert!(graph.get(&lib_id).unwrap().dependencies.is_empty());
assert_eq!(
graph.get(&app_id).unwrap().dependencies,
BTreeSet::from([lib_id])
);
}
#[test]
fn overrides_reject_new_cycles() {
let provider = TestProvider {
id: "node",
projects: vec![project("node", "app", "app"), project("node", "lib", "lib")],
};
let overrides = BTreeMap::from([
(
"node:app".to_string(),
WorkspaceProjectOverride {
depends_add: BTreeSet::from(["node:lib".to_string()]),
..Default::default()
},
),
(
"node:lib".to_string(),
WorkspaceProjectOverride {
depends_add: BTreeSet::from(["node:app".to_string()]),
..Default::default()
},
),
]);
let err = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace"))
.unwrap()
.with_overrides(&overrides)
.unwrap_err();
assert!(err.downcast_ref::<WorkspaceProjectCycleError>().is_some());
}
#[test]
fn matching_dependencies_traverse_projects_without_the_requested_task() {
let core_id = ProjectId::new("node", "core").unwrap();
let bridge_id = ProjectId::new("node", "bridge").unwrap();
let app_id = ProjectId::new("node", "app").unwrap();
let core = project("node", "core", "core");
let mut bridge = project("node", "bridge", "bridge");
bridge.dependencies.insert(core_id.clone());
let mut app = project("node", "app", "app");
app.dependencies.insert(bridge_id);
let provider = TestProvider {
id: "node",
projects: vec![app, bridge, core],
};
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace")).unwrap();
let projects = graph
.matching_dependency_projects(&app_id, |project| project.id == core_id)
.unwrap();
assert_eq!(
projects
.iter()
.map(|project| project.id.as_str())
.collect::<Vec<_>>(),
vec!["node:core"]
);
}
#[test]
fn affected_projects_include_transitive_cross_provider_dependents() {
let core_id = ProjectId::new("cargo", "core").unwrap();
let left_id = ProjectId::new("node", "left").unwrap();
let right_id = ProjectId::new("node", "right").unwrap();
let app_id = ProjectId::new("node", "app").unwrap();
let cargo = TestProvider {
id: "cargo",
projects: vec![project("cargo", "core", "crates/core")],
};
let mut left = project("node", "left", "packages/left");
left.dependencies.insert(core_id.clone());
let mut right = project("node", "right", "packages/right");
right.dependencies.insert(core_id.clone());
let mut app = project("node", "app", "apps/app");
app.dependencies = BTreeSet::from([left_id.clone(), right_id.clone()]);
let node = TestProvider {
id: "node",
projects: vec![app, left, right, project("node", "unrelated", "other")],
};
let graph =
WorkspaceProjectGraph::discover_all(&[&node, &cargo], Path::new("/workspace")).unwrap();
assert_eq!(
graph.affected_projects([&core_id]).unwrap(),
BTreeSet::from([core_id, left_id, right_id, app_id])
);
}
#[test]
fn affected_projects_only_walk_reverse_dependency_edges() {
let lib_id = ProjectId::new("node", "lib").unwrap();
let app_id = ProjectId::new("node", "app").unwrap();
let lib = project("node", "lib", "lib");
let mut app = project("node", "app", "app");
app.dependencies.insert(lib_id);
let provider = TestProvider {
id: "node",
projects: vec![app, lib],
};
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace")).unwrap();
assert_eq!(
graph.affected_projects([&app_id]).unwrap(),
BTreeSet::from([app_id])
);
}
#[test]
fn affected_project_reasons_distinguish_changes_and_dependents() {
let lib_id = ProjectId::new("node", "lib").unwrap();
let app_id = ProjectId::new("node", "app").unwrap();
let lib = project("node", "lib", "packages/lib");
let mut app = project("node", "app", "packages/app");
app.dependencies.insert(lib_id.clone());
let provider = TestProvider {
id: "node",
projects: vec![app, lib],
};
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace")).unwrap();
let affected = graph
.affected_projects_for_changes(
Path::new("/workspace"),
["packages/lib/src/lib.rs"],
&[],
&BTreeMap::new(),
)
.unwrap();
let reasons = affected
.projects()
.map(|(id, reasons)| (id.clone(), reasons.clone()))
.collect::<BTreeMap<_, _>>();
assert_eq!(
reasons[&lib_id],
BTreeSet::from([AffectedProjectReason::ChangedPath {
path: PathBuf::from("packages/lib/src/lib.rs"),
}])
);
assert_eq!(
reasons[&app_id],
BTreeSet::from([AffectedProjectReason::Dependent { dependency: lib_id }])
);
}
#[test]
fn affected_project_reasons_retain_every_dependency_cause() {
let core_id = ProjectId::new("node", "core").unwrap();
let left_id = ProjectId::new("node", "left").unwrap();
let right_id = ProjectId::new("node", "right").unwrap();
let app_id = ProjectId::new("node", "app").unwrap();
let core = project("node", "core", "packages/core");
let mut left = project("node", "left", "packages/left");
left.dependencies.insert(core_id.clone());
let mut right = project("node", "right", "packages/right");
right.dependencies.insert(core_id.clone());
let mut app = project("node", "app", "apps/app");
app.dependencies = BTreeSet::from([left_id.clone(), right_id.clone()]);
let provider = TestProvider {
id: "node",
projects: vec![app, core, left, right],
};
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace")).unwrap();
let affected = graph
.affected_projects_for_changes(
Path::new("/workspace"),
["packages/core/src/lib.rs"],
&[],
&BTreeMap::new(),
)
.unwrap();
let app_reasons = affected
.projects()
.find_map(|(id, reasons)| (id == &app_id).then_some(reasons))
.unwrap();
assert_eq!(
app_reasons,
&BTreeSet::from([
AffectedProjectReason::Dependent {
dependency: left_id,
},
AffectedProjectReason::Dependent {
dependency: right_id,
},
])
);
}
#[test]
fn affected_projects_reject_unknown_changed_projects() {
let graph = WorkspaceProjectGraph::default();
let missing = ProjectId::new("node", "missing").unwrap();
let err = graph.affected_projects([&missing]).unwrap_err();
assert_eq!(
err.to_string(),
"unknown workspace project ProjectId(\"node:missing\")"
);
}
#[test]
fn unowned_workspace_paths_affect_every_project() {
let provider = test_provider(vec![
project("test", "app", "apps/app"),
project("test", "lib", "packages/lib"),
project("test", "docs", "docs"),
]);
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace")).unwrap();
let affected = graph
.affected_projects_for_paths(Path::new("/workspace"), ["workspace.toml"], &[])
.unwrap();
assert_eq!(
affected,
BTreeSet::from([
ProjectId::new("test", "app").unwrap(),
ProjectId::new("test", "docs").unwrap(),
ProjectId::new("test", "lib").unwrap(),
])
);
}
#[test]
fn declared_global_inputs_affect_every_project() {
let provider = test_provider(vec![
project("test", "app", "apps/app"),
project("test", "lib", "packages/lib"),
project("test", "docs", "docs"),
]);
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace")).unwrap();
let affected = graph
.affected_projects_for_paths(
Path::new("/workspace"),
["apps/app/toolchain.toml"],
&["**/toolchain.toml".to_string()],
)
.unwrap();
assert_eq!(
affected,
graph.projects().map(|project| project.id.clone()).collect()
);
}
#[test]
fn ordinary_owned_paths_affect_only_their_reverse_dependency_closure() {
let lib_id = ProjectId::new("test", "lib").unwrap();
let app_id = ProjectId::new("test", "app").unwrap();
let mut app = project("test", "app", "apps/app");
app.dependencies.insert(lib_id.clone());
let provider = test_provider(vec![
app,
project("test", "lib", "packages/lib"),
project("test", "docs", "docs"),
]);
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace")).unwrap();
let affected = graph
.affected_projects_for_paths(
Path::new("/workspace"),
["packages/lib/src/lib.rs"],
&["**/toolchain.toml".to_string()],
)
.unwrap();
assert_eq!(affected, BTreeSet::from([lib_id, app_id]));
}
#[test]
fn global_input_exclusions_preserve_project_scoping() {
let provider = test_provider(vec![
project("test", "app", "apps/app"),
project("test", "docs", "docs"),
]);
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace")).unwrap();
let affected = graph
.affected_projects_for_paths(
Path::new("/workspace"),
["docs/config.json"],
&["**/*.json".to_string(), "!docs/**".to_string()],
)
.unwrap();
assert_eq!(
affected,
BTreeSet::from([ProjectId::new("test", "docs").unwrap()])
);
}
#[test]
fn matching_dependencies_are_deduplicated_in_deterministic_post_order() {
let core_id = ProjectId::new("node", "core").unwrap();
let left_id = ProjectId::new("node", "left").unwrap();
let right_id = ProjectId::new("node", "right").unwrap();
let app_id = ProjectId::new("node", "app").unwrap();
let core = project("node", "core", "core");
let mut left = project("node", "left", "left");
left.dependencies.insert(core_id.clone());
let mut right = project("node", "right", "right");
right.dependencies.insert(core_id);
let mut app = project("node", "app", "app");
app.dependencies = BTreeSet::from([right_id, left_id]);
let provider = TestProvider {
id: "node",
projects: vec![right, app, core, left],
};
let graph = WorkspaceProjectGraph::discover(&provider, Path::new("/workspace")).unwrap();
let projects = graph
.matching_dependency_projects(&app_id, |_| true)
.unwrap();
assert_eq!(
projects
.iter()
.map(|project| project.id.as_str())
.collect::<Vec<_>>(),
vec!["node:core", "node:left", "node:right"]
);
}
#[test]
fn matching_dependencies_reject_an_unknown_starting_project() {
let graph = WorkspaceProjectGraph::default();
let missing = ProjectId::new("node", "missing").unwrap();
let err = graph
.matching_dependency_projects(&missing, |_| true)
.unwrap_err();
assert_eq!(
err.to_string(),
"unknown workspace project ProjectId(\"node:missing\")"
);
}
}