use std::{
io::Read as _,
path::{Path, PathBuf},
};
use anyhow::{Context as _, Result, bail};
#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
pub(super) struct Version(pub(super) [u64; 3]);
impl Version {
pub(super) fn parse(value: &str) -> Result<Self> {
let mut version = [0; 3];
let parts: Vec<_> = value.split('.').collect();
if parts.is_empty() || parts.len() > 3 {
bail!("invalid Rust version {value:?}");
}
for (index, part) in parts.iter().enumerate() {
if part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()) {
bail!("invalid Rust version {value:?}");
}
version[index] = part.parse()?;
}
Ok(Self(version))
}
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.0[0], self.0[1], self.0[2])
}
}
#[derive(Debug, Default)]
pub(super) struct Project {
pub(super) toolchain: Option<PathBuf>,
pub(super) manifest: Option<PathBuf>,
pub(super) minimum: Version,
}
impl Project {
pub(super) fn detect(cwd: &Path) -> Result<Self> {
let mut project = Self::default();
for dir in cwd.ancestors() {
if project.toolchain.is_none() {
for name in ["rust-toolchain", "rust-toolchain.toml"] {
let path = dir.join(name);
if path
.try_exists()
.with_context(|| format!("inspect {}", path.display()))?
{
project.toolchain = Some(path);
break;
}
}
}
if project.manifest.is_none() {
let path = dir.join("Cargo.toml");
if path
.try_exists()
.with_context(|| format!("inspect {}", path.display()))?
{
project.manifest = Some(path);
}
}
}
if let Some(manifest) = &project.manifest {
project.minimum = requirements(manifest)?;
}
Ok(project)
}
pub(super) fn is_rust(&self) -> bool {
self.toolchain.is_some() || self.manifest.is_some()
}
}
fn read_manifest(path: &Path) -> Result<toml::Value> {
const LIMIT: u64 = 1024 * 1024;
let mut text = String::new();
std::fs::File::open(path)
.with_context(|| format!("read {}", path.display()))?
.take(LIMIT + 1)
.read_to_string(&mut text)?;
if text.len() as u64 > LIMIT {
bail!("{} exceeds the 1 MiB manifest limit", path.display());
}
toml::from_str(&text).with_context(|| format!("parse {}", path.display()))
}
fn requirements(manifest: &Path) -> Result<Version> {
let value = read_manifest(manifest)?;
let package = value.get("package");
let workspace = workspace_manifest(manifest, &value)?;
let inherited = workspace
.as_ref()
.and_then(|value| value.get("workspace"))
.and_then(|value| value.get("package"));
let mut minimum = Version::default();
for key in ["rust-version", "edition"] {
let field = package.and_then(|package| package.get(key));
let field = if field
.and_then(|field| field.get("workspace"))
.and_then(toml::Value::as_bool)
== Some(true)
{
Some(
inherited
.and_then(|package| package.get(key))
.with_context(|| {
format!(
"{} inherits {key}, but workspace.package.{key} is missing",
manifest.display()
)
})?,
)
} else if package.is_none() {
inherited.and_then(|package| package.get(key))
} else {
field
};
if let Some(field) = field {
let text = field.as_str().with_context(|| {
format!(
"{}: {key} must be a string or workspace inheritance",
manifest.display()
)
})?;
let version = if key == "rust-version" {
Version::parse(text)?
} else {
match text {
"2015" => Version([1, 0, 0]),
"2018" => Version([1, 31, 0]),
"2021" => Version([1, 56, 0]),
"2024" => Version([1, 85, 0]),
_ => bail!(
"{}: unsupported Rust edition {text:?}; update SCV's project resolver",
manifest.display()
),
}
};
minimum = minimum.max(version);
}
}
Ok(minimum)
}
fn workspace_manifest(manifest: &Path, value: &toml::Value) -> Result<Option<toml::Value>> {
if value.get("workspace").is_some() {
return Ok(Some(value.clone()));
}
let dir = manifest.parent().context("manifest has no parent")?;
if let Some(workspace) = value
.get("package")
.and_then(|package| package.get("workspace"))
{
let path = workspace
.as_str()
.context("package.workspace must be a path")?;
return read_manifest(&dir.join(path).join("Cargo.toml")).map(Some);
}
let inherits = ["rust-version", "edition"].iter().any(|key| {
value
.get("package")
.and_then(|package| package.get(key))
.and_then(|field| field.get("workspace"))
.and_then(toml::Value::as_bool)
== Some(true)
});
if inherits {
for parent in dir.ancestors().skip(1) {
let path = parent.join("Cargo.toml");
if path.try_exists()? {
let value = read_manifest(&path)?;
if value.get("workspace").is_some() {
return Ok(Some(value));
}
}
}
}
Ok(None)
}
#[cfg(test)]
mod tests;