use crate::audit::Audit;
use crate::audits::behavioral::error_probe::{
advertises_json_output, parse_error_envelope, probe_bad_invocation_json,
};
use crate::project::Project;
use crate::types::{AuditGroup, AuditLayer, AuditResult, AuditStatus, Confidence};
const REQUIRED_KEYS: &[&str] = &["error", "kind", "message"];
pub struct JsonErrorsAudit;
impl Audit for JsonErrorsAudit {
fn id(&self) -> &str {
"p2-json-errors"
}
fn label(&self) -> &'static str {
"Errors emit JSON envelope with `error`/`kind`/`message` under `--output json`"
}
fn group(&self) -> AuditGroup {
AuditGroup::P2
}
fn layer(&self) -> AuditLayer {
AuditLayer::Behavioral
}
fn covers(&self) -> &'static [&'static str] {
&["p2-must-json-errors"]
}
fn applicable(&self, project: &Project) -> bool {
project.runner.is_some()
}
fn run(&self, project: &Project) -> anyhow::Result<AuditResult> {
let status = match project.help_output() {
None => AuditStatus::Skip("could not probe --help".into()),
Some(help) if !advertises_json_output(help) => AuditStatus::Skip(
"binary does not advertise `--output json` in --help; \
MUST applies only to CLIs that opt into the JSON contract."
.into(),
),
Some(_) => {
let result = probe_bad_invocation_json(project.runner_ref());
classify_json_error(&result)
}
};
Ok(AuditResult {
id: self.id().to_string(),
label: self.label().into(),
group: self.group(),
layer: self.layer(),
status,
confidence: Confidence::High,
})
}
}
pub(crate) fn classify_json_error(result: &crate::runner::RunResult) -> AuditStatus {
let Some((value, channel)) = parse_error_envelope(result) else {
return AuditStatus::Fail(
"bad invocation under `--output json` produced no parseable JSON \
on stderr or stdout. JSON mode must emit a JSON error envelope, \
not plain text."
.into(),
);
};
let Some(obj) = value.as_object() else {
return AuditStatus::Fail(format!(
"{channel} parsed as JSON but the top-level value is not an object. \
Error envelopes must be `{{...}}` with named fields."
));
};
let missing: Vec<&&str> = REQUIRED_KEYS
.iter()
.filter(|k| !obj.contains_key(**k))
.collect();
if missing.is_empty() {
AuditStatus::Pass
} else {
let missing_list: Vec<String> = missing.iter().map(|k| (**k).to_string()).collect();
AuditStatus::Fail(format!(
"JSON error envelope on {channel} is missing required keys: {}. \
Spec requires at least `error`, `kind`, and `message`.",
missing_list.join(", ")
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runner::{RunResult, RunStatus};
fn fake_result(stderr: &str, stdout: &str) -> RunResult {
RunResult {
exit_code: Some(2),
stdout: stdout.to_string(),
stderr: stderr.to_string(),
status: RunStatus::Ok,
stdout_truncated: false,
stderr_truncated: false,
}
}
#[test]
fn pass_with_all_three_keys_on_stderr() {
let stderr = r#"{"error":"BadFlag","kind":"usage","message":"unknown flag --bad"}"#;
assert_eq!(
classify_json_error(&fake_result(stderr, "")),
AuditStatus::Pass
);
}
#[test]
fn pass_with_extra_keys() {
let stderr =
r#"{"error":"BadFlag","kind":"usage","message":"...","exit_code":2,"docs_url":"..."}"#;
assert_eq!(
classify_json_error(&fake_result(stderr, "")),
AuditStatus::Pass
);
}
#[test]
fn pass_when_envelope_on_stdout() {
let stdout = r#"{"error":"X","kind":"usage","message":"m"}"#;
assert_eq!(
classify_json_error(&fake_result("", stdout)),
AuditStatus::Pass
);
}
#[test]
fn fail_missing_kind() {
let stderr = r#"{"error":"BadFlag","message":"unknown"}"#;
match classify_json_error(&fake_result(stderr, "")) {
AuditStatus::Fail(msg) => {
assert!(msg.contains("kind"));
assert!(msg.contains("missing"));
}
other => panic!("expected Fail, got {other:?}"),
}
}
#[test]
fn fail_plain_text_error() {
let stderr = "error: unknown flag --bad";
match classify_json_error(&fake_result(stderr, "")) {
AuditStatus::Fail(msg) => assert!(msg.contains("no parseable JSON")),
other => panic!("expected Fail, got {other:?}"),
}
}
#[test]
fn fail_when_top_level_is_array() {
let stderr = r#"["error","kind","message"]"#;
match classify_json_error(&fake_result(stderr, "")) {
AuditStatus::Fail(msg) => assert!(msg.contains("not an object")),
other => panic!("expected Fail, got {other:?}"),
}
}
}