use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProjectKind {
Package,
Workspace,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrateManifest {
pub dir: PathBuf,
pub manifest: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Project {
pub root: PathBuf,
pub manifest: PathBuf,
pub kind: ProjectKind,
pub crates: Vec<CrateManifest>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum DiscoveryError {
NotFound { start: PathBuf },
Read { path: PathBuf, message: String },
Parse { path: PathBuf, message: String },
}
impl fmt::Display for DiscoveryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DiscoveryError::NotFound { start } => {
write!(f, "no Cargo.toml found at or above {}", start.display())
}
DiscoveryError::Read { path, message } => {
write!(f, "failed to read {}: {message}", path.display())
}
DiscoveryError::Parse { path, message } => {
write!(f, "failed to parse {}: {message}", path.display())
}
}
}
}
impl std::error::Error for DiscoveryError {}
pub fn discover(start: &Path) -> Result<Project, DiscoveryError> {
let manifest = find_manifest(start).ok_or_else(|| DiscoveryError::NotFound {
start: start.to_path_buf(),
})?;
let root = manifest
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let value = parse_manifest(&manifest)?;
if let Some(workspace) = value.get("workspace") {
let crates = workspace_members(&root, workspace);
return Ok(Project {
root,
manifest,
kind: ProjectKind::Workspace,
crates,
});
}
let crates = vec![CrateManifest {
dir: root.clone(),
manifest: manifest.clone(),
}];
Ok(Project {
root,
manifest,
kind: ProjectKind::Package,
crates,
})
}
fn find_manifest(start: &Path) -> Option<PathBuf> {
let mut dir = if start.is_file() {
start.parent().map(Path::to_path_buf)
} else {
Some(start.to_path_buf())
};
while let Some(current) = dir {
let candidate = current.join("Cargo.toml");
if candidate.is_file() {
return Some(candidate);
}
dir = current.parent().map(Path::to_path_buf);
}
None
}
fn parse_manifest(path: &Path) -> Result<toml::Value, DiscoveryError> {
let text = std::fs::read_to_string(path).map_err(|e| DiscoveryError::Read {
path: path.to_path_buf(),
message: e.to_string(),
})?;
text.parse::<toml::Value>()
.map_err(|e| DiscoveryError::Parse {
path: path.to_path_buf(),
message: e.to_string(),
})
}
fn workspace_members(root: &Path, workspace: &toml::Value) -> Vec<CrateManifest> {
let members = workspace
.get("members")
.and_then(toml::Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
members
.iter()
.filter_map(toml::Value::as_str)
.map(|member| {
let dir = root.join(member);
let manifest = dir.join("Cargo.toml");
CrateManifest { dir, manifest }
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn fixtures() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
}
#[test]
fn single_package_finds_one_crate() {
let project = discover(&fixtures().join("single-package")).expect("discover");
assert_eq!(project.kind, ProjectKind::Package);
assert_eq!(project.crates.len(), 1);
}
#[test]
fn workspace_finds_two_members() {
let project = discover(&fixtures().join("workspace")).expect("discover");
assert_eq!(project.kind, ProjectKind::Workspace);
assert_eq!(project.crates.len(), 2);
}
#[test]
fn missing_manifest_returns_err() {
let dir = std::env::temp_dir().join(format!(
"cargo-target-gc-no-manifest-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("create temp dir");
let result = discover(&dir);
let _ = std::fs::remove_dir_all(&dir);
assert!(matches!(result, Err(DiscoveryError::NotFound { .. })));
}
#[test]
fn walks_up_from_nested_file() {
let nested = fixtures().join("single-package/src/main.rs");
let project = discover(&nested).expect("discover from nested file");
assert_eq!(project.kind, ProjectKind::Package);
}
}