use std::sync::OnceLock;
use serde::Serialize;
use crate::exit::Exit;
static JSON_MODE: OnceLock<bool> = OnceLock::new();
static CORRELATION_ID: OnceLock<String> = OnceLock::new();
pub(crate) fn set_json_mode(enabled: bool) {
match JSON_MODE.set(enabled) {
Ok(()) => {}
Err(_) => {
let existing = *JSON_MODE.get().expect("OnceLock initialized");
assert_eq!(
existing, enabled,
"JSON mode toggle was already initialised with a different value"
);
}
}
}
#[must_use]
pub fn json_enabled() -> bool {
*JSON_MODE.get().unwrap_or(&false)
}
pub(crate) fn set_correlation_id(id: impl Into<String>) {
let value = id.into();
match CORRELATION_ID.set(value.clone()) {
Ok(()) => {}
Err(_) => {
let existing = CORRELATION_ID.get().expect("OnceLock initialized");
assert_eq!(
existing, &value,
"correlation id was already initialised with a different value"
);
}
}
}
#[must_use]
pub fn correlation_id() -> Option<&'static str> {
CORRELATION_ID.get().map(String::as_str)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Outcome {
Ok,
PreconditionUnmet,
Usage,
IntegrityFailure,
SchemaMismatch,
QuarantinedInput,
ChainCorruption,
Internal,
}
impl Outcome {
#[must_use]
pub const fn from_exit(exit: Exit) -> Self {
match exit {
Exit::Ok => Self::Ok,
Exit::Usage => Self::Usage,
Exit::IntegrityFailure => Self::IntegrityFailure,
Exit::SchemaMismatch => Self::SchemaMismatch,
Exit::QuarantinedInput => Self::QuarantinedInput,
Exit::ChainCorruption => Self::ChainCorruption,
Exit::PreconditionUnmet => Self::PreconditionUnmet,
Exit::Internal => Self::Internal,
}
}
}
#[derive(Debug, Serialize)]
pub struct Envelope<T: Serialize> {
pub command: &'static str,
pub exit_code: i32,
pub outcome: Outcome,
pub report: T,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_outcome: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub correlation_id: Option<String>,
}
impl<T: Serialize> Envelope<T> {
pub fn new(command: &'static str, exit: Exit, report: T) -> Self {
Self {
command,
exit_code: exit.code(),
outcome: Outcome::from_exit(exit),
report,
policy_outcome: None,
correlation_id: correlation_id().map(ToOwned::to_owned),
}
}
#[must_use]
pub fn with_outcome(mut self, outcome: Outcome) -> Self {
self.outcome = outcome;
self
}
#[must_use]
#[allow(dead_code)]
pub fn with_policy_outcome(mut self, policy_outcome: serde_json::Value) -> Self {
self.policy_outcome = Some(policy_outcome);
self
}
#[must_use]
#[allow(dead_code)]
pub fn with_correlation_id(mut self, correlation_id: impl Into<String>) -> Self {
self.correlation_id = Some(correlation_id.into());
self
}
}
pub fn emit<T: Serialize>(envelope: &Envelope<T>, exit: Exit) -> Exit {
match serde_json::to_string_pretty(envelope) {
Ok(text) => {
println!("{text}");
exit
}
Err(err) => {
eprintln!(
"cortex {}: failed to serialize JSON output: {err}",
envelope.command
);
Exit::Internal
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Serialize)]
struct Report {
rows_scanned: usize,
}
#[test]
fn outcome_maps_every_exit_variant() {
assert_eq!(Outcome::from_exit(Exit::Ok), Outcome::Ok);
assert_eq!(Outcome::from_exit(Exit::Usage), Outcome::Usage);
assert_eq!(
Outcome::from_exit(Exit::IntegrityFailure),
Outcome::IntegrityFailure
);
assert_eq!(
Outcome::from_exit(Exit::SchemaMismatch),
Outcome::SchemaMismatch
);
assert_eq!(
Outcome::from_exit(Exit::QuarantinedInput),
Outcome::QuarantinedInput
);
assert_eq!(
Outcome::from_exit(Exit::ChainCorruption),
Outcome::ChainCorruption
);
assert_eq!(
Outcome::from_exit(Exit::PreconditionUnmet),
Outcome::PreconditionUnmet
);
assert_eq!(Outcome::from_exit(Exit::Internal), Outcome::Internal);
}
#[test]
fn envelope_serializes_with_optional_fields() {
let envelope = Envelope::new("cortex.audit.verify", Exit::Ok, Report { rows_scanned: 3 })
.with_correlation_id("cmd_test")
.with_policy_outcome(serde_json::json!({"final_outcome": "Allow"}));
let json = serde_json::to_value(&envelope).unwrap();
assert_eq!(json["command"], "cortex.audit.verify");
assert_eq!(json["exit_code"], 0);
assert_eq!(json["outcome"], "ok");
assert_eq!(json["report"]["rows_scanned"], 3);
assert_eq!(json["policy_outcome"]["final_outcome"], "Allow");
assert_eq!(json["correlation_id"], "cmd_test");
}
#[test]
fn envelope_omits_optional_fields_when_unset() {
let envelope = Envelope::new("cortex.init", Exit::Ok, Report { rows_scanned: 0 });
let json = serde_json::to_value(&envelope).unwrap();
assert!(json.get("policy_outcome").is_none());
assert!(json.get("correlation_id").is_none());
}
#[test]
fn outcome_override_changes_token_without_changing_exit() {
let envelope = Envelope::new(
"cortex.doctor",
Exit::PreconditionUnmet,
Report { rows_scanned: 0 },
)
.with_outcome(Outcome::SchemaMismatch);
let json = serde_json::to_value(&envelope).unwrap();
assert_eq!(json["exit_code"], 7);
assert_eq!(json["outcome"], "schema_mismatch");
}
}