use anyhow::{anyhow, Context, Result};
use cargo_util_schemas::manifest::TomlManifest;
use glob::Pattern;
use log::warn;
use std::{
fs,
path::{Path, PathBuf},
};
#[derive(Debug, PartialEq)]
pub enum WorkspaceMemberStatus {
Added(PathBuf),
Excluded(PathBuf),
AlreadyCoveredByGlob(PathBuf),
NoWorkspaceFound,
}
pub fn add_to_workspace(member_path: &Path) -> Result<WorkspaceMemberStatus> {
let Some(mut workspace) = Workspace::find_root(member_path)? else {
return Ok(WorkspaceMemberStatus::NoWorkspaceFound);
};
let relative_member_path = workspace.relative_path_for(member_path)?;
if workspace.is_excluded(&relative_member_path) {
warn!(
"Project `{}` matches `workspace.exclude` in {}; skipping workspace membership.",
relative_member_path,
workspace.cargo_toml_path.display()
);
return Ok(WorkspaceMemberStatus::Excluded(workspace.cargo_toml_path));
}
if workspace.is_covered_by_existing_member(&relative_member_path) {
return Ok(WorkspaceMemberStatus::AlreadyCoveredByGlob(
workspace.cargo_toml_path,
));
}
workspace.append_member(relative_member_path)?;
workspace.save()?;
Ok(WorkspaceMemberStatus::Added(workspace.cargo_toml_path))
}
struct Workspace {
manifest: TomlManifest,
cargo_toml_path: PathBuf,
root_dir: PathBuf,
}
impl Workspace {
fn find_root(member_path: &Path) -> Result<Option<Self>> {
for ancestor in member_path.ancestors().skip(1) {
let cargo_toml_path = ancestor.join("Cargo.toml");
if !cargo_toml_path.exists() {
continue;
}
let content = fs::read_to_string(&cargo_toml_path)?;
let manifest: TomlManifest = toml::from_str(&content)
.with_context(|| format!("Failed to parse {}", cargo_toml_path.display()))?;
if manifest.workspace.is_some() {
return Ok(Some(Self {
manifest,
cargo_toml_path,
root_dir: ancestor.to_path_buf(),
}));
}
}
Ok(None)
}
fn relative_path_for(&self, member_path: &Path) -> Result<String> {
let rel = member_path.strip_prefix(&self.root_dir).with_context(|| {
format!(
"Project path {} is not inside workspace root {}",
member_path.display(),
self.root_dir.display()
)
})?;
Ok(rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/"))
}
fn is_excluded(&self, relative_member_path: &str) -> bool {
self.manifest
.workspace
.as_ref()
.and_then(|ws| ws.exclude.as_ref())
.is_some_and(|exclude| exclude.iter().any(|e| e == relative_member_path))
}
fn is_covered_by_existing_member(&self, relative_member_path: &str) -> bool {
let Some(members) = self
.manifest
.workspace
.as_ref()
.and_then(|ws| ws.members.as_ref())
else {
return false;
};
members.iter().any(|entry| {
if entry == relative_member_path {
return true;
}
Pattern::new(entry)
.map(|p| p.matches(relative_member_path))
.unwrap_or(false)
})
}
fn append_member(&mut self, relative_member_path: String) -> Result<()> {
let workspace = self.manifest.workspace.as_mut().ok_or_else(|| {
anyhow!(
"There is no workspace project at {}",
self.cargo_toml_path.display()
)
})?;
let members = workspace.members.get_or_insert_with(Vec::new);
let was_sorted = members.windows(2).all(|w| w[0] <= w[1]);
members.push(relative_member_path);
if was_sorted {
members.sort();
}
Ok(())
}
fn save(&self) -> Result<()> {
let new_manifest = toml::to_string_pretty(&self.manifest)?;
fs::write(&self.cargo_toml_path, new_manifest)
.with_context(|| format!("Failed to write {}", self.cargo_toml_path.display()))?;
Ok(())
}
}