crtx 0.1.0

CLI for the Cortex supervisory memory substrate.
//! Structured JSON output envelope for the `cortex` CLI.
//!
//! Phase 4.A: every operator-visible subcommand can emit a machine-readable
//! envelope when invoked with the global `--json` flag. The envelope carries
//! the canonical command name, the typed [`Exit`] code, a machine-readable
//! `outcome` token, and the command-specific structured `report` payload.
//! Optional fields surface the ADR 0026 policy decision composition and the
//! runtime layer's correlation id when they are emitted.
//!
//! # Truth ceiling guardrail
//!
//! JSON output is a *rendering* of the same decision the human-readable lines
//! describe. A `policy_outcome=Reject` decision MUST NOT be laundered into
//! looking authoritative by the JSON envelope: it is the caller's
//! responsibility to copy ceiling / forbidden-uses fields onto the
//! command-specific report so the same authority surface that the prose
//! output carries is present in the structured payload. See
//! `docs/CODE_COMPLETE_ROADMAP.md` Phase 4 §JSON output ceiling.
//!
//! # JSON mode toggle
//!
//! The flag is parsed once at the top level and stashed in a process-global
//! [`OnceLock`] so subcommands that already have a free-standing `run(...)`
//! signature do not need to thread an extra parameter through every call
//! path. The toggle is one-shot per process — `set_json_mode` panics if
//! called more than once with conflicting values — which lines up with the
//! `cortex` binary's "one CLI invocation per process" model.

use std::sync::OnceLock;

use serde::Serialize;

use crate::exit::Exit;

/// Process-global JSON mode toggle. Set once by `main()` after `clap` parses
/// the top-level flags; read by every subcommand that knows how to emit a
/// structured envelope.
static JSON_MODE: OnceLock<bool> = OnceLock::new();

/// Process-global correlation id stamped at the start of `main()`. Used by
/// envelopes so a JSON consumer can correlate the structured payload with
/// the matching tracing log line.
static CORRELATION_ID: OnceLock<String> = OnceLock::new();

/// Initialise the JSON-mode toggle. Idempotent only when re-set to the same
/// value; the second call is a hard assertion otherwise so accidental
/// reuse of `main` from tests cannot silently flip the rendering mode.
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"
            );
        }
    }
}

/// Whether the active CLI invocation requested structured JSON output.
#[must_use]
pub fn json_enabled() -> bool {
    *JSON_MODE.get().unwrap_or(&false)
}

/// Install the correlation id stamped by `main()` for this process. Safe to
/// call exactly once; a second call with a different value is a hard
/// assertion so the envelope cannot silently drift from the tracing line.
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"
            );
        }
    }
}

/// Active correlation id for the current CLI invocation, if any.
#[must_use]
pub fn correlation_id() -> Option<&'static str> {
    CORRELATION_ID.get().map(String::as_str)
}

/// Machine-readable rendering of a subcommand's outcome.
///
/// The set is small on purpose: downstream tooling greps these tokens to
/// classify a CLI invocation without having to know the full exit-code
/// table. New tokens MUST be added here rather than ad-hoc in command
/// modules.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Outcome {
    /// Command produced its intended artifact; exit 0.
    Ok,
    /// Operator-visible precondition (path, permission, missing input) was
    /// not satisfied. No state was changed.
    PreconditionUnmet,
    /// The CLI invocation itself was malformed (clap-level error).
    Usage,
    /// Per-row chain or audit invariant failed; structurally the file may
    /// still parse.
    IntegrityFailure,
    /// On-disk schema version disagrees with the running binary.
    SchemaMismatch,
    /// Input could not be admitted (signed manifest mismatch, AXIOM
    /// envelope rejected).
    QuarantinedInput,
    /// Hash chain is structurally broken; the file does not parse as a
    /// valid JSONL chain at all.
    ChainCorruption,
    /// Unrecoverable internal error.
    Internal,
}

impl Outcome {
    /// Map the typed [`Exit`] code to a default outcome token. Commands that
    /// need a more specific token (e.g. `precondition_unmet` vs.
    /// `schema_mismatch` when both surface as the same exit) should set
    /// the field explicitly via [`Envelope::with_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,
        }
    }
}

/// Stable JSON envelope emitted by every `--json`-aware subcommand.
///
/// The shape is part of the CLI contract: downstream tooling matches on
/// `command`, `outcome`, and the typed fields of `report`. New fields may be
/// added (backwards-compatible) but existing fields cannot be renamed
/// without coordinating with consumers — exit-code style.
#[derive(Debug, Serialize)]
pub struct Envelope<T: Serialize> {
    /// Canonical command name (e.g. `cortex.audit.verify`,
    /// `cortex.memory.search`).
    pub command: &'static str,
    /// Exit code the process will return. Mirrors [`Exit::code`] so callers
    /// can rely on either channel.
    pub exit_code: i32,
    /// Machine-readable outcome token.
    pub outcome: Outcome,
    /// Command-specific structured payload.
    pub report: T,
    /// ADR 0026 policy decision summary, when the command composes one.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub policy_outcome: Option<serde_json::Value>,
    /// Runtime-layer correlation id, when the command emits one.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub correlation_id: Option<String>,
}

impl<T: Serialize> Envelope<T> {
    /// Build an envelope for `command` with `report` as its payload. The
    /// outcome token defaults to [`Outcome::from_exit`]; override with
    /// [`Self::with_outcome`]. The process-global correlation id is
    /// attached when one was stamped by `main()`.
    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),
        }
    }

    /// Override the outcome token (used when the exit code maps to multiple
    /// semantic outcomes — e.g. `Exit::PreconditionUnmet` may surface as
    /// `precondition_unmet` or `schema_mismatch` depending on the cause).
    #[must_use]
    pub fn with_outcome(mut self, outcome: Outcome) -> Self {
        self.outcome = outcome;
        self
    }

    /// Attach the ADR 0026 policy decision summary.
    #[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
    }

    /// Attach a runtime-layer correlation id.
    #[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
    }
}

/// Serialize `envelope` to pretty JSON on stdout. Returns the original
/// `exit` so call sites can write `return emit(envelope, exit);`. A
/// serialization failure escalates to [`Exit::Internal`] — the JSON
/// rendering is part of the command's contract, so silently degrading to
/// human output would violate the truth-ceiling guarantee operators rely on.
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() {
        // Every Exit variant must round-trip into a stable Outcome so
        // downstream tooling never sees an unmapped value.
        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");
    }
}