arcature 2026.2.0

Arcature application framework: a high-level Application facade over the certified Arcature subsystems, with the low-level Axum/Tower escape hatch preserved.
Documentation
//! `CheckResult` and `SystemCheckReport` (AP2.1-7).
//!
//! The output shapes of a system check. A single [`CheckResult`] is what
//! one check returns; a [`SystemCheckReport`] is the aggregated, ordered,
//! JSON-serializable report `arc doctor --checks` (and the MCP
//! `system_checks` tool) emits.
//!
//! # JSON shape (stable)
//!
//! A report serializes to:
//!
//! ```json
//! {
//!   "checks": [
//!     {
//!       "id": "ARC0001",
//!       "category": "framework",
//!       "severity": "warning",
//!       "status": "pass",
//!       "description": "framework version recorded",
//!       "context": "framework 2026.1.0",
//!       "fix_hint": null
//!     }
//!   ]
//! }
//! ```
//!
//! The field names are stable. A field rename is a breaking change to the
//! diagnostic surface and requires a cross-stack protocol version bump
//! plus a change fragment.
//!
//! # No secret in output
//!
//! A check's `context` and `fix_hint` MUST NOT carry a secret. The
//! framework does not double-redact check output (the check is framework/
//! application code, not hostile protocol input); a check author is
//! responsible for redacting any value before placing it in the result.
//! The Inspector redaction layer (`arcature_observe::redact::inspector`)
//! is the foundation the served UI will apply to all displayed values
//! when it lands.

use crate::system_check::category::CheckCategory;
use crate::system_check::id::CheckId;
use crate::system_check::severity::CheckSeverity;

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

/// The outcome of running a single system check.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckStatus {
    /// The check passed; the application is OK on this axis.
    Pass,
    /// The check failed at its declared severity.
    Fail,
    /// The check could not run (e.g. a required subsystem was absent). Not
    /// an error — the operator decides whether a skipped check matters.
    Skip,
}

impl CheckStatus {
    /// The stable lowercase string used in JSON output.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Pass => "pass",
            Self::Fail => "fail",
            Self::Skip => "skip",
        }
    }
}

impl std::fmt::Display for CheckStatus {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// The result of running one [`SystemCheck`](super::check::SystemCheck).
///
/// `context` is a short, human-readable explanation of the outcome (e.g.
/// `"framework 2026.1.0"` or `"DATABASE_URL is not set"`). `fix_hint` is an
/// optional, short suggestion for how to resolve a non-pass result (e.g.
/// `"set DATABASE_URL or run arc db migrate"`). Both are framework /
/// application strings; a check author redacts any secret before placing it
/// here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckResult {
    /// The check's stable ID, echoed so a serialized result is
    /// self-describing.
    pub id: CheckId,
    /// The category the check inspects.
    pub category: CheckCategory,
    /// The severity assigned to a failing result.
    pub severity: CheckSeverity,
    /// The outcome (pass / fail / skip).
    pub status: CheckStatus,
    /// The check's description (static).
    pub description: &'static str,
    /// A short, human-readable explanation of the outcome.
    pub context: String,
    /// An optional fix hint for a non-pass result.
    pub fix_hint: Option<String>,
}

/// A JSON-serializable view of a single [`CheckResult`].
///
/// This is the wire shape `arc doctor --checks --json` and the MCP
/// `system_checks` tool emit. It flattens the typed fields into the stable
/// string form (severity/category/status as lowercase strings, fix_hint as
/// `null` when absent). Behind the `serde` feature, matching the
/// established `Json<T>` / `Page<T>` pattern (A4).
#[cfg(feature = "serde")]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CheckResultJson {
    pub id: String,
    pub category: String,
    pub severity: String,
    pub status: String,
    pub description: String,
    pub context: String,
    pub fix_hint: Option<String>,
}

#[cfg(feature = "serde")]
impl From<&CheckResult> for CheckResultJson {
    fn from(result: &CheckResult) -> Self {
        Self {
            id: result.id.as_str().to_string(),
            category: result.category.as_str().to_string(),
            severity: result.severity.as_str().to_string(),
            status: result.status.as_str().to_string(),
            description: result.description.to_string(),
            context: result.context.clone(),
            fix_hint: result.fix_hint.clone(),
        }
    }
}

/// The aggregated, ordered report of a system-check run.
///
/// Built by the registry's `run` helper (or by an application's own
/// aggregator). Order is contributor-order then declaration-order within a
/// contributor — stable and deterministic. A report NEVER aborts on a
/// single check's failure: a failing check is recorded with `status =
/// Fail`; the report continues so the operator sees the full picture.
#[derive(Debug, Clone, Default)]
pub struct SystemCheckReport {
    pub results: Vec<CheckResult>,
}

