Skip to main content

warden/output/
envelope.rs

1//! The versioned `--json` envelope.
2//!
3//! Compatibility contract, and the reason this type is centralized:
4//!
5//! - Fields may be **added** freely. Consumers must tolerate unknown fields.
6//! - Fields may **not** be renamed, retyped, or removed without bumping
7//!   [`crate::store::RECORD_VERSION`].
8//! - `warden_version` tracks the binary; `record_version` tracks the shape of
9//!   the rows and of this envelope. A consumer pins on `record_version`.
10//! - An unbounded end of the reporting period is reported as `null`, never as a
11//!   fabricated date.
12
13use chrono::{DateTime, SecondsFormat, TimeZone, Utc};
14use serde::Serialize;
15
16use crate::cli::TimeWindow;
17use crate::store::RECORD_VERSION;
18
19/// The `period` of a report. `null` on either end means "unbounded".
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct Period {
22    pub from: Option<String>,
23    pub to: Option<String>,
24}
25
26impl Period {
27    /// Build a period from a window, rendering bounds as ISO-8601 UTC.
28    ///
29    /// [`TimeWindow::all`] uses saturating sentinels that are not representable
30    /// as timestamps; those become `null` rather than a fake date.
31    pub fn from_window(window: TimeWindow) -> Self {
32        Self {
33            from: window.from().map(iso8601),
34            to: window.to().map(iso8601),
35        }
36    }
37}
38
39/// The one timestamp rendering warden publishes. Every ISO-8601 string in a
40/// `--json` document — envelope period, note text, row fields — goes through
41/// here, so a consumer never sees two precisions in one response.
42pub fn iso8601(ts: DateTime<Utc>) -> String {
43    ts.to_rfc3339_opts(SecondsFormat::Millis, true)
44}
45
46/// The same rendering from epoch millis, for timestamps that are not already
47/// bounded into a `DateTime`.
48pub fn iso8601_ms(ts_ms: i64) -> Option<String> {
49    Utc.timestamp_millis_opt(ts_ms).single().map(iso8601)
50}
51
52/// The one shape every `--json` response has.
53///
54/// `rows` is `Value` rather than a typed row: the row shape is per report and
55/// may gain fields freely, so the envelope stays report-agnostic.
56#[derive(Debug, Clone, Serialize)]
57pub struct Envelope {
58    pub warden_version: &'static str,
59    pub record_version: u32,
60    pub report: String,
61    pub period: Period,
62    pub rows: Vec<serde_json::Value>,
63    pub notes: Vec<String>,
64}
65
66impl Envelope {
67    pub fn new(
68        report: impl Into<String>,
69        window: TimeWindow,
70        rows: Vec<serde_json::Value>,
71    ) -> Self {
72        Self {
73            warden_version: env!("CARGO_PKG_VERSION"),
74            record_version: RECORD_VERSION,
75            report: report.into(),
76            period: Period::from_window(window),
77            rows,
78            notes: Vec::new(),
79        }
80    }
81
82    /// Attach the caller-supplied notes (e.g. `"cost figures are estimates"`).
83    pub fn with_notes<S: Into<String>>(mut self, notes: impl IntoIterator<Item = S>) -> Self {
84        self.notes = notes.into_iter().map(Into::into).collect();
85        self
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    fn window() -> TimeWindow {
94        TimeWindow::new(1_754_006_400_000, 1_754_611_200_000)
95    }
96
97    #[test]
98    fn envelope_has_the_documented_shape() {
99        let env = Envelope::new(
100            "projects",
101            window(),
102            vec![serde_json::json!({"project": "acme"})],
103        )
104        .with_notes(["cost figures are estimates"]);
105        let v = serde_json::to_value(&env).unwrap();
106
107        assert_eq!(v["warden_version"], env!("CARGO_PKG_VERSION"));
108        assert_eq!(v["record_version"], RECORD_VERSION);
109        assert_eq!(v["report"], "projects");
110        assert_eq!(v["period"]["from"], "2025-08-01T00:00:00.000Z");
111        assert_eq!(v["period"]["to"], "2025-08-08T00:00:00.000Z");
112        assert_eq!(v["rows"][0]["project"], "acme");
113        assert_eq!(v["notes"][0], "cost figures are estimates");
114
115        let mut keys: Vec<&str> = v.as_object().unwrap().keys().map(String::as_str).collect();
116        keys.sort_unstable();
117        assert_eq!(
118            keys,
119            [
120                "notes",
121                "period",
122                "record_version",
123                "report",
124                "rows",
125                "warden_version"
126            ],
127            "the envelope has exactly the six documented fields"
128        );
129
130        // The wire order is the documented one.
131        let wire = serde_json::to_string(&env).unwrap();
132        assert!(wire.starts_with(r#"{"warden_version":"#), "{wire}");
133    }
134
135    #[test]
136    fn unbounded_window_serializes_as_null_not_a_fake_date() {
137        let env: Envelope = Envelope::new("summary", TimeWindow::all(), Vec::new());
138        let v = serde_json::to_value(&env).unwrap();
139        assert!(v["period"]["from"].is_null());
140        assert!(v["period"]["to"].is_null());
141        assert!(v["rows"].as_array().unwrap().is_empty());
142        assert!(v["notes"].as_array().unwrap().is_empty());
143    }
144
145    #[test]
146    fn notes_default_to_empty_rather_than_absent() {
147        let env: Envelope = Envelope::new("models", window(), Vec::new());
148        assert!(env.notes.is_empty());
149    }
150}