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}