impl SystemCheckReport {
    /// Returns `true` iff at least one result has `status = Fail` and
    /// `severity = Error`.
    #[must_use]
    pub fn has_error(&self) -> bool {
        self.results
            .iter()
            .any(|r| r.status == CheckStatus::Fail && r.severity == CheckSeverity::Error)
    }

    /// Returns `true` iff at least one result has `status = Fail` (any
    /// severity).
    #[must_use]
    pub fn has_failure(&self) -> bool {
        self.results.iter().any(|r| r.status == CheckStatus::Fail)
    }

    /// The number of results.
    #[must_use]
    pub fn len(&self) -> usize {
        self.results.len()
    }

    /// Whether the report is empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.results.is_empty()
    }

    /// The JSON view of the report (the stable wire shape). Behind the
    /// `serde` feature.
    #[cfg(feature = "serde")]
    #[must_use]
    pub fn to_json_view(&self) -> SystemCheckReportJson {
        SystemCheckReportJson {
            checks: self.results.iter().map(CheckResultJson::from).collect(),
        }
    }
}

/// The JSON view of a [`SystemCheckReport`]. Behind the `serde` feature.
#[cfg(feature = "serde")]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SystemCheckReportJson {
    pub checks: Vec<CheckResultJson>,
}

#[cfg(test)]
mod tests {
    use super::*;

    fn result(status: CheckStatus, severity: CheckSeverity) -> CheckResult {
        CheckResult {
            id: CheckId::new_unchecked("ARC0001"),
            category: CheckCategory::Framework,
            severity,
            status,
            description: "test",
            context: "ctx".to_string(),
            fix_hint: None,
        }
    }

    #[test]
    fn status_strings_are_stable_lowercase() {
        assert_eq!(CheckStatus::Pass.as_str(), "pass");
        assert_eq!(CheckStatus::Fail.as_str(), "fail");
        assert_eq!(CheckStatus::Skip.as_str(), "skip");
    }

    #[cfg(feature = "serde")]
    #[test]
    fn json_view_flattens_typed_fields_to_strings() {
        let r = result(CheckStatus::Fail, CheckSeverity::Warning);
        let view = CheckResultJson::from(&r);
        assert_eq!(view.id, "ARC0001");
        assert_eq!(view.category, "framework");
        assert_eq!(view.severity, "warning");
        assert_eq!(view.status, "fail");
        assert_eq!(view.description, "test");
        assert_eq!(view.context, "ctx");
        assert_eq!(view.fix_hint, None);
    }

    #[cfg(feature = "serde")]
    #[test]
    fn json_view_serializes_fix_hint_as_null_when_absent() {
        let r = result(CheckStatus::Pass, CheckSeverity::Info);
        let view = CheckResultJson::from(&r);
        let json = serde_json::to_value(&view).expect("serialize");
        assert!(json.get("fix_hint").expect("fix_hint present").is_null());
    }

    #[cfg(feature = "serde")]
    #[test]
    fn json_view_serializes_fix_hint_when_present() {
        let mut r = result(CheckStatus::Fail, CheckSeverity::Error);
        r.fix_hint = Some("set DATABASE_URL".to_string());
        let view = CheckResultJson::from(&r);
        assert_eq!(view.fix_hint.as_deref(), Some("set DATABASE_URL"));
    }

    #[test]
    fn report_has_error_only_when_error_severity_fails() {
        let mut report = SystemCheckReport::default();
        assert!(!report.has_error());
        report
            .results
            .push(result(CheckStatus::Pass, CheckSeverity::Error));
        assert!(!report.has_error());
        report
            .results
            .push(result(CheckStatus::Fail, CheckSeverity::Warning));
        assert!(!report.has_error());
        assert!(report.has_failure());
        report
            .results
            .push(result(CheckStatus::Fail, CheckSeverity::Error));
        assert!(report.has_error());
    }

    #[cfg(feature = "serde")]
    #[test]
    fn report_to_json_view_round_trips() {
        let mut report = SystemCheckReport::default();
        report
            .results
            .push(result(CheckStatus::Pass, CheckSeverity::Info));
        report
            .results
            .push(result(CheckStatus::Skip, CheckSeverity::Info));
        let view = report.to_json_view();
        let json = serde_json::to_string(&view).expect("serialize");
        assert!(json.contains("\"status\":\"pass\""));
        assert!(json.contains("\"status\":\"skip\""));
        let back: SystemCheckReportJson = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(back.checks.len(), 2);
    }

    #[test]
    fn report_len_and_empty() {
        let report = SystemCheckReport::default();
        assert!(report.is_empty());
        assert_eq!(report.len(), 0);
    }
}