Skip to main content

sqlite_graphrag/
tz.rs

1//! Fuso horário de exibição para campos `*_iso` do JSON de saída.
2//!
3//! Precedência (do mais para o menos prioritário):
4//! 1. Flag `--tz <IANA>` passada na CLI
5//! 2. Env var `SQLITE_GRAPHRAG_DISPLAY_TZ`
6//! 3. Fallback UTC
7//!
8//! A timezone é inicializada UMA vez via [`init`][crate::tz::init] e armazenada em
9//! `FUSO_GLOBAL` (OnceLock). Após a inicialização, [`formatar_iso`][crate::tz::formatar_iso] e
10//! [`epoch_para_iso`][crate::tz::epoch_para_iso] convertem timestamps aplicando o fuso escolhido.
11
12use crate::errors::AppError;
13use crate::i18n::validacao;
14use chrono::{DateTime, TimeZone, Utc};
15use chrono_tz::Tz;
16use std::sync::OnceLock;
17
18static FUSO_GLOBAL: OnceLock<Tz> = OnceLock::new();
19
20/// Resolve o fuso a partir do env var `SQLITE_GRAPHRAG_DISPLAY_TZ`.
21///
22/// Retorna `Tz::UTC` se a variável estiver ausente ou vazia.
23/// Retorna erro de validação se o valor for um nome IANA inválido.
24fn resolver_tz_de_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(validacao::tz_invalido(v.trim()))),
30        _ => Ok(Tz::UTC),
31    }
32}
33
34/// Inicializa o fuso global.
35///
36/// `explicit` — valor vindo da flag `--tz` da CLI (já parseado).
37/// Se `explicit` for `None`, tenta `SQLITE_GRAPHRAG_DISPLAY_TZ`, depois UTC.
38///
39/// Chamadas subsequentes são ignoradas silenciosamente (OnceLock semantics).
40/// Retorna erro apenas se `explicit` for `None` e o env var for inválido.
41pub fn init(explicit: Option<Tz>) -> Result<(), AppError> {
42    let fuso = match explicit {
43        Some(tz) => tz,
44        None => resolver_tz_de_env()?,
45    };
46    let _ = FUSO_GLOBAL.set(fuso);
47    Ok(())
48}
49
50/// Retorna o fuso ativo.
51///
52/// Se [`init`] nunca foi chamado, tenta ler o env var; fallback UTC.
53pub fn fuso_atual() -> Tz {
54    *FUSO_GLOBAL.get_or_init(|| resolver_tz_de_env().unwrap_or(Tz::UTC))
55}
56
57/// Formata um `DateTime<Utc>` usando o fuso global.
58///
59/// Formato: `%Y-%m-%dT%H:%M:%S%:z` (ex: `2026-04-19T10:00:00+00:00` para UTC,
60/// `2026-04-19T07:00:00-03:00` para `America/Sao_Paulo`).
61pub fn formatar_iso(ts: DateTime<Utc>) -> String {
62    let fuso = fuso_atual();
63    ts.with_timezone(&fuso)
64        .format("%Y-%m-%dT%H:%M:%S%:z")
65        .to_string()
66}
67
68/// Converte um Unix epoch (segundos) para string ISO 8601 com fuso global.
69///
70/// Valores fora do intervalo representável retornam o fallback
71/// `"1970-01-01T00:00:00+00:00"`.
72pub fn epoch_para_iso(epoch: i64) -> String {
73    Utc.timestamp_opt(epoch, 0)
74        .single()
75        .map(formatar_iso)
76        .unwrap_or_else(|| "1970-01-01T00:00:00+00:00".to_string())
77}
78
79#[cfg(test)]
80mod testes {
81    use super::*;
82    use serial_test::serial;
83
84    #[test]
85    #[serial]
86    fn utc_default_quando_env_ausente() {
87        // Remove variável para garantir fallback UTC
88        std::env::remove_var("SQLITE_GRAPHRAG_DISPLAY_TZ");
89        let resultado = resolver_tz_de_env().expect("não deve falhar com env ausente");
90        assert_eq!(resultado, Tz::UTC);
91    }
92
93    #[test]
94    #[serial]
95    fn env_valido_aplica_timezone() {
96        std::env::set_var("SQLITE_GRAPHRAG_DISPLAY_TZ", "America/Sao_Paulo");
97        let resultado = resolver_tz_de_env().expect("America/Sao_Paulo é válido");
98        assert_eq!(resultado.name(), "America/Sao_Paulo");
99        std::env::remove_var("SQLITE_GRAPHRAG_DISPLAY_TZ");
100    }
101
102    #[test]
103    #[serial]
104    fn env_invalido_retorna_erro_validation() {
105        std::env::set_var("SQLITE_GRAPHRAG_DISPLAY_TZ", "Invalido/Naoexiste");
106        let resultado = resolver_tz_de_env();
107        assert!(resultado.is_err(), "timezone inválida deve retornar Err");
108        match resultado {
109            Err(AppError::Validation(msg)) => {
110                assert!(
111                    msg.contains("SQLITE_GRAPHRAG_DISPLAY_TZ"),
112                    "mensagem deve citar a env var"
113                );
114                assert!(
115                    msg.contains("Invalido/Naoexiste"),
116                    "mensagem deve citar o valor inválido"
117                );
118            }
119            other => panic!("esperado AppError::Validation, obtido: {other:?}"),
120        }
121        std::env::remove_var("SQLITE_GRAPHRAG_DISPLAY_TZ");
122    }
123
124    #[test]
125    fn epoch_zero_gera_utc_iso() {
126        // Testa epoch_para_iso diretamente sem estado global
127        std::env::remove_var("SQLITE_GRAPHRAG_DISPLAY_TZ");
128        let resultado = {
129            // Aplica UTC diretamente sem usar FUSO_GLOBAL
130            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!(resultado, "1970-01-01T00:00:00+00:00");
141    }
142
143    #[test]
144    fn formatar_iso_utc_preserva_offset_zero() {
145        let ts = Utc.timestamp_opt(1_705_320_000, 0).single().unwrap();
146        // Aplica UTC diretamente
147        let resultado = ts
148            .with_timezone(&Tz::UTC)
149            .format("%Y-%m-%dT%H:%M:%S%:z")
150            .to_string();
151        assert_eq!(resultado, "2024-01-15T12:00:00+00:00");
152    }
153
154    #[test]
155    fn formatar_iso_sao_paulo_aplica_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 resultado = ts
159            .with_timezone(&sao_paulo)
160            .format("%Y-%m-%dT%H:%M:%S%:z")
161            .to_string();
162        // America/Sao_Paulo em janeiro é UTC-3
163        assert!(
164            resultado.contains("-03:00"),
165            "esperado offset -03:00, obtido: {resultado}"
166        );
167    }
168}