1use chrono::{DateTime, SecondsFormat, Utc};
22use serde::{Deserialize, Serialize};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct TemporalCapture {
28 pub utc: DateTime<Utc>,
29}
30
31impl TemporalCapture {
32 pub fn system() -> Self {
34 Self { utc: Utc::now() }
35 }
36
37 pub fn at(utc: DateTime<Utc>) -> Self {
39 Self { utc }
40 }
41}
42
43#[derive(Debug, Default)]
50pub struct TemporalState {
51 pub capture: Option<TemporalCapture>,
52 pub zones: Vec<String>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct TemporalRecord {
61 pub captured_utc: String,
63 pub tzdb_version: String,
65 pub zones: Vec<String>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct UnknownZone {
73 pub zone: String,
74}
75
76impl std::fmt::Display for UnknownZone {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 write!(
79 f,
80 "declared `now:` zone '{}' is not a known IANA timezone (tzdb {})",
81 self.zone,
82 crate::window::tz_db_version()
83 )
84 }
85}
86
87pub fn render_line(capture: &TemporalCapture, zone: &str) -> Result<String, UnknownZone> {
92 let tz = crate::window::parse_tz(zone).ok_or_else(|| UnknownZone {
93 zone: zone.to_string(),
94 })?;
95 let local = capture.utc.with_timezone(&tz);
96 Ok(format!(
97 "Current datetime: {} ({}; tzdb {}; captured at run start).",
98 local.to_rfc3339_opts(SecondsFormat::Secs, false),
99 zone,
100 crate::window::tz_db_version()
101 ))
102}
103
104pub fn effective_zone<'a>(
107 step_zone: Option<&'a str>,
108 frame_zone: Option<&'a str>,
109) -> Option<&'a str> {
110 step_zone.or(frame_zone)
111}
112
113pub fn compose_effective_system(
119 base: &str,
120 step_zone: Option<&str>,
121 frame_zone: Option<&str>,
122 state: &mut TemporalState,
123) -> Result<String, UnknownZone> {
124 let Some(zone) = effective_zone(step_zone, frame_zone) else {
125 return Ok(base.to_string());
126 };
127 let capture = *state
128 .capture
129 .get_or_insert_with(TemporalCapture::system);
130 let line = render_line(&capture, zone)?;
131 if !state.zones.iter().any(|z| z == zone) {
132 state.zones.push(zone.to_string());
133 }
134 Ok(if base.is_empty() {
135 line
136 } else {
137 format!("{base}\n\n{line}")
138 })
139}
140
141pub fn record_of(state: &TemporalState) -> Option<TemporalRecord> {
144 let capture = state.capture?;
145 if state.zones.is_empty() {
146 return None;
147 }
148 Some(TemporalRecord {
149 captured_utc: capture.utc.to_rfc3339_opts(SecondsFormat::Secs, true),
150 tzdb_version: crate::window::tz_db_version().to_string(),
151 zones: state.zones.clone(),
152 })
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use chrono::TimeZone;
159
160 fn fixed() -> TemporalCapture {
161 TemporalCapture::at(Utc.with_ymd_and_hms(2026, 7, 7, 19, 33, 5).unwrap())
163 }
164
165 #[test]
166 fn render_is_deterministic_and_zone_correct() {
167 let cap = fixed();
168 let l1 = render_line(&cap, "America/Bogota").unwrap();
169 let l2 = render_line(&cap, "America/Bogota").unwrap();
170 assert_eq!(l1, l2, "pure function of (capture, zone, tzdb)");
171 assert!(
172 l1.contains("2026-07-07T14:33:05-05:00"),
173 "Bogotá renders UTC-5: {l1}"
174 );
175 assert!(l1.contains("(America/Bogota; tzdb "));
176 assert!(l1.contains("captured at run start"));
177 }
178
179 #[test]
180 fn render_utc() {
181 let l = render_line(&fixed(), "UTC").unwrap();
182 assert!(l.contains("2026-07-07T19:33:05+00:00"), "{l}");
183 }
184
185 #[test]
186 fn render_is_dst_correct() {
187 let winter = TemporalCapture::at(Utc.with_ymd_and_hms(2026, 1, 7, 19, 33, 5).unwrap());
189 let l = render_line(&winter, "America/New_York").unwrap();
190 assert!(l.contains("14:33:05-05:00"), "EST: {l}");
191 let l = render_line(&fixed(), "America/New_York").unwrap();
193 assert!(l.contains("15:33:05-04:00"), "EDT: {l}");
194 }
195
196 #[test]
197 fn unknown_zone_fails_closed() {
198 let err = render_line(&fixed(), "Fake/Zone").unwrap_err();
201 assert_eq!(err.zone, "Fake/Zone");
202 assert!(err.to_string().contains("not a known IANA timezone"));
203 }
204
205 #[test]
206 fn step_zone_overrides_frame_zone() {
207 assert_eq!(effective_zone(Some("UTC"), Some("America/Bogota")), Some("UTC"));
208 assert_eq!(effective_zone(None, Some("America/Bogota")), Some("America/Bogota"));
209 assert_eq!(effective_zone(None, None), None);
210 }
211
212 #[test]
213 fn compose_injects_once_per_zone_and_shares_one_capture() {
214 let mut state = TemporalState {
215 capture: Some(fixed()),
216 zones: Vec::new(),
217 };
218 let s1 = compose_effective_system("BASE", Some("UTC"), None, &mut state).unwrap();
219 assert!(s1.starts_with("BASE\n\n"), "{s1}");
220 assert!(s1.contains("Current datetime:"));
221 let s2 =
222 compose_effective_system("BASE", None, Some("America/Bogota"), &mut state).unwrap();
223 assert!(s2.contains("14:33:05-05:00"), "same capture, Bogotá zone: {s2}");
224 let _ = compose_effective_system("BASE", Some("UTC"), None, &mut state).unwrap();
226 assert_eq!(state.zones, vec!["UTC".to_string(), "America/Bogota".to_string()]);
227 }
228
229 #[test]
230 fn compose_without_zone_is_identity_and_records_nothing() {
231 let mut state = TemporalState::default();
232 let s = compose_effective_system("BASE", None, None, &mut state).unwrap();
233 assert_eq!(s, "BASE");
234 assert!(state.capture.is_none(), "no capture without a declared zone");
235 assert!(record_of(&state).is_none());
236 }
237
238 #[test]
239 fn record_projects_capture_and_zones() {
240 let mut state = TemporalState {
241 capture: Some(fixed()),
242 zones: Vec::new(),
243 };
244 let _ = compose_effective_system("", Some("America/Bogota"), None, &mut state).unwrap();
245 let rec = record_of(&state).expect("record");
246 assert_eq!(rec.captured_utc, "2026-07-07T19:33:05Z");
247 assert_eq!(rec.zones, vec!["America/Bogota".to_string()]);
248 assert_eq!(rec.tzdb_version, crate::window::tz_db_version());
249 let json = serde_json::to_string(&rec).unwrap();
251 assert!(json.contains("\"captured_utc\""));
252 assert!(json.contains("\"tzdb_version\""));
253 assert!(json.contains("\"zones\""));
254 }
255}