warden/output/
envelope.rs1use chrono::{DateTime, SecondsFormat, TimeZone, Utc};
14use serde::Serialize;
15
16use crate::cli::TimeWindow;
17use crate::store::RECORD_VERSION;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct Period {
22 pub from: Option<String>,
23 pub to: Option<String>,
24}
25
26impl Period {
27 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
39pub fn iso8601(ts: DateTime<Utc>) -> String {
43 ts.to_rfc3339_opts(SecondsFormat::Millis, true)
44}
45
46pub fn iso8601_ms(ts_ms: i64) -> Option<String> {
49 Utc.timestamp_millis_opt(ts_ms).single().map(iso8601)
50}
51
52#[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 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 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}