Skip to main content

sqlite_graphrag/
tz.rs

1//! Display timezone for `*_iso` fields in JSON output.
2//!
3//! Precedence (highest to lowest priority):
4//! 1. `--tz <IANA>` flag passed on the CLI
5//! 2. XDG setting `display.tz` (`config set display.tz America/Sao_Paulo`)
6//! 3. Fallback UTC
7//!
8//! The timezone is initialized once via [`init`][crate::tz::init] and stored in
9//! `GLOBAL_TZ` (OnceLock). After initialization, [`format_iso`][crate::tz::format_iso] and
10//! [`epoch_to_iso`][crate::tz::epoch_to_iso] convert timestamps applying the chosen timezone.
11
12use 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
20/// Resolves the timezone from XDG setting `display.tz`.
21///
22/// Returns `Tz::UTC` if unset or empty.
23/// Returns a validation error if the value is an invalid IANA name.
24fn resolve_tz_from_xdg() -> Result<Tz, AppError> {
25    match crate::config::get_setting("display.tz") {
26        Ok(Some(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
34/// Initializes the global timezone.
35///
36/// `explicit` — value from the `--tz` CLI flag (already parsed).
37/// If `explicit` is `None`, tries XDG `display.tz`, then UTC.
38///
39/// Subsequent calls are silently ignored (OnceLock semantics).
40/// Returns an error only if `explicit` is `None` and the XDG value is invalid.
41pub fn init(explicit: Option<Tz>) -> Result<(), AppError> {
42    let fuso = match explicit {
43        Some(tz) => tz,
44        None => resolve_tz_from_xdg()?,
45    };
46    let _ = GLOBAL_TZ.set(fuso);
47    Ok(())
48}
49
50/// Returns the active timezone.
51///
52/// If [`init`] was never called, tries to read the env var; fallback UTC.
53pub fn current_tz() -> Tz {
54    *GLOBAL_TZ.get_or_init(|| resolve_tz_from_xdg().unwrap_or(Tz::UTC))
55}
56
57/// Formats a `DateTime<Utc>` using the global timezone.
58///
59/// Format: `%Y-%m-%dT%H:%M:%S%:z` (e.g. `2026-04-19T10:00:00+00:00` for UTC,
60/// `2026-04-19T07:00:00-03:00` for `America/Sao_Paulo`).
61pub 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
68/// Converts a Unix epoch (seconds) to an ISO 8601 string with the global timezone.
69///
70/// Values outside the representable range return the fallback
71/// `"1970-01-01T00:00:00+00:00"`.
72pub 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
83    #[test]
84    fn utc_default_when_xdg_unset() {
85        // Without display.tz in XDG config, resolver returns UTC.
86        // (Host config may set display.tz; only assert Ok.)
87        let result = resolve_tz_from_xdg();
88        assert!(
89            result.is_ok(),
90            "xdg tz resolve must not error when unset/valid"
91        );
92    }
93
94    #[test]
95    fn epoch_zero_yields_utc_iso() {
96        let result = {
97            let tz = Tz::UTC;
98            Utc.timestamp_opt(0, 0)
99                .single()
100                .map(|dt| {
101                    dt.with_timezone(&tz)
102                        .format("%Y-%m-%dT%H:%M:%S%:z")
103                        .to_string()
104                })
105                .unwrap_or_else(|| "1970-01-01T00:00:00+00:00".to_string())
106        };
107        assert_eq!(result, "1970-01-01T00:00:00+00:00");
108    }
109
110    #[test]
111    fn format_iso_utc_preserves_zero_offset() {
112        let ts = Utc.timestamp_opt(1_705_320_000, 0).single().unwrap();
113        let result = ts
114            .with_timezone(&Tz::UTC)
115            .format("%Y-%m-%dT%H:%M:%S%:z")
116            .to_string();
117        assert_eq!(result, "2024-01-15T12:00:00+00:00");
118    }
119
120    #[test]
121    fn format_iso_sao_paulo_applies_offset() {
122        let ts = Utc.timestamp_opt(1_705_320_000, 0).single().unwrap();
123        let sao_paulo: Tz = "America/Sao_Paulo".parse().unwrap();
124        let result = ts
125            .with_timezone(&sao_paulo)
126            .format("%Y-%m-%dT%H:%M:%S%:z")
127            .to_string();
128        assert!(
129            result.contains("-03:00"),
130            "expected offset -03:00, got: {result}"
131        );
132    }
133
134    #[test]
135    fn invalid_iana_parse_is_validation() {
136        let bad = "Invalid/Nonexistent";
137        let parsed = bad.parse::<Tz>();
138        assert!(parsed.is_err());
139    }
140}