use std::path::{Path, PathBuf};
use cargo_metadata::MetadataCommand;
use serde_json::Value;
use crate::error::{
ConflictingCoverageMetadataError, CoverageGateError, InvalidNoCoverableLinesValueError, InvalidThresholdValueError, LoadMetadataError,
ThresholdOutOfRangeError, WorkspaceScopedNoCoverableLinesError,
};
const MIN_LINES_LOWER: f64 = 0.0;
const MIN_LINES_UPPER: f64 = 100.0;
#[derive(Debug, Clone)]
pub(crate) struct Workspace {
pub(crate) members: Vec<Member>,
pub(crate) default_min_lines_percent: Option<f64>,
}
#[derive(Debug, Clone)]
pub(crate) struct Member {
pub(crate) name: String,
pub(crate) manifest_dir: PathBuf,
pub(crate) min_lines_percent: Option<f64>,
pub(crate) expect_no_coverable_lines: bool,
}
impl Workspace {
#[ohno::enrich_err("failed to load cargo workspace metadata")]
pub(crate) fn load(manifest_path: Option<&Path>) -> Result<Self, CoverageGateError> {
let mut cmd = MetadataCommand::new();
cmd.no_deps();
if let Some(path) = manifest_path {
cmd.manifest_path(path);
}
let metadata = cmd.exec().map_err(LoadMetadataError::caused_by)?;
let workspace_default = extract_coverage_gate(&metadata.workspace_metadata, "workspace", Scope::Workspace)?.min_lines_percent;
let mut members: Vec<Member> = metadata
.workspace_packages()
.iter()
.map(|pkg| {
let manifest_dir = pkg
.manifest_path
.parent()
.expect("cargo-metadata always reports a manifest file path with a parent directory")
.as_std_path()
.to_path_buf();
let gate = extract_coverage_gate(&pkg.metadata, &pkg.name, Scope::Package)?;
Ok::<Member, CoverageGateError>(Member {
name: pkg.name.to_string(),
manifest_dir,
min_lines_percent: gate.min_lines_percent,
expect_no_coverable_lines: gate.expect_no_coverable_lines,
})
})
.collect::<Result<Vec<_>, _>>()?;
members.sort_by(|a, b| a.name.cmp(&b.name));
Ok(Self {
members,
default_min_lines_percent: workspace_default,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Scope {
Package,
Workspace,
}
#[derive(Debug, Default, Clone, Copy, PartialEq)]
struct CoverageGateMetadata {
min_lines_percent: Option<f64>,
expect_no_coverable_lines: bool,
}
fn extract_coverage_gate(metadata: &Value, source: &str, scope: Scope) -> Result<CoverageGateMetadata, CoverageGateError> {
let Some(gate) = metadata.get("coverage-gate") else {
return Ok(CoverageGateMetadata::default());
};
let min_lines_percent = extract_min_lines_percent(gate, source)?;
let expect_no_coverable_lines = extract_expect_no_coverable_lines(gate, source, scope)?;
if min_lines_percent.is_some() && expect_no_coverable_lines {
return Err(ConflictingCoverageMetadataError::new(source.to_owned()).into());
}
Ok(CoverageGateMetadata {
min_lines_percent,
expect_no_coverable_lines,
})
}
fn extract_min_lines_percent(gate: &Value, source: &str) -> Result<Option<f64>, CoverageGateError> {
let Some(min) = gate.get("min-lines-percent") else {
return Ok(None);
};
let value = min
.as_f64()
.ok_or_else(|| InvalidThresholdValueError::new(source.to_owned(), min.clone()))?;
if !(MIN_LINES_LOWER..=MIN_LINES_UPPER).contains(&value) {
return Err(ThresholdOutOfRangeError::new(source.to_owned(), value, MIN_LINES_LOWER, MIN_LINES_UPPER).into());
}
Ok(Some(value))
}
fn extract_expect_no_coverable_lines(gate: &Value, source: &str, scope: Scope) -> Result<bool, CoverageGateError> {
let Some(raw) = gate.get("expect-no-coverable-lines") else {
return Ok(false);
};
let value = raw
.as_bool()
.ok_or_else(|| InvalidNoCoverableLinesValueError::new(source.to_owned(), raw.clone()))?;
if value && scope == Scope::Workspace {
return Err(WorkspaceScopedNoCoverableLinesError::new().into());
}
Ok(value)
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use std::fs;
use super::*;
fn write_workspace(dir: &Path, root_body: &str, members: &[(&str, &str)]) {
fs::write(dir.join("Cargo.toml"), root_body).expect("write root Cargo.toml");
for (name, body) in members {
let member_dir = dir.join(name);
fs::create_dir_all(member_dir.join("src")).expect("mkdir member src");
fs::write(member_dir.join("Cargo.toml"), body).expect("write member Cargo.toml");
fs::write(member_dir.join("src/lib.rs"), "// empty\n").expect("write lib.rs");
}
}
const ROOT_NO_DEFAULT: &str = r#"
[workspace]
resolver = "2"
members = ["alpha", "beta", "gamma"]
"#;
const ROOT_WITH_DEFAULT: &str = r#"
[workspace]
resolver = "2"
members = ["alpha", "beta"]
[workspace.metadata.coverage-gate]
min-lines-percent = 80
"#;
fn member(name: &str, min_lines_percent: Option<&str>) -> String {
let extra = min_lines_percent.map_or(String::new(), |m| {
format!("\n[package.metadata.coverage-gate]\nmin-lines-percent = {m}\n")
});
format!(
r#"
[package]
name = "{name}"
version = "0.1.0"
edition = "2021"
{extra}
"#
)
}
#[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")]
#[test]
fn loads_workspace_with_no_metadata_anywhere() {
let tmp = tempfile::tempdir().expect("tempdir");
write_workspace(
tmp.path(),
ROOT_NO_DEFAULT,
&[
("alpha", &member("alpha", None)),
("beta", &member("beta", None)),
("gamma", &member("gamma", None)),
],
);
let ws = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect("workspace load should succeed");
assert!(ws.default_min_lines_percent.is_none());
assert_eq!(ws.members.len(), 3);
let names: Vec<&str> = ws.members.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "beta", "gamma"]);
for m in &ws.members {
assert!(m.min_lines_percent.is_none());
assert!(m.manifest_dir.is_dir());
}
}
#[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")]
#[test]
fn picks_up_workspace_level_default() {
let tmp = tempfile::tempdir().expect("tempdir");
write_workspace(
tmp.path(),
ROOT_WITH_DEFAULT,
&[("alpha", &member("alpha", None)), ("beta", &member("beta", None))],
);
let ws = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect("workspace load should succeed");
assert_eq!(ws.default_min_lines_percent, Some(80.0));
}
#[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")]
#[test]
fn picks_up_per_crate_override() {
let tmp = tempfile::tempdir().expect("tempdir");
write_workspace(
tmp.path(),
ROOT_WITH_DEFAULT,
&[("alpha", &member("alpha", Some("90.5"))), ("beta", &member("beta", Some("0")))],
);
let ws = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect("workspace load should succeed");
let alpha = ws.members.iter().find(|m| m.name == "alpha").expect("alpha");
let beta = ws.members.iter().find(|m| m.name == "beta").expect("beta");
assert_eq!(alpha.min_lines_percent, Some(90.5));
assert_eq!(beta.min_lines_percent, Some(0.0));
assert_eq!(ws.default_min_lines_percent, Some(80.0));
}
#[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")]
#[test]
fn rejects_out_of_range_per_crate_threshold() {
let tmp = tempfile::tempdir().expect("tempdir");
write_workspace(
tmp.path(),
ROOT_NO_DEFAULT,
&[
("alpha", &member("alpha", Some("120"))),
("beta", &member("beta", None)),
("gamma", &member("gamma", None)),
],
);
let err = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect_err("out-of-range value must error");
let rendered = err.to_string();
assert!(rendered.contains("alpha"), "rendered: {rendered}");
assert!(rendered.contains("120"), "rendered: {rendered}");
}
#[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")]
#[test]
fn rejects_negative_workspace_threshold() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = r#"
[workspace]
resolver = "2"
members = ["alpha"]
[workspace.metadata.coverage-gate]
min-lines-percent = -1
"#;
write_workspace(tmp.path(), root, &[("alpha", &member("alpha", None))]);
let err = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect_err("negative workspace value must error");
let rendered = err.to_string();
assert!(rendered.contains("workspace"), "rendered: {rendered}");
assert!(rendered.contains("-1"), "rendered: {rendered}");
}
#[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")]
#[test]
fn rejects_non_numeric_threshold() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = r#"
[workspace]
resolver = "2"
members = ["alpha"]
[workspace.metadata.coverage-gate]
min-lines-percent = "ninety"
"#;
write_workspace(tmp.path(), root, &[("alpha", &member("alpha", None))]);
let err = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect_err("string threshold must error");
assert!(err.to_string().contains("must be a number"));
}
fn member_with_gate(name: &str, gate_body: &str) -> String {
format!(
r#"
[package]
name = "{name}"
version = "0.1.0"
edition = "2021"
[package.metadata.coverage-gate]
{gate_body}
"#
)
}
#[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")]
#[test]
fn picks_up_expect_no_coverable_lines() {
let tmp = tempfile::tempdir().expect("tempdir");
write_workspace(
tmp.path(),
ROOT_NO_DEFAULT,
&[
("alpha", &member_with_gate("alpha", "expect-no-coverable-lines = true")),
("beta", &member_with_gate("beta", "expect-no-coverable-lines = false")),
("gamma", &member("gamma", None)),
],
);
let ws = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect("workspace load should succeed");
let alpha = ws.members.iter().find(|m| m.name == "alpha").expect("alpha");
let beta = ws.members.iter().find(|m| m.name == "beta").expect("beta");
let gamma = ws.members.iter().find(|m| m.name == "gamma").expect("gamma");
assert!(alpha.expect_no_coverable_lines);
assert!(alpha.min_lines_percent.is_none());
assert!(!beta.expect_no_coverable_lines);
assert!(!gamma.expect_no_coverable_lines);
}
#[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")]
#[test]
fn rejects_both_min_lines_and_expect_no_coverable_lines() {
let tmp = tempfile::tempdir().expect("tempdir");
write_workspace(
tmp.path(),
ROOT_NO_DEFAULT,
&[
(
"alpha",
&member_with_gate("alpha", "min-lines-percent = 50\nexpect-no-coverable-lines = true"),
),
("beta", &member("beta", None)),
("gamma", &member("gamma", None)),
],
);
let err = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect_err("conflicting keys must error");
let rendered = err.to_string();
assert!(rendered.contains("alpha"), "rendered: {rendered}");
assert!(rendered.contains("cannot set both"), "rendered: {rendered}");
}
#[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")]
#[test]
fn rejects_workspace_scoped_expect_no_coverable_lines() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = r#"
[workspace]
resolver = "2"
members = ["alpha"]
[workspace.metadata.coverage-gate]
expect-no-coverable-lines = true
"#;
write_workspace(tmp.path(), root, &[("alpha", &member("alpha", None))]);
let err = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect_err("workspace-scoped assertion must error");
let rendered = err.to_string();
assert!(rendered.contains("package-level"), "rendered: {rendered}");
assert!(rendered.contains("expect-no-coverable-lines"), "rendered: {rendered}");
}
#[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")]
#[test]
fn rejects_non_boolean_expect_no_coverable_lines() {
let tmp = tempfile::tempdir().expect("tempdir");
write_workspace(
tmp.path(),
ROOT_NO_DEFAULT,
&[
("alpha", &member_with_gate("alpha", "expect-no-coverable-lines = \"yes\"")),
("beta", &member("beta", None)),
("gamma", &member("gamma", None)),
],
);
let err = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect_err("non-boolean value must error");
let rendered = err.to_string();
assert!(rendered.contains("must be a boolean"), "rendered: {rendered}");
}
#[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")]
#[test]
fn workspace_scoped_expect_no_coverable_lines_false_is_accepted() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = r#"
[workspace]
resolver = "2"
members = ["alpha"]
[workspace.metadata.coverage-gate]
min-lines-percent = 80
expect-no-coverable-lines = false
"#;
write_workspace(tmp.path(), root, &[("alpha", &member("alpha", None))]);
let ws = Workspace::load(Some(&tmp.path().join("Cargo.toml"))).expect("workspace load should succeed");
assert_eq!(ws.default_min_lines_percent, Some(80.0));
}
}