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///
41/// # Never fails on a bad XDG value
42///
43/// GAP-SG-200: this used to propagate the validation error, and `main` runs it
44/// before dispatching ANY subcommand. So `config set display.tz 0` — which the
45/// registry accepted with exit 0 — bricked every later invocation of the
46/// binary, including the `config unset display.tz` that would have undone it.
47/// The operator was left with no way back through the CLI at all.
48///
49/// Every other XDG reader in the crate already degrades: `retry.rs`,
50/// `tracing_init.rs`, `paths.rs`, `lock.rs`, `i18n/mod.rs` and the rest use
51/// `if let Ok(Some(v))` or `unwrap_or`. [`current_tz`] in this very file does
52/// `unwrap_or(Tz::UTC)`. This function was the lone exception, and being the
53/// exception is what made it a brick.
54///
55/// The flag path keeps its guarantees: clap parses `--tz` into a `Tz` before
56/// this is called, so an invalid flag is still rejected at argument time.
57pub fn init(explicit: Option<Tz>) -> Result<(), AppError> {
58    let fuso = match explicit {
59        Some(tz) => tz,
60        None => resolve_tz_from_xdg().unwrap_or_else(|e| {
61            tracing::warn!(
62                target: "config",
63                key = "display.tz",
64                error = %e.localized_message(),
65                "invalid XDG timezone; falling back to UTC. \
66                 Fix with `config set display.tz <IANA>` or clear it with \
67                 `config unset display.tz`"
68            );
69            Tz::UTC
70        }),
71    };
72    let _ = GLOBAL_TZ.set(fuso);
73    Ok(())
74}
75
76/// Returns the active timezone.
77///
78/// If [`init`] was never called, tries to read the env var; fallback UTC.
79pub fn current_tz() -> Tz {
80    *GLOBAL_TZ.get_or_init(|| resolve_tz_from_xdg().unwrap_or(Tz::UTC))
81}
82
83/// Formats a `DateTime<Utc>` using the global timezone.
84///
85/// Format: `%Y-%m-%dT%H:%M:%S%:z` (e.g. `2026-04-19T10:00:00+00:00` for UTC,
86/// `2026-04-19T07:00:00-03:00` for `America/Sao_Paulo`).
87pub fn format_iso(ts: DateTime<Utc>) -> String {
88    let fuso = current_tz();
89    ts.with_timezone(&fuso)
90        .format("%Y-%m-%dT%H:%M:%S%:z")
91        .to_string()
92}
93
94/// Converts a Unix epoch (seconds) to an ISO 8601 string with the global timezone.
95///
96/// Values outside the representable range return the fallback
97/// `"1970-01-01T00:00:00+00:00"`.
98pub fn epoch_to_iso(epoch: i64) -> String {
99    Utc.timestamp_opt(epoch, 0)
100        .single()
101        .map(format_iso)
102        .unwrap_or_else(|| "1970-01-01T00:00:00+00:00".to_string())
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn utc_default_when_xdg_unset() {
111        // Without display.tz in XDG config, resolver returns UTC.
112        // (Host config may set display.tz; only assert Ok.)
113        let result = resolve_tz_from_xdg();
114        assert!(
115            result.is_ok(),
116            "xdg tz resolve must not error when unset/valid"
117        );
118    }
119
120    #[test]
121    fn epoch_zero_yields_utc_iso() {
122        let result = {
123            let tz = Tz::UTC;
124            Utc.timestamp_opt(0, 0)
125                .single()
126                .map(|dt| {
127                    dt.with_timezone(&tz)
128                        .format("%Y-%m-%dT%H:%M:%S%:z")
129                        .to_string()
130                })
131                .unwrap_or_else(|| "1970-01-01T00:00:00+00:00".to_string())
132        };
133        assert_eq!(result, "1970-01-01T00:00:00+00:00");
134    }
135
136    #[test]
137    fn format_iso_utc_preserves_zero_offset() {
138        let ts = Utc.timestamp_opt(1_705_320_000, 0).single().unwrap();
139        let result = ts
140            .with_timezone(&Tz::UTC)
141            .format("%Y-%m-%dT%H:%M:%S%:z")
142            .to_string();
143        assert_eq!(result, "2024-01-15T12:00:00+00:00");
144    }
145
146    #[test]
147    fn format_iso_sao_paulo_applies_offset() {
148        let ts = Utc.timestamp_opt(1_705_320_000, 0).single().unwrap();
149        let sao_paulo: Tz = "America/Sao_Paulo".parse().unwrap();
150        let result = ts
151            .with_timezone(&sao_paulo)
152            .format("%Y-%m-%dT%H:%M:%S%:z")
153            .to_string();
154        assert!(
155            result.contains("-03:00"),
156            "expected offset -03:00, got: {result}"
157        );
158    }
159
160    #[test]
161    fn invalid_iana_parse_is_validation() {
162        let bad = "Invalid/Nonexistent";
163        let parsed = bad.parse::<Tz>();
164        assert!(parsed.is_err());
165    }
166}