1use crate::errors::AppError;
13use crate::i18n::validation;
14use chrono::{DateTime, TimeZone, Utc};
15use chrono_tz::Tz;
16use std::sync::OnceLock;
17
18static GLOBAL_TZ: OnceLock<Tz> = OnceLock::new();
19
20fn resolve_tz_from_env() -> Result<Tz, AppError> {
25 match std::env::var("SQLITE_GRAPHRAG_DISPLAY_TZ") {
26 Ok(v) if !v.trim().is_empty() => v
27 .trim()
28 .parse::<Tz>()
29 .map_err(|_| AppError::Validation(validation::invalid_tz(v.trim()))),
30 _ => Ok(Tz::UTC),
31 }
32}
33
34pub fn init(explicit: Option<Tz>) -> Result<(), AppError> {
42 let fuso = match explicit {
43 Some(tz) => tz,
44 None => resolve_tz_from_env()?,
45 };
46 let _ = GLOBAL_TZ.set(fuso);
47 Ok(())
48}
49
50pub fn current_tz() -> Tz {
54 *GLOBAL_TZ.get_or_init(|| resolve_tz_from_env().unwrap_or(Tz::UTC))
55}
56
57pub fn format_iso(ts: DateTime<Utc>) -> String {
62 let fuso = current_tz();
63 ts.with_timezone(&fuso)
64 .format("%Y-%m-%dT%H:%M:%S%:z")
65 .to_string()
66}
67
68pub fn epoch_to_iso(epoch: i64) -> String {
73 Utc.timestamp_opt(epoch, 0)
74 .single()
75 .map(format_iso)
76 .unwrap_or_else(|| "1970-01-01T00:00:00+00:00".to_string())
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use serial_test::serial;
83
84 #[test]
85 #[serial]
86 fn utc_default_when_env_missing() {
87 std::env::remove_var("SQLITE_GRAPHRAG_DISPLAY_TZ");
89 let result = resolve_tz_from_env().expect("must not fail with env absent");
90 assert_eq!(result, Tz::UTC);
91 }
92
93 #[test]
94 #[serial]
95 fn env_valid_applies_timezone() {
96 std::env::set_var("SQLITE_GRAPHRAG_DISPLAY_TZ", "America/Sao_Paulo");
97 let result = resolve_tz_from_env().expect("America/Sao_Paulo is valid");
98 assert_eq!(result.name(), "America/Sao_Paulo");
99 std::env::remove_var("SQLITE_GRAPHRAG_DISPLAY_TZ");
100 }
101
102 #[test]
103 #[serial]
104 fn env_invalid_returns_validation_error() {
105 std::env::set_var("SQLITE_GRAPHRAG_DISPLAY_TZ", "Invalid/Nonexistent");
106 let result = resolve_tz_from_env();
107 assert!(result.is_err(), "invalid timezone must return Err");
108 match result {
109 Err(AppError::Validation(msg)) => {
110 assert!(
111 msg.contains("SQLITE_GRAPHRAG_DISPLAY_TZ"),
112 "message must cite the env var"
113 );
114 assert!(
115 msg.contains("Invalid/Nonexistent"),
116 "message must cite the invalid value"
117 );
118 }
119 other => unreachable!("expected AppError::Validation, got: {other:?}"),
120 }
121 std::env::remove_var("SQLITE_GRAPHRAG_DISPLAY_TZ");
122 }
123
124 #[test]
125 fn epoch_zero_yields_utc_iso() {
126 std::env::remove_var("SQLITE_GRAPHRAG_DISPLAY_TZ");
128 let result = {
129 let tz = Tz::UTC;
131 Utc.timestamp_opt(0, 0)
132 .single()
133 .map(|dt| {
134 dt.with_timezone(&tz)
135 .format("%Y-%m-%dT%H:%M:%S%:z")
136 .to_string()
137 })
138 .unwrap_or_else(|| "1970-01-01T00:00:00+00:00".to_string())
139 };
140 assert_eq!(result, "1970-01-01T00:00:00+00:00");
141 }
142
143 #[test]
144 fn format_iso_utc_preserves_zero_offset() {
145 let ts = Utc.timestamp_opt(1_705_320_000, 0).single().unwrap();
146 let result = ts
148 .with_timezone(&Tz::UTC)
149 .format("%Y-%m-%dT%H:%M:%S%:z")
150 .to_string();
151 assert_eq!(result, "2024-01-15T12:00:00+00:00");
152 }
153
154 #[test]
155 fn format_iso_sao_paulo_applies_offset() {
156 let ts = Utc.timestamp_opt(1_705_320_000, 0).single().unwrap();
157 let sao_paulo: Tz = "America/Sao_Paulo".parse().unwrap();
158 let result = ts
159 .with_timezone(&sao_paulo)
160 .format("%Y-%m-%dT%H:%M:%S%:z")
161 .to_string();
162 assert!(
164 result.contains("-03:00"),
165 "expected offset -03:00, got: {result}"
166 );
167 }
168}