Skip to main content

autumn_web/
time_zone.rs

1//! Per-user time zone resolution and locale-aware date/time rendering.
2//!
3//! Autumn exposes a [`TimeZone`] extractor so handlers can render timestamps in the
4//! requesting user's local time instead of always serving UTC. It mirrors the
5//! [`Clock`](crate::time::Clock) extractor pattern — deterministic, injected,
6//! testable — and reuses the existing `chrono-tz` dependency (already required by
7//! the scheduler).
8//!
9//! # Resolution order
10//!
11//! The extractor walks the request in this order, returning the first valid IANA
12//! zone found, or falling back to the configured app default (`UTC` when omitted):
13//!
14//! 1. `UserTimeZone` extension (set by your auth middleware for the logged-in user).
15//! 2. `autumn_time_zone` key in the signed session.
16//! 3. Plain `autumn_time_zone` cookie (unsigned, for apps without sessions).
17//! 4. `?tz=<iana>` query parameter (dev/test convenience).
18//!
19//! The order is configurable via [`TimeZoneConfig::sources`].
20//!
21//! # Quick example
22//!
23//! ```rust,ignore
24//! use autumn_web::prelude::*;
25//! use autumn_web::time_zone::{TimeZone, local_datetime};
26//! use autumn_web::time::Clock;
27//!
28//! #[get("/events")]
29//! async fn index(clock: Clock, tz: TimeZone) -> Markup {
30//!     let now = clock.now();
31//!     html! { p { (local_datetime(now, *tz)) } }
32//! }
33//! ```
34
35use chrono::TimeZone as _;
36use chrono::{DateTime, Utc};
37use chrono_tz::Tz;
38use serde::Deserialize;
39
40// ── Config ────────────────────────────────────────────────────────────────────
41
42/// Source of the time zone in the resolution chain.
43#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum Source {
46    /// `UserTimeZone` extension inserted by the app's auth middleware.
47    User,
48    /// `autumn_time_zone` key in the framework's signed session cookie.
49    Session,
50    /// Plain (unsigned) `autumn_time_zone` cookie.
51    Cookie,
52    /// `?tz=<iana>` query parameter override.
53    Query,
54}
55
56/// Configuration for the time zone subsystem.
57///
58/// Populated from the `[time_zone]` block in `autumn.toml`, or left at
59/// defaults (`UTC`, all sources). Both the table form and the scalar
60/// shorthand are accepted:
61///
62/// ```toml
63/// # Scalar shorthand — sets `identifier`, keeps default sources:
64/// time_zone = "America/New_York"
65///
66/// # Table form — also lets you reorder the resolution sources:
67/// [time_zone]
68/// identifier = "America/New_York"
69/// ```
70#[derive(Debug, Clone)]
71pub struct TimeZoneConfig {
72    /// IANA time zone identifier used when no source resolves.
73    /// Defaults to `"UTC"`.
74    pub identifier: String,
75    /// Ordered list of sources tried during resolution.
76    pub sources: Vec<Source>,
77}
78
79fn default_time_zone_identifier() -> String {
80    "UTC".to_owned()
81}
82
83fn default_time_zone_sources() -> Vec<Source> {
84    vec![Source::User, Source::Session, Source::Cookie, Source::Query]
85}
86
87impl Default for TimeZoneConfig {
88    fn default() -> Self {
89        Self {
90            identifier: default_time_zone_identifier(),
91            sources: default_time_zone_sources(),
92        }
93    }
94}
95
96impl<'de> Deserialize<'de> for TimeZoneConfig {
97    /// Accept either a scalar identifier (`time_zone = "America/New_York"`) or
98    /// a full table (`[time_zone] identifier = "…"`), so the documented
99    /// shorthand in issue #836 loads correctly.
100    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
101    where
102        D: serde::Deserializer<'de>,
103    {
104        #[derive(Deserialize)]
105        #[serde(untagged)]
106        enum Repr {
107            Scalar(String),
108            // NOTE: keep MANUAL_SCHEMA_SECTIONS in config.rs in sync with these
109            // table fields (schema walker can't see untagged variant fields).
110            Table {
111                #[serde(default = "default_time_zone_identifier")]
112                identifier: String,
113                #[serde(default = "default_time_zone_sources")]
114                sources: Vec<Source>,
115            },
116        }
117
118        Ok(match Repr::deserialize(deserializer)? {
119            Repr::Scalar(identifier) => Self {
120                identifier,
121                sources: default_time_zone_sources(),
122            },
123            Repr::Table {
124                identifier,
125                sources,
126            } => Self {
127                identifier,
128                sources,
129            },
130        })
131    }
132}
133
134impl TimeZoneConfig {
135    /// Validate the config, returning an error if the identifier is not a
136    /// recognised IANA zone. Called by
137    /// [`AutumnConfig::validate`](crate::config::AutumnConfig::validate) at startup.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`crate::config::ConfigError::Validation`] when the identifier
142    /// is not a known IANA time zone.
143    pub fn validate(&self) -> Result<(), crate::config::ConfigError> {
144        parse_iana(&self.identifier).ok_or_else(|| {
145            crate::config::ConfigError::Validation(format!(
146                "time_zone identifier `{}` is not a valid IANA time zone",
147                self.identifier
148            ))
149        })?;
150        Ok(())
151    }
152
153    /// Returns the configured default [`Tz`], or `UTC` if the identifier is
154    /// somehow invalid (should not happen after validation).
155    #[must_use]
156    pub fn default_tz(&self) -> Tz {
157        parse_iana(&self.identifier).unwrap_or(Tz::UTC)
158    }
159}
160
161// ── UserTimeZone extension ───────────────────────────────────────────────────
162
163/// Newtype placed into request extensions by the app's auth middleware when
164/// an authenticated user with a known time zone is present.
165///
166/// The [`TimeZone`] extractor reads this as the highest-priority source (when
167/// `Source::User` is in the resolution chain, which it is by default).
168///
169/// ```rust,ignore
170/// // In your auth / current-user middleware:
171/// parts.extensions.insert(UserTimeZone(user.time_zone.parse::<Tz>().unwrap()));
172/// ```
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub struct UserTimeZone(pub Tz);
175
176// ── Session key ───────────────────────────────────────────────────────────────
177
178/// Session key used to persist the chosen time zone in the signed session.
179pub const TIME_ZONE_SESSION_KEY: &str = "autumn_time_zone";
180
181// ── TimeZone extractor ────────────────────────────────────────────────────────
182
183/// Axum extractor that resolves the per-request time zone.
184///
185/// Declare it as a handler parameter to get the zone without any manual
186/// resolution. Compose it with [`Clock`](crate::time::Clock) for fully
187/// deterministic, test-injectable time handling:
188///
189/// ```rust,ignore
190/// use autumn_web::time_zone::TimeZone;
191/// use autumn_web::time::Clock;
192///
193/// async fn handler(clock: Clock, tz: TimeZone) -> String {
194///     let local = tz.convert(clock.now());
195///     local.format("%Y-%m-%d %H:%M %Z").to_string()
196/// }
197/// ```
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub struct TimeZone(pub Tz);
200
201impl TimeZone {
202    /// Construct a `TimeZone` for testing without going through extraction.
203    #[must_use]
204    pub const fn new(tz: Tz) -> Self {
205        Self(tz)
206    }
207
208    /// Returns the wrapped [`Tz`].
209    #[must_use]
210    pub const fn tz(&self) -> Tz {
211        self.0
212    }
213
214    /// Returns the IANA identifier string (e.g. `"America/New_York"`).
215    #[must_use]
216    pub fn iana(&self) -> &'static str {
217        self.0.name()
218    }
219
220    /// Convert a UTC timestamp into this zone's local time.
221    #[must_use]
222    pub fn convert(&self, dt: DateTime<Utc>) -> chrono::DateTime<Tz> {
223        use chrono::TimeZone as _;
224        self.0.from_utc_datetime(&dt.naive_utc())
225    }
226}
227
228impl std::ops::Deref for TimeZone {
229    type Target = Tz;
230    fn deref(&self) -> &Self::Target {
231        &self.0
232    }
233}
234
235impl axum::extract::FromRequestParts<crate::state::AppState> for TimeZone {
236    type Rejection = std::convert::Infallible;
237
238    async fn from_request_parts(
239        parts: &mut axum::http::request::Parts,
240        state: &crate::state::AppState,
241    ) -> Result<Self, Self::Rejection> {
242        let cfg = state.config().time_zone;
243        let sources = cfg.sources.clone();
244        let default_tz = cfg.default_tz();
245
246        for source in &sources {
247            if let Some(tz) = resolve_source(parts, source).await {
248                return Ok(Self(tz));
249            }
250        }
251        Ok(Self(default_tz))
252    }
253}
254
255async fn resolve_source(parts: &axum::http::request::Parts, source: &Source) -> Option<Tz> {
256    match source {
257        Source::User => parts.extensions.get::<UserTimeZone>().map(|utz| utz.0),
258        Source::Session => {
259            let session = parts.extensions.get::<crate::session::Session>().cloned()?;
260            let value: String = session.get(TIME_ZONE_SESSION_KEY).await?;
261            parse_iana(&value)
262        }
263        Source::Cookie => resolve_from_cookie(parts),
264        Source::Query => resolve_from_query(parts),
265    }
266}
267
268fn resolve_from_query(parts: &axum::http::request::Parts) -> Option<Tz> {
269    let query = parts.uri.query()?;
270    for pair in query.split('&') {
271        if let Some(value) = pair.strip_prefix("tz=")
272            && let Some(tz) = parse_iana(&percent_decode(value))
273        {
274            return Some(tz);
275        }
276    }
277    None
278}
279
280fn resolve_from_cookie(parts: &axum::http::request::Parts) -> Option<Tz> {
281    let cookie_header = parts
282        .headers
283        .get(axum::http::header::COOKIE)
284        .and_then(|h| h.to_str().ok())?;
285    for cookie in cookie_header.split(';') {
286        let cookie = cookie.trim();
287        if let Some(value) = cookie.strip_prefix("autumn_time_zone=")
288            && let Some(tz) = parse_iana(&percent_decode(value))
289        {
290            return Some(tz);
291        }
292    }
293    None
294}
295
296/// Resolve `%XX` percent-escapes in a query/cookie value so that
297/// pre-encoded IANA identifiers (e.g. `America%2FNew_York`,
298/// `Etc%2FGMT%2B5`) still parse.
299///
300/// `+` is left literal rather than decoded to a space: IANA identifiers can
301/// contain `+` (e.g. `Etc/GMT+5`) and never contain spaces, so treating `+`
302/// as a literal is the correct choice here.
303fn percent_decode(value: &str) -> std::borrow::Cow<'_, str> {
304    if !value.contains('%') {
305        return std::borrow::Cow::Borrowed(value);
306    }
307    let bytes = value.as_bytes();
308    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
309    let mut i = 0;
310    while i < bytes.len() {
311        if bytes[i] == b'%'
312            && i + 2 < bytes.len()
313            && let Some(byte) = decode_hex_pair(bytes[i + 1], bytes[i + 2])
314        {
315            out.push(byte);
316            i += 3;
317        } else {
318            out.push(bytes[i]);
319            i += 1;
320        }
321    }
322    // Fall back to the original string if decoding produced invalid UTF-8.
323    String::from_utf8(out).map_or_else(
324        |_| std::borrow::Cow::Owned(value.to_owned()),
325        std::borrow::Cow::Owned,
326    )
327}
328
329/// Decode two ASCII hex digits into the byte they represent, or `None` if
330/// either character is not a hex digit.
331fn decode_hex_pair(hi: u8, lo: u8) -> Option<u8> {
332    let hi = (hi as char).to_digit(16)?;
333    let lo = (lo as char).to_digit(16)?;
334    // `hi` and `lo` are each in `0..16`, so the result is always `<= 255`.
335    u8::try_from(hi * 16 + lo).ok()
336}
337
338// ── IANA parsing ──────────────────────────────────────────────────────────────
339
340/// Parse and validate an IANA time zone identifier.
341///
342/// Returns `None` for unknown or malformed identifiers so callers can fall
343/// through to the next resolution source.
344#[must_use]
345pub fn parse_iana(s: &str) -> Option<Tz> {
346    s.trim().parse::<Tz>().ok()
347}
348
349// ── Session & cookie helpers ──────────────────────────────────────────────────
350
351/// Persist a time zone choice into the framework's signed session cookie.
352///
353/// The value is the IANA identifier string (e.g. `"America/New_York"`).
354pub async fn set_time_zone_in_session(session: &crate::session::Session, iana: &str) {
355    session.insert(TIME_ZONE_SESSION_KEY, iana).await;
356}
357
358/// Produce a `Set-Cookie` header value that persists the chosen zone.
359///
360/// The cookie is unsigned — for signed persistence use
361/// [`set_time_zone_in_session`] instead.
362#[must_use]
363pub fn set_time_zone_cookie(iana: &str) -> String {
364    let safe = encode_tz_cookie_value(iana);
365    format!("autumn_time_zone={safe}; Path=/; Max-Age=31536000; SameSite=Lax")
366}
367
368fn encode_tz_cookie_value(value: &str) -> String {
369    let mut out = String::with_capacity(value.len());
370    for b in value.bytes() {
371        if is_tz_cookie_byte(b) {
372            out.push(char::from(b));
373        } else {
374            push_pct_encoded(&mut out, b);
375        }
376    }
377    out
378}
379
380const fn is_tz_cookie_byte(b: u8) -> bool {
381    b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'+' | b'/')
382}
383
384fn push_pct_encoded(out: &mut String, byte: u8) {
385    const HEX: &[u8; 16] = b"0123456789ABCDEF";
386    out.push('%');
387    out.push(char::from(HEX[(byte >> 4) as usize]));
388    out.push(char::from(HEX[(byte & 0x0f) as usize]));
389}
390
391// ── Maud view helpers ─────────────────────────────────────────────────────────
392
393/// A semantic `<time>` element showing the local date and time.
394///
395/// The `datetime` attribute is always UTC RFC3339 for machine readers; the
396/// visible text uses the given zone.
397///
398/// ```rust,ignore
399/// let markup = local_datetime(clock.now(), *tz);
400/// ```
401#[cfg(feature = "maud")]
402#[must_use]
403pub fn local_datetime(dt: DateTime<Utc>, tz: Tz) -> maud::Markup {
404    let local = tz.from_utc_datetime(&dt.naive_utc());
405    let display = local.format("%Y-%m-%d %H:%M %Z").to_string();
406    let rfc = dt.to_rfc3339();
407    maud::html! {
408        time datetime=(rfc) { (display) }
409    }
410}
411
412/// A semantic `<time>` element showing the local date (no time component).
413#[cfg(feature = "maud")]
414#[must_use]
415pub fn local_date(dt: DateTime<Utc>, tz: Tz) -> maud::Markup {
416    let local = tz.from_utc_datetime(&dt.naive_utc());
417    let display = local.format("%Y-%m-%d").to_string();
418    let rfc = dt.to_rfc3339();
419    maud::html! {
420        time datetime=(rfc) { (display) }
421    }
422}
423
424/// A semantic `<time>` element with a human-readable relative time string.
425///
426/// `now` should come from the [`Clock`](crate::time::Clock) extractor so
427/// tests can control it deterministically.
428///
429/// ```rust,ignore
430/// let markup = time_ago(event.created_at, clock.now(), *tz);
431/// ```
432#[cfg(feature = "maud")]
433#[must_use]
434pub fn time_ago(dt: DateTime<Utc>, now: DateTime<Utc>, tz: Tz) -> maud::Markup {
435    let relative = crate::format::relative_time_words(dt, now);
436    let rfc = dt.to_rfc3339();
437    let local = tz.from_utc_datetime(&dt.naive_utc());
438    let display = local.format("%Y-%m-%d %H:%M %Z").to_string();
439    maud::html! {
440        time datetime=(rfc) title=(display) { (relative) }
441    }
442}
443
444// ── Form parsing helpers ──────────────────────────────────────────────────────
445
446/// Error returned by [`parse_local_datetime`].
447#[derive(Debug, thiserror::Error)]
448pub enum TimeZoneError {
449    /// The input string did not match `YYYY-MM-DDTHH:MM`.
450    #[error("invalid datetime-local input `{input}`: expected YYYY-MM-DDTHH:MM")]
451    InvalidFormat {
452        /// The string that failed to parse.
453        input: String,
454    },
455    /// The input represents an ambiguous or non-existent local time (DST gap).
456    #[error("local time `{input}` is ambiguous or non-existent in `{zone}`")]
457    AmbiguousLocalTime {
458        /// Original input string.
459        input: String,
460        /// Zone name for the error message.
461        zone: String,
462    },
463}
464
465/// Parse a browser `datetime-local` input value (`YYYY-MM-DDTHH:MM`) as a
466/// time in `tz` and convert it to UTC.
467///
468/// DST ambiguity is resolved by choosing the **earlier** (pre-transition)
469/// interpretation.
470///
471/// # Errors
472///
473/// Returns [`TimeZoneError::InvalidFormat`] for unparseable input and
474/// [`TimeZoneError::AmbiguousLocalTime`] for a non-existent local time
475/// (e.g. a DST gap when clocks spring forward).
476pub fn parse_local_datetime(input: &str, tz: Tz) -> Result<DateTime<Utc>, TimeZoneError> {
477    use chrono::NaiveDateTime;
478    let naive = NaiveDateTime::parse_from_str(input.trim(), "%Y-%m-%dT%H:%M").map_err(|_| {
479        TimeZoneError::InvalidFormat {
480            input: input.to_owned(),
481        }
482    })?;
483    tz.from_local_datetime(&naive)
484        .earliest()
485        .ok_or_else(|| TimeZoneError::AmbiguousLocalTime {
486            input: input.to_owned(),
487            zone: tz.name().to_owned(),
488        })
489        .map(|dt| dt.with_timezone(&Utc))
490}
491
492/// Format a UTC timestamp as a `datetime-local` input value (`YYYY-MM-DDTHH:MM`)
493/// in the given zone. Suitable for populating an `<input type="datetime-local">`.
494#[must_use]
495pub fn to_local_input_value(dt: DateTime<Utc>, tz: Tz) -> String {
496    let local = tz.from_utc_datetime(&dt.naive_utc());
497    local.format("%Y-%m-%dT%H:%M").to_string()
498}
499
500/// Maud helper that renders a `<input type="datetime-local">` pre-filled with
501/// the UTC value converted to `tz`.
502#[cfg(feature = "maud")]
503#[must_use]
504pub fn datetime_local_input(
505    name: &str,
506    label: &str,
507    dt: Option<DateTime<Utc>>,
508    tz: Tz,
509) -> maud::Markup {
510    let value = dt.map(|d| to_local_input_value(d, tz)).unwrap_or_default();
511    maud::html! {
512        div.field {
513            label for=(name) { (label) }
514            input type="datetime-local" id=(name) name=(name) value=(value);
515        }
516    }
517}
518
519// ── Ambient request zone (task-local) ─────────────────────────────────────────
520
521tokio::task_local! {
522    static AMBIENT_TZ: Tz;
523}
524
525/// Run `fut` with `tz` set as the ambient request-scoped time zone.
526///
527/// Use in mailer templates and job workers to render timestamps in the zone
528/// that was active when the request was handled:
529///
530/// ```rust,ignore
531/// with_request_time_zone(tz.tz(), async move {
532///     mail.deliver_later(&state).await;
533/// })
534/// .await;
535/// ```
536pub async fn with_request_time_zone<F, R>(tz: Tz, fut: F) -> R
537where
538    F: std::future::Future<Output = R>,
539{
540    AMBIENT_TZ.scope(tz, fut).await
541}
542
543/// Read the ambient time zone set by [`with_request_time_zone`], or `UTC` if
544/// none is set.
545#[must_use]
546pub fn ambient_time_zone() -> Tz {
547    AMBIENT_TZ.try_with(|tz| *tz).unwrap_or(Tz::UTC)
548}
549
550// ── Tests ─────────────────────────────────────────────────────────────────────
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use axum::body::Body;
556    use axum::http::Request;
557    use chrono::Timelike;
558
559    fn parts(uri: &str, headers: &[(&str, &str)]) -> axum::http::request::Parts {
560        let mut req = Request::builder().uri(uri);
561        for (k, v) in headers {
562            req = req.header(*k, *v);
563        }
564        let (parts, _) = req.body(Body::empty()).unwrap().into_parts();
565        parts
566    }
567
568    // ── IANA parsing ──────────────────────────────────────────────────────────
569
570    #[test]
571    fn parse_iana_valid_zones() {
572        assert!(parse_iana("UTC").is_some());
573        assert!(parse_iana("America/New_York").is_some());
574        assert!(parse_iana("Asia/Tokyo").is_some());
575        assert!(parse_iana("Europe/London").is_some());
576        assert!(parse_iana("America/Sao_Paulo").is_some());
577    }
578
579    #[test]
580    fn parse_iana_invalid_zones() {
581        assert!(parse_iana("Mars/Phobos").is_none());
582        assert!(parse_iana("").is_none());
583        assert!(parse_iana("garbage").is_none());
584        assert!(parse_iana("Not/A/Zone").is_none());
585    }
586
587    #[test]
588    fn parse_iana_trims_whitespace() {
589        assert!(parse_iana("  UTC  ").is_some());
590        assert!(parse_iana("  America/New_York  ").is_some());
591    }
592
593    // ── Config ────────────────────────────────────────────────────────────────
594
595    #[test]
596    fn config_default_is_utc() {
597        let cfg = TimeZoneConfig::default();
598        assert_eq!(cfg.identifier, "UTC");
599        assert_eq!(cfg.default_tz(), Tz::UTC);
600    }
601
602    #[test]
603    fn config_validate_accepts_valid_identifier() {
604        let cfg = TimeZoneConfig {
605            identifier: "America/New_York".to_owned(),
606            ..Default::default()
607        };
608        assert!(cfg.validate().is_ok());
609    }
610
611    #[test]
612    fn config_validate_rejects_unknown_identifier() {
613        let cfg = TimeZoneConfig {
614            identifier: "Mars/Phobos".to_owned(),
615            ..Default::default()
616        };
617        let err = cfg.validate().unwrap_err();
618        let msg = err.to_string();
619        assert!(
620            msg.contains("Mars/Phobos"),
621            "error should mention the bad identifier: {msg}"
622        );
623    }
624
625    #[test]
626    fn config_default_sources_order() {
627        let cfg = TimeZoneConfig::default();
628        assert_eq!(
629            cfg.sources,
630            vec![Source::User, Source::Session, Source::Cookie, Source::Query]
631        );
632    }
633
634    #[derive(serde::Deserialize)]
635    struct Wrapper {
636        #[serde(default)]
637        time_zone: TimeZoneConfig,
638    }
639
640    #[test]
641    fn config_deserializes_scalar_shorthand() {
642        let w: Wrapper = toml::from_str(r#"time_zone = "America/New_York""#).unwrap();
643        assert_eq!(w.time_zone.identifier, "America/New_York");
644        // Scalar form keeps the default source chain.
645        assert_eq!(w.time_zone.sources, default_time_zone_sources());
646    }
647
648    #[test]
649    fn config_deserializes_table_form() {
650        let w: Wrapper = toml::from_str(
651            r#"
652            [time_zone]
653            identifier = "Asia/Tokyo"
654            sources = ["query", "cookie"]
655            "#,
656        )
657        .unwrap();
658        assert_eq!(w.time_zone.identifier, "Asia/Tokyo");
659        assert_eq!(w.time_zone.sources, vec![Source::Query, Source::Cookie]);
660    }
661
662    #[test]
663    fn config_table_form_defaults_missing_fields() {
664        let w: Wrapper = toml::from_str(
665            r#"
666            [time_zone]
667            identifier = "Europe/London"
668            "#,
669        )
670        .unwrap();
671        assert_eq!(w.time_zone.identifier, "Europe/London");
672        assert_eq!(w.time_zone.sources, default_time_zone_sources());
673    }
674
675    #[test]
676    fn config_absent_uses_default() {
677        let w: Wrapper = toml::from_str("").unwrap();
678        assert_eq!(w.time_zone.identifier, "UTC");
679    }
680
681    // ── Query resolution ──────────────────────────────────────────────────────
682
683    #[test]
684    fn query_param_resolves_valid_zone() {
685        let p = parts("/?tz=Asia/Tokyo", &[]);
686        let result = resolve_from_query(&p);
687        assert_eq!(result, Some(Tz::Asia__Tokyo));
688    }
689
690    #[test]
691    fn query_param_ignores_invalid_zone() {
692        let p = parts("/?tz=Mars/Phobos", &[]);
693        assert!(resolve_from_query(&p).is_none());
694    }
695
696    #[test]
697    fn query_param_absent_returns_none() {
698        let p = parts("/", &[]);
699        assert!(resolve_from_query(&p).is_none());
700    }
701
702    #[test]
703    fn query_param_percent_encoded_slash() {
704        let p = parts("/?tz=America%2FNew_York", &[]);
705        assert_eq!(resolve_from_query(&p), Some(Tz::America__New_York));
706    }
707
708    #[test]
709    fn query_param_percent_encoded_plus() {
710        // Etc/GMT+5 contains a literal '+'; fully percent-encoded form must parse.
711        let p = parts("/?tz=Etc%2FGMT%2B5", &[]);
712        assert_eq!(resolve_from_query(&p), Some(Tz::Etc__GMTPlus5));
713    }
714
715    #[test]
716    fn percent_decode_passthrough_when_no_escapes() {
717        assert_eq!(percent_decode("America/New_York"), "America/New_York");
718        assert!(matches!(
719            percent_decode("UTC"),
720            std::borrow::Cow::Borrowed("UTC")
721        ));
722    }
723
724    #[test]
725    fn percent_decode_resolves_escapes() {
726        assert_eq!(percent_decode("America%2FNew_York"), "America/New_York");
727        assert_eq!(percent_decode("Etc%2FGMT%2B5"), "Etc/GMT+5");
728    }
729
730    #[test]
731    fn percent_decode_leaves_plus_literal() {
732        assert_eq!(percent_decode("Etc/GMT+5"), "Etc/GMT+5");
733    }
734
735    #[test]
736    fn percent_decode_keeps_trailing_partial_escape_literal() {
737        // A stray '%' with too few hex digits is preserved verbatim.
738        assert_eq!(percent_decode("UTC%2"), "UTC%2");
739        assert_eq!(percent_decode("UTC%"), "UTC%");
740    }
741
742    // ── Cookie resolution ─────────────────────────────────────────────────────
743
744    #[test]
745    fn cookie_resolves_valid_zone() {
746        let p = parts("/", &[("Cookie", "autumn_time_zone=America/Chicago")]);
747        let result = resolve_from_cookie(&p);
748        assert_eq!(result, Some(Tz::America__Chicago));
749    }
750
751    #[test]
752    fn cookie_ignores_other_cookies() {
753        let p = parts(
754            "/",
755            &[("Cookie", "session=abc; autumn_time_zone=UTC; other=x")],
756        );
757        let result = resolve_from_cookie(&p);
758        assert_eq!(result, Some(Tz::UTC));
759    }
760
761    #[test]
762    fn cookie_invalid_zone_returns_none() {
763        let p = parts("/", &[("Cookie", "autumn_time_zone=garbage")]);
764        assert!(resolve_from_cookie(&p).is_none());
765    }
766
767    #[test]
768    fn cookie_absent_returns_none() {
769        let p = parts("/", &[]);
770        assert!(resolve_from_cookie(&p).is_none());
771    }
772
773    #[test]
774    fn cookie_percent_encoded_value() {
775        // Frontend libraries often encodeURIComponent cookie values.
776        let p = parts("/", &[("Cookie", "autumn_time_zone=America%2FNew_York")]);
777        assert_eq!(resolve_from_cookie(&p), Some(Tz::America__New_York));
778    }
779
780    // ── Cookie helper ─────────────────────────────────────────────────────────
781
782    #[test]
783    fn set_time_zone_cookie_produces_correct_header() {
784        let header = set_time_zone_cookie("America/New_York");
785        assert!(header.starts_with("autumn_time_zone=America/New_York"));
786        assert!(header.contains("Path=/"));
787        assert!(header.contains("Max-Age=31536000"));
788        assert!(header.contains("SameSite=Lax"));
789    }
790
791    #[test]
792    fn set_time_zone_cookie_encodes_special_chars() {
793        // '+' is safe per our allow-list; space should be encoded
794        let header = set_time_zone_cookie("Etc/UTC");
795        assert!(header.contains("Etc/UTC"));
796    }
797
798    // ── UserTimeZone extractor ────────────────────────────────────────────────
799
800    #[test]
801    fn user_time_zone_newtype_roundtrips() {
802        let utz = UserTimeZone(Tz::Asia__Tokyo);
803        assert_eq!(utz.0, Tz::Asia__Tokyo);
804    }
805
806    // ── TimeZone struct ───────────────────────────────────────────────────────
807
808    #[test]
809    fn time_zone_new_constructor() {
810        let tz = TimeZone::new(Tz::UTC);
811        assert_eq!(tz.tz(), Tz::UTC);
812        assert_eq!(*tz, Tz::UTC);
813    }
814
815    #[test]
816    fn time_zone_iana_returns_name() {
817        let tz = TimeZone::new(Tz::America__New_York);
818        assert_eq!(tz.iana(), "America/New_York");
819    }
820
821    #[test]
822    fn time_zone_convert_uses_given_zone() {
823        use chrono::TimeZone as ChrTz;
824        let utc = chrono::Utc.with_ymd_and_hms(2025, 6, 14, 12, 0, 0).unwrap();
825        let tz = TimeZone::new(Tz::America__New_York);
826        let local = tz.convert(utc);
827        // New York is UTC-4 in June (EDT)
828        assert_eq!(local.hour(), 8);
829    }
830
831    // ── Form parsing ──────────────────────────────────────────────────────────
832
833    #[test]
834    fn parse_local_datetime_tokyo() {
835        // 15:30 in Tokyo (UTC+9) → 06:30 UTC
836        let result = parse_local_datetime("2025-06-14T15:30", Tz::Asia__Tokyo).unwrap();
837        assert_eq!(result.hour(), 6);
838        assert_eq!(result.minute(), 30);
839    }
840
841    #[test]
842    fn parse_local_datetime_new_york_summer() {
843        // 12:00 in New York (UTC-4 in summer) → 16:00 UTC
844        let result = parse_local_datetime("2025-06-14T12:00", Tz::America__New_York).unwrap();
845        assert_eq!(result.hour(), 16);
846        assert_eq!(result.minute(), 0);
847    }
848
849    #[test]
850    fn parse_local_datetime_invalid_format() {
851        let err = parse_local_datetime("not-a-date", Tz::UTC).unwrap_err();
852        assert!(matches!(err, TimeZoneError::InvalidFormat { .. }));
853    }
854
855    #[test]
856    fn to_local_input_value_roundtrip() {
857        let zones = [Tz::UTC, Tz::America__New_York, Tz::Asia__Tokyo];
858        for tz in zones {
859            let original = "2025-06-14T15:30";
860            let utc = parse_local_datetime(original, tz).unwrap();
861            let back = to_local_input_value(utc, tz);
862            assert_eq!(back, original, "roundtrip failed for {}", tz.name());
863        }
864    }
865
866    #[test]
867    fn to_local_input_value_formats_correctly() {
868        use chrono::TimeZone as ChrTz;
869        let utc = chrono::Utc.with_ymd_and_hms(2025, 6, 14, 6, 30, 0).unwrap();
870        assert_eq!(
871            to_local_input_value(utc, Tz::Asia__Tokyo),
872            "2025-06-14T15:30"
873        );
874        assert_eq!(to_local_input_value(utc, Tz::UTC), "2025-06-14T06:30");
875    }
876
877    // ── Maud view helpers ─────────────────────────────────────────────────────
878
879    #[cfg(feature = "maud")]
880    mod maud_tests {
881        use super::*;
882        use chrono::TimeZone as ChrTz;
883
884        #[allow(clippy::many_single_char_names)]
885        fn utc(y: i32, mo: u32, d: u32, h: u32, m: u32, s: u32) -> DateTime<Utc> {
886            chrono::Utc.with_ymd_and_hms(y, mo, d, h, m, s).unwrap()
887        }
888
889        #[test]
890        fn local_datetime_uses_zone() {
891            let dt = utc(2025, 6, 14, 12, 0, 0);
892            let utc_html = local_datetime(dt, Tz::UTC).into_string();
893            let tokyo_html = local_datetime(dt, Tz::Asia__Tokyo).into_string();
894            let ny_html = local_datetime(dt, Tz::America__New_York).into_string();
895            // Each zone yields a different visible time
896            assert!(utc_html.contains("12:00"), "UTC: {utc_html}");
897            assert!(tokyo_html.contains("21:00"), "Tokyo: {tokyo_html}");
898            assert!(ny_html.contains("08:00"), "New York: {ny_html}");
899        }
900
901        #[test]
902        fn local_datetime_datetime_attr_is_utc() {
903            let dt = utc(2025, 6, 14, 12, 0, 0);
904            let html = local_datetime(dt, Tz::Asia__Tokyo).into_string();
905            // The machine-readable datetime attr should include the UTC timestamp
906            assert!(
907                html.contains("2025-06-14T12:00:00"),
908                "datetime attr must be UTC: {html}"
909            );
910        }
911
912        #[test]
913        fn local_date_uses_zone() {
914            // Close to midnight UTC — the date differs by zone
915            let dt = utc(2025, 6, 14, 23, 30, 0); // Jun 14 23:30 UTC = Jun 15 08:30 Tokyo
916            let utc_html = local_date(dt, Tz::UTC).into_string();
917            let tokyo_html = local_date(dt, Tz::Asia__Tokyo).into_string();
918            assert!(utc_html.contains("2025-06-14"), "UTC: {utc_html}");
919            assert!(tokyo_html.contains("2025-06-15"), "Tokyo: {tokyo_html}");
920        }
921
922        #[test]
923        fn time_ago_seconds() {
924            let now = utc(2025, 6, 14, 12, 0, 30);
925            let dt = utc(2025, 6, 14, 12, 0, 0);
926            let html = time_ago(dt, now, Tz::UTC).into_string();
927            assert!(html.contains("seconds ago"), "{html}");
928        }
929
930        #[test]
931        fn time_ago_minutes() {
932            let now = utc(2025, 6, 14, 12, 5, 0);
933            let dt = utc(2025, 6, 14, 12, 0, 0);
934            let html = time_ago(dt, now, Tz::UTC).into_string();
935            assert!(html.contains("minutes ago"), "{html}");
936        }
937
938        #[test]
939        fn time_ago_hours() {
940            let now = utc(2025, 6, 14, 14, 0, 0);
941            let dt = utc(2025, 6, 14, 12, 0, 0);
942            let html = time_ago(dt, now, Tz::UTC).into_string();
943            assert!(html.contains("hours ago"), "{html}");
944        }
945
946        #[test]
947        fn time_ago_days() {
948            let now = utc(2025, 6, 16, 12, 0, 0);
949            let dt = utc(2025, 6, 14, 12, 0, 0);
950            let html = time_ago(dt, now, Tz::UTC).into_string();
951            assert!(html.contains("days ago"), "{html}");
952        }
953
954        #[test]
955        fn time_ago_future_minutes() {
956            let now = utc(2025, 6, 14, 12, 0, 0);
957            let dt = utc(2025, 6, 14, 12, 5, 0);
958            let html = time_ago(dt, now, Tz::UTC).into_string();
959            assert!(html.contains("in "), "{html}");
960            assert!(html.contains("minutes"), "{html}");
961        }
962
963        #[test]
964        fn time_ago_preserves_utc_datetime_attr() {
965            let now = utc(2025, 6, 14, 12, 5, 0);
966            let dt = utc(2025, 6, 14, 12, 0, 0);
967            let html = time_ago(dt, now, Tz::UTC).into_string();
968            assert!(html.contains("datetime="), "{html}");
969        }
970    }
971
972    // ── Ambient TZ ────────────────────────────────────────────────────────────
973
974    #[tokio::test]
975    async fn ambient_time_zone_defaults_to_utc() {
976        assert_eq!(ambient_time_zone(), Tz::UTC);
977    }
978
979    #[tokio::test]
980    async fn with_request_time_zone_sets_ambient() {
981        let result = with_request_time_zone(Tz::Asia__Tokyo, async { ambient_time_zone() }).await;
982        assert_eq!(result, Tz::Asia__Tokyo);
983    }
984
985    #[tokio::test]
986    async fn ambient_returns_utc_outside_scope() {
987        // Inside scope
988        let inside =
989            with_request_time_zone(Tz::America__New_York, async { ambient_time_zone() }).await;
990        // Outside scope
991        let outside = ambient_time_zone();
992        assert_eq!(inside, Tz::America__New_York);
993        assert_eq!(outside, Tz::UTC);
994    }
995}