use crate::cli::CommonArgs;
use crate::error::{Error, Result};
#[derive(Debug, PartialEq, Eq)]
pub enum ConfigIssue {
FipsPolicyConflict { explicit: String },
SignerWithoutAnchor,
JobsZero,
JobsExceedsCpus { requested: usize, available: usize },
}
impl ConfigIssue {
pub fn is_error(&self) -> bool {
matches!(
self,
ConfigIssue::FipsPolicyConflict { .. } | ConfigIssue::JobsZero
)
}
pub fn describe(&self) -> String {
match self {
ConfigIssue::FipsPolicyConflict { explicit } => {
format!("error: --fips forces --policy=nist but --policy={explicit} was set")
}
ConfigIssue::SignerWithoutAnchor => {
"warning: --signer is set but --anchor is not; the signer key will not be used"
.to_string()
}
ConfigIssue::JobsZero => "error: --jobs must be at least 1".to_string(),
ConfigIssue::JobsExceedsCpus {
requested,
available,
} => {
format!(
"warning: --jobs {requested} exceeds available CPUs ({available}); scheduling overhead may dominate"
)
}
}
}
fn arg(&self) -> &'static str {
match self {
ConfigIssue::FipsPolicyConflict { .. } => "--policy",
ConfigIssue::SignerWithoutAnchor => "--signer",
ConfigIssue::JobsZero | ConfigIssue::JobsExceedsCpus { .. } => "--jobs",
}
}
}
pub fn collect(common: &CommonArgs) -> Vec<ConfigIssue> {
let mut issues = Vec::new();
if common.fips
&& let Some(p) = common.policy.as_deref()
&& p != "nist"
{
issues.push(ConfigIssue::FipsPolicyConflict {
explicit: p.to_string(),
});
}
if common.signer.is_some() && !common.anchor {
issues.push(ConfigIssue::SignerWithoutAnchor);
}
if common.jobs == 0 {
issues.push(ConfigIssue::JobsZero);
} else if let Some(available) = available_cpus()
&& common.jobs > available
{
issues.push(ConfigIssue::JobsExceedsCpus {
requested: common.jobs,
available,
});
}
issues
}
pub fn report(issues: &[ConfigIssue]) -> Result<()> {
for issue in issues {
if !issue.is_error() {
eprintln!("{}", issue.describe());
}
}
let errors: Vec<&ConfigIssue> = issues.iter().filter(|i| i.is_error()).collect();
if errors.is_empty() {
return Ok(());
}
let arg = errors[0].arg();
let reason = errors
.iter()
.map(|e| e.describe())
.collect::<Vec<_>>()
.join("\n ");
Err(Error::InvalidArg { arg, reason })
}
fn available_cpus() -> Option<usize> {
std::thread::available_parallelism().ok().map(|n| n.get())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn base_args() -> CommonArgs {
CommonArgs::for_filter(None)
}
#[test]
fn clean_config_collects_no_issues() {
let c = base_args();
assert!(collect(&c).is_empty());
}
#[test]
fn fips_with_explicit_default_policy_errors() {
let mut c = base_args();
c.fips = true;
c.policy = Some("default".to_string());
let issues = collect(&c);
assert_eq!(
issues,
vec![ConfigIssue::FipsPolicyConflict {
explicit: "default".to_string()
}]
);
assert!(issues[0].is_error());
}
#[test]
fn fips_with_explicit_nist_policy_ok() {
let mut c = base_args();
c.fips = true;
c.policy = Some("nist".to_string());
assert!(collect(&c).is_empty());
}
#[test]
fn fips_with_no_explicit_policy_ok() {
let mut c = base_args();
c.fips = true;
assert!(
collect(&c).is_empty(),
"fips alone is fine; policy defaults to nist later"
);
}
#[test]
fn signer_without_anchor_warns() {
let mut c = base_args();
c.signer = Some(Path::new("k.pem").to_path_buf());
let issues = collect(&c);
assert_eq!(issues, vec![ConfigIssue::SignerWithoutAnchor]);
assert!(!issues[0].is_error());
assert!(report(&issues).is_ok());
}
#[test]
fn signer_with_anchor_ok() {
let mut c = base_args();
c.signer = Some(Path::new("k.pem").to_path_buf());
c.anchor = true;
assert!(collect(&c).is_empty());
}
#[test]
fn jobs_zero_errors() {
let mut c = base_args();
c.jobs = 0;
let issues = collect(&c);
assert_eq!(issues, vec![ConfigIssue::JobsZero]);
assert!(issues[0].is_error());
let err = report(&issues).unwrap_err();
match err {
Error::InvalidArg { arg, reason } => {
assert_eq!(arg, "--jobs");
assert!(reason.contains("at least 1"), "got: {reason}");
}
_ => panic!("expected InvalidArg, got {err:?}"),
}
}
#[test]
fn multiple_errors_listed_in_one_message() {
let mut c = base_args();
c.fips = true;
c.policy = Some("default".to_string());
c.jobs = 0;
let issues = collect(&c);
assert_eq!(issues.len(), 2, "both errors collected, not just the first");
let err = report(&issues).unwrap_err();
match err {
Error::InvalidArg { reason, .. } => {
assert!(reason.contains("--policy"), "missing policy in: {reason}");
assert!(reason.contains("--jobs"), "missing jobs in: {reason}");
}
_ => panic!("expected InvalidArg, got {err:?}"),
}
}
#[test]
fn jobs_above_available_warns() {
let mut c = base_args();
c.jobs = 65535;
let issues = collect(&c);
assert!(
issues
.iter()
.any(|i| matches!(i, ConfigIssue::JobsExceedsCpus { .. })),
"expected JobsExceedsCpus warning, got {issues:?}"
);
assert!(report(&issues).is_ok());
}
#[test]
fn describe_prefixes_match_severity() {
assert!(ConfigIssue::JobsZero.describe().starts_with("error:"));
assert!(
ConfigIssue::SignerWithoutAnchor
.describe()
.starts_with("warning:")
);
assert!(
ConfigIssue::JobsExceedsCpus {
requested: 99,
available: 1
}
.describe()
.starts_with("warning:")
);
}
}