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 config = state.config_arc();
243        let cfg = &config.time_zone;
244        let sources = cfg.sources.clone();
245        let default_tz = cfg.default_tz();
246
247        for source in &sources {
248            if let Some(tz) = resolve_source(parts, source).await {
249                return Ok(Self(tz));
250            }
251        }
252        Ok(Self(default_tz))
253    }
254}
255
256async fn resolve_source(parts: &axum::http::request::Parts, source: &Source) -> Option<Tz> {
257    match source {
258        Source::User => parts.extensions.get::<UserTimeZone>().map(|utz| utz.0),
259        Source::Session => {
260            let session = parts.extensions.get::<crate::session::Session>().cloned()?;
261            let value: String = session.get(TIME_ZONE_SESSION_KEY).await?;
262            parse_iana(&value)
263        }
264        Source::Cookie => resolve_from_cookie(parts),
265        Source::Query => resolve_from_query(parts),
266    }
267}
268
269fn resolve_from_query(parts: &axum::http::request::Parts) -> Option<Tz> {
270    let query = parts.uri.query()?;
271    for pair in query.split('&') {
272        if let Some(value) = pair.strip_prefix("tz=")
273            && let Some(tz) = parse_iana(&percent_decode(value))
274        {
275            return Some(tz);
276        }
277    }
278    None
279}
280
281fn resolve_from_cookie(parts: &axum::http::request::Parts) -> Option<Tz> {
282    let cookie_header = parts
283        .headers
284        .get(axum::http::header::COOKIE)
285        .and_then(|h| h.to_str().ok())?;
286    for cookie in cookie_header.split(';') {
287        let cookie = cookie.trim();
288        if let Some(value) = cookie.strip_prefix("autumn_time_zone=")
289            && let Some(tz) = parse_iana(&percent_decode(value))
290        {
291            return Some(tz);
292        }
293    }
294    None
295}
296
297/// Resolve `%XX` percent-escapes in a query/cookie value so that
298/// pre-encoded IANA identifiers (e.g. `America%2FNew_York`,
299/// `Etc%2FGMT%2B5`) still parse.
300///
301/// `+` is left literal rather than decoded to a space: IANA identifiers can
302/// contain `+` (e.g. `Etc/GMT+5`) and never contain spaces, so treating `+`
303/// as a literal is the correct choice here.
304fn percent_decode(value: &str) -> std::borrow::Cow<'_, str> {
305    if !value.contains('%') {
306        return std::borrow::Cow::Borrowed(value);
307    }
308    let bytes = value.as_bytes();
309    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
310    let mut i = 0;
311    while i < bytes.len() {
312        if bytes[i] == b'%'
313            && i + 2 < bytes.len()
314            && let Some(byte) = decode_hex_pair(bytes[i + 1], bytes[i + 2])
315        {
316            out.push(byte);
317            i += 3;
318        } else {
319            out.push(bytes[i]);
320            i += 1;
321        }
322    }
323    // Fall back to the original string if decoding produced invalid UTF-8.
324    String::from_utf8(out).map_or_else(
325        |_| std::borrow::Cow::Owned(value.to_owned()),
326        std::borrow::Cow::Owned,
327    )
328}
329
330/// Decode two ASCII hex digits into the byte they represent, or `None` if
331/// either character is not a hex digit.
332fn decode_hex_pair(hi: u8, lo: u8) -> Option<u8> {
333    let hi = (hi as char).to_digit(16)?;
334    let lo = (lo as char).to_digit(16)?;
335    // `hi` and `lo` are each in `0..16`, so the result is always `<= 255`.
336    u8::try_from(hi * 16 + lo).ok()
337}
338
339// ── IANA parsing ──────────────────────────────────────────────────────────────
340
341/// Parse and validate an IANA time zone identifier.
342///
343/// Returns `None` for unknown or malformed identifiers so callers can fall
344/// through to the next resolution source.
345#[must_use]
346pub fn parse_iana(s: &str) -> Option<Tz> {
347    s.trim().parse::<Tz>().ok()
348}
349
350// ── Session & cookie helpers ──────────────────────────────────────────────────
351
352/// Persist a time zone choice into the framework's signed session cookie.
353///
354/// The value is the IANA identifier string (e.g. `"America/New_York"`).
355pub async fn set_time_zone_in_session(session: &crate::session::Session, iana: &str) {
356    session.insert(TIME_ZONE_SESSION_KEY, iana).await;
357}
358
359/// Produce a `Set-Cookie` header value that persists the chosen zone.
360///
361/// The cookie is unsigned — for signed persistence use
362/// [`set_time_zone_in_session`] instead.
363#[must_use]
364pub fn set_time_zone_cookie(iana: &str) -> String {
365    let safe = encode_tz_cookie_value(iana);
366    format!("autumn_time_zone={safe}; Path=/; Max-Age=31536000; SameSite=Lax")
367}
368
369fn encode_tz_cookie_value(value: &str) -> String {
370    let mut out = String::with_capacity(value.len());
371    for b in value.bytes() {
372        if is_tz_cookie_byte(b) {
373            out.push(char::from(b));
374        } else {
375            push_pct_encoded(&mut out, b);
376        }
377    }
378    out
379}
380
381const fn is_tz_cookie_byte(b: u8) -> bool {
382    b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'+' | b'/')
383}
384
385fn push_pct_encoded(out: &mut String, byte: u8) {
386    const HEX: &[u8; 16] = b"0123456789ABCDEF";
387    out.push('%');
388    out.push(char::from(HEX[(byte >> 4) as usize]));
389    out.push(char::from(HEX[(byte & 0x0f) as usize]));
390}
391
392// ── Maud view helpers ─────────────────────────────────────────────────────────
393
394/// A semantic `<time>` element showing the local date and time.
395///
396/// The `datetime` attribute is always UTC RFC3339 for machine readers; the
397/// visible text uses the given zone.
398///
399/// ```rust,ignore
400/// let markup = local_datetime(clock.now(), *tz);
401/// ```
402#[cfg(feature = "maud")]
403#[must_use]
404pub fn local_datetime(dt: DateTime<Utc>, tz: Tz) -> maud::Markup {
405    let local = tz.from_utc_datetime(&dt.naive_utc());
406    let display = local.format("%Y-%m-%d %H:%M %Z").to_string();
407    let rfc = dt.to_rfc3339();
408    maud::html! {
409        time datetime=(rfc) { (display) }
410    }
411}
412
413/// A semantic `<time>` element showing the local date (no time component).
414#[cfg(feature = "maud")]
415#[must_use]
416pub fn local_date(dt: DateTime<Utc>, tz: Tz) -> maud::Markup {
417    let local = tz.from_utc_datetime(&dt.naive_utc());
418    let display = local.format("%Y-%m-%d").to_string();
419    let rfc = dt.to_rfc3339();
420    maud::html! {
421        time datetime=(rfc) { (display) }
422    }
423}
424
425/// A semantic `<time>` element with a human-readable relative time string.
426///
427/// `now` should come from the [`Clock`](crate::time::Clock) extractor so
428/// tests can control it deterministically.
429///
430/// ```rust,ignore
431/// let markup = time_ago(event.created_at, clock.now(), *tz);
432/// ```
433#[cfg(feature = "maud")]
434#[must_use]
435pub fn time_ago(dt: DateTime<Utc>, now: DateTime<Utc>, tz: Tz) -> maud::Markup {
436    let relative = crate::format::relative_time_words(dt, now);
437    let rfc = dt.to_rfc3339();
438    let local = tz.from_utc_datetime(&dt.naive_utc());
439    let display = local.format("%Y-%m-%d %H:%M %Z").to_string();
440    maud::html! {
441        time datetime=(rfc) title=(display) { (relative) }
442    }
443}
444
445// ── Form parsing helpers ──────────────────────────────────────────────────────
446
447/// Error returned by [`parse_local_datetime`].
448#[derive(Debug, thiserror::Error)]
449pub enum TimeZoneError {
450    /// The input string did not match `YYYY-MM-DDTHH:MM`.
451    #[error("invalid datetime-local input `{input}`: expected YYYY-MM-DDTHH:MM")]
452    InvalidFormat {
453        /// The string that failed to parse.
454        input: String,
455    },
456    /// The input represents an ambiguous or non-existent local time (DST gap).
457    #[error("local time `{input}` is ambiguous or non-existent in `{zone}`")]
458    AmbiguousLocalTime {
459        /// Original input string.
460        input: String,
461        /// Zone name for the error message.
462        zone: String,
463    },
464}
465
466/// Parse a browser `datetime-local` input value (`YYYY-MM-DDTHH:MM`) as a
467/// time in `tz` and convert it to UTC.
468///
469/// DST ambiguity is resolved by choosing the **earlier** (pre-transition)
470/// interpretation.
471///
472/// # Errors
473///
474/// Returns [`TimeZoneError::InvalidFormat`] for unparseable input and
475/// [`TimeZoneError::AmbiguousLocalTime`] for a non-existent local time
476/// (e.g. a DST gap when clocks spring forward).
477pub fn parse_local_datetime(input: &str, tz: Tz) -> Result<DateTime<Utc>, TimeZoneError> {
478    use chrono::NaiveDateTime;
479    let naive = NaiveDateTime::parse_from_str(input.trim(), "%Y-%m-%dT%H:%M").map_err(|_| {
480        TimeZoneError::InvalidFormat {
481            input: input.to_owned(),
482        }
483    })?;
484    tz.from_local_datetime(&naive)
485        .earliest()
486        .ok_or_else(|| TimeZoneError::AmbiguousLocalTime {
487            input: input.to_owned(),
488            zone: tz.name().to_owned(),
489        })
490        .map(|dt| dt.with_timezone(&Utc))
491}
492
493/// Format a UTC timestamp as a `datetime-local` input value (`YYYY-MM-DDTHH:MM`)
494/// in the given zone. Suitable for populating an `<input type="datetime-local">`.
495#[must_use]
496pub fn to_local_input_value(dt: DateTime<Utc>, tz: Tz) -> String {
497    let local = tz.from_utc_datetime(&dt.naive_utc());
498    local.format("%Y-%m-%dT%H:%M").to_string()
499}
500
501/// Maud helper that renders a `<input type="datetime-local">` pre-filled with
502/// the UTC value converted to `tz`.
503#[cfg(feature = "maud")]
504#[must_use]
505pub fn datetime_local_input(
506    name: &str,
507    label: &str,
508    dt: Option<DateTime<Utc>>,
509    tz: Tz,
510) -> maud::Markup {
511    let value = dt.map(|d| to_local_input_value(d, tz)).unwrap_or_default();
512    maud::html! {
513        div.field {
514            label for=(name) { (label) }
515            input type="datetime-local" id=(name) name=(name) value=(value);
516        }
517    }
518}
519
520// ── Ambient request zone (task-local) ─────────────────────────────────────────
521
522tokio::task_local! {
523    static AMBIENT_TZ: Tz;
524}
525
526/// Run `fut` with `tz` set as the ambient request-scoped time zone.
527///
528/// Use in mailer templates and job workers to render timestamps in the zone
529/// that was active when the request was handled:
530///
531/// ```rust,ignore
532/// with_request_time_zone(tz.tz(), async move {
533///     mail.deliver_later(&state).await;
534/// })
535/// .await;
536/// ```
537pub async fn with_request_time_zone<F, R>(tz: Tz, fut: F) -> R
538where
539    F: std::future::Future<Output = R>,
540{
541    AMBIENT_TZ.scope(tz, fut).await
542}
543
544/// Read the ambient time zone set by [`with_request_time_zone`], or `UTC` if
545/// none is set.
546#[must_use]
547pub fn ambient_time_zone() -> Tz {
548    AMBIENT_TZ.try_with(|tz| *tz).unwrap_or(Tz::UTC)
549}
550
551// ── Tests ─────────────────────────────────────────────────────────────────────
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use axum::body::Body;
557    use axum::http::Request;
558    use chrono::Timelike;
559
560    fn parts(uri: &str, headers: &[(&str, &str)]) -> axum::http::request::Parts {
561        let mut req = Request::builder().uri(uri);
562        for (k, v) in headers {
563            req = req.header(*k, *v);
564        }
565        let (parts, _) = req.body(Body::empty()).unwrap().into_parts();
566        parts
567    }
568
569    // ── IANA parsing ──────────────────────────────────────────────────────────
570
571    #[test]
572    fn parse_iana_valid_zones() {
573        assert!(parse_iana("UTC").is_some());
574        assert!(parse_iana("America/New_York").is_some());
575        assert!(parse_iana("Asia/Tokyo").is_some());
576        assert!(parse_iana("Europe/London").is_some());
577        assert!(parse_iana("America/Sao_Paulo").is_some());
578    }
579
580    #[test]
581    fn parse_iana_invalid_zones() {
582        assert!(parse_iana("Mars/Phobos").is_none());
583        assert!(parse_iana("").is_none());
584        assert!(parse_iana("garbage").is_none());
585        assert!(parse_iana("Not/A/Zone").is_none());
586    }
587
588    #[test]
589    fn parse_iana_trims_whitespace() {
590        assert!(parse_iana("  UTC  ").is_some());
591        assert!(parse_iana("  America/New_York  ").is_some());
592    }
593
594    // ── Config ────────────────────────────────────────────────────────────────
595
596    #[test]
597    fn config_default_is_utc() {
598        let cfg = TimeZoneConfig::default();
599        assert_eq!(cfg.identifier, "UTC");
600        assert_eq!(cfg.default_tz(), Tz::UTC);
601    }
602
603    #[test]
604    fn config_validate_accepts_valid_identifier() {
605        let cfg = TimeZoneConfig {
606            identifier: "America/New_York".to_owned(),
607            ..Default::default()
608        };
609        assert!(cfg.validate().is_ok());
610    }
611
612    #[test]
613    fn config_validate_rejects_unknown_identifier() {
614        let cfg = TimeZoneConfig {
615            identifier: "Mars/Phobos".to_owned(),
616            ..Default::default()
617        };
618        let err = cfg.validate().unwrap_err();
619        let msg = err.to_string();
620        assert!(
621            msg.contains("Mars/Phobos"),
622            "error should mention the bad identifier: {msg}"
623        );
624    }
625
626    #[test]
627    fn config_default_sources_order() {
628        let cfg = TimeZoneConfig::default();
629        assert_eq!(
630            cfg.sources,
631            vec![Source::User, Source::Session, Source::Cookie, Source::Query]
632        );
633    }
634
635    #[derive(serde::Deserialize)]
636    struct Wrapper {
637        #[serde(default)]
638        time_zone: TimeZoneConfig,
639    }
640
641    #[test]
642    fn config_deserializes_scalar_shorthand() {
643        let w: Wrapper = toml::from_str(r#"time_zone = "America/New_York""#).unwrap();
644        assert_eq!(w.time_zone.identifier, "America/New_York");
645        // Scalar form keeps the default source chain.
646        assert_eq!(w.time_zone.sources, default_time_zone_sources());
647    }
648
649    #[test]
650    fn config_deserializes_table_form() {
651        let w: Wrapper = toml::from_str(
652            r#"
653            [time_zone]
654            identifier = "Asia/Tokyo"
655            sources = ["query", "cookie"]
656            "#,
657        )
658        .unwrap();
659        assert_eq!(w.time_zone.identifier, "Asia/Tokyo");
660        assert_eq!(w.time_zone.sources, vec![Source::Query, Source::Cookie]);
661    }
662
663    #[test]
664    fn config_table_form_defaults_missing_fields() {
665        let w: Wrapper = toml::from_str(
666            r#"
667            [time_zone]
668            identifier = "Europe/London"
669            "#,
670        )
671        .unwrap();
672        assert_eq!(w.time_zone.identifier, "Europe/London");
673        assert_eq!(w.time_zone.sources, default_time_zone_sources());
674    }
675
676    #[test]
677    fn config_absent_uses_default() {
678        let w: Wrapper = toml::from_str("").unwrap();
679        assert_eq!(w.time_zone.identifier, "UTC");
680    }
681
682    // ── Query resolution ──────────────────────────────────────────────────────
683
684    #[test]
685    fn query_param_resolves_valid_zone() {
686        let p = parts("/?tz=Asia/Tokyo", &[]);
687        let result = resolve_from_query(&p);
688        assert_eq!(result, Some(Tz::Asia__Tokyo));
689    }
690
691    #[test]
692    fn query_param_ignores_invalid_zone() {
693        let p = parts("/?tz=Mars/Phobos", &[]);
694        assert!(resolve_from_query(&p).is_none());
695    }
696
697    #[test]
698    fn query_param_absent_returns_none() {
699        let p = parts("/", &[]);
700        assert!(resolve_from_query(&p).is_none());
701    }
702
703    #[test]
704    fn query_param_percent_encoded_slash() {
705        let p = parts("/?tz=America%2FNew_York", &[]);
706        assert_eq!(resolve_from_query(&p), Some(Tz::America__New_York));
707    }
708
709    #[test]
710    fn query_param_percent_encoded_plus() {
711        // Etc/GMT+5 contains a literal '+'; fully percent-encoded form must parse.
712        let p = parts("/?tz=Etc%2FGMT%2B5", &[]);
713        assert_eq!(resolve_from_query(&p), Some(Tz::Etc__GMTPlus5));
714    }
715
716    #[test]
717    fn percent_decode_passthrough_when_no_escapes() {
718        assert_eq!(percent_decode("America/New_York"), "America/New_York");
719        assert!(matches!(
720            percent_decode("UTC"),
721            std::borrow::Cow::Borrowed("UTC")
722        ));
723    }
724
725    #[test]
726    fn percent_decode_resolves_escapes() {
727        assert_eq!(percent_decode("America%2FNew_York"), "America/New_York");
728        assert_eq!(percent_decode("Etc%2FGMT%2B5"), "Etc/GMT+5");
729    }
730
731    #[test]
732    fn percent_decode_leaves_plus_literal() {
733        assert_eq!(percent_decode("Etc/GMT+5"), "Etc/GMT+5");
734    }
735
736    #[test]
737    fn percent_decode_keeps_trailing_partial_escape_literal() {
738        // A stray '%' with too few hex digits is preserved verbatim.
739        assert_eq!(percent_decode("UTC%2"), "UTC%2");
740        assert_eq!(percent_decode("UTC%"), "UTC%");
741    }
742
743    // ── Cookie resolution ─────────────────────────────────────────────────────
744
745    #[test]
746    fn cookie_resolves_valid_zone() {
747        let p = parts("/", &[("Cookie", "autumn_time_zone=America/Chicago")]);
748        let result = resolve_from_cookie(&p);
749        assert_eq!(result, Some(Tz::America__Chicago));
750    }
751
752    #[test]
753    fn cookie_ignores_other_cookies() {
754        let p = parts(
755            "/",
756            &[("Cookie", "session=abc; autumn_time_zone=UTC; other=x")],
757        );
758        let result = resolve_from_cookie(&p);
759        assert_eq!(result, Some(Tz::UTC));
760    }
761
762    #[test]
763    fn cookie_invalid_zone_returns_none() {
764        let p = parts("/", &[("Cookie", "autumn_time_zone=garbage")]);
765        assert!(resolve_from_cookie(&p).is_none());
766    }
767
768    #[test]
769    fn cookie_absent_returns_none() {
770        let p = parts("/", &[]);
771        assert!(resolve_from_cookie(&p).is_none());
772    }
773
774    #[test]
775    fn cookie_percent_encoded_value() {
776        // Frontend libraries often encodeURIComponent cookie values.
777        let p = parts("/", &[("Cookie", "autumn_time_zone=America%2FNew_York")]);
778        assert_eq!(resolve_from_cookie(&p), Some(Tz::America__New_York));
779    }
780
781    // ── Cookie helper ─────────────────────────────────────────────────────────
782
783    #[test]
784    fn set_time_zone_cookie_produces_correct_header() {
785        let header = set_time_zone_cookie("America/New_York");
786        assert!(header.starts_with("autumn_time_zone=America/New_York"));
787        assert!(header.contains("Path=/"));
788        assert!(header.contains("Max-Age=31536000"));
789        assert!(header.contains("SameSite=Lax"));
790    }
791
792    #[test]
793    fn set_time_zone_cookie_encodes_special_chars() {
794        // '+' is safe per our allow-list; space should be encoded
795        let header = set_time_zone_cookie("Etc/UTC");
796        assert!(header.contains("Etc/UTC"));
797    }
798
799    // ── UserTimeZone extractor ────────────────────────────────────────────────
800
801    #[test]
802    fn user_time_zone_newtype_roundtrips() {
803        let utz = UserTimeZone(Tz::Asia__Tokyo);
804        assert_eq!(utz.0, Tz::Asia__Tokyo);
805    }
806
807    // ── TimeZone struct ───────────────────────────────────────────────────────
808
809    #[test]
810    fn time_zone_new_constructor() {
811        let tz = TimeZone::new(Tz::UTC);
812        assert_eq!(tz.tz(), Tz::UTC);
813        assert_eq!(*tz, Tz::UTC);
814    }
815
816    #[test]
817    fn time_zone_iana_returns_name() {
818        let tz = TimeZone::new(Tz::America__New_York);
819        assert_eq!(tz.iana(), "America/New_York");
820    }
821
822    #[test]
823    fn time_zone_convert_uses_given_zone() {
824        use chrono::TimeZone as ChrTz;
825        let utc = chrono::Utc.with_ymd_and_hms(2025, 6, 14, 12, 0, 0).unwrap();
826        let tz = TimeZone::new(Tz::America__New_York);
827        let local = tz.convert(utc);
828        // New York is UTC-4 in June (EDT)
829        assert_eq!(local.hour(), 8);
830    }
831
832    // ── Form parsing ──────────────────────────────────────────────────────────
833
834    #[test]
835    fn parse_local_datetime_tokyo() {
836        // 15:30 in Tokyo (UTC+9) → 06:30 UTC
837        let result = parse_local_datetime("2025-06-14T15:30", Tz::Asia__Tokyo).unwrap();
838        assert_eq!(result.hour(), 6);
839        assert_eq!(result.minute(), 30);
840    }
841
842    #[test]
843    fn parse_local_datetime_new_york_summer() {
844        // 12:00 in New York (UTC-4 in summer) → 16:00 UTC
845        let result = parse_local_datetime("2025-06-14T12:00", Tz::America__New_York).unwrap();
846        assert_eq!(result.hour(), 16);
847        assert_eq!(result.minute(), 0);
848    }
849
850    #[test]
851    fn parse_local_datetime_invalid_format() {
852        let err = parse_local_datetime("not-a-date", Tz::UTC).unwrap_err();
853        assert!(matches!(err, TimeZoneError::InvalidFormat { .. }));
854    }
855
856    #[test]
857    fn to_local_input_value_roundtrip() {
858        let zones = [Tz::UTC, Tz::America__New_York, Tz::Asia__Tokyo];
859        for tz in zones {
860            let original = "2025-06-14T15:30";
861            let utc = parse_local_datetime(original, tz).unwrap();
862            let back = to_local_input_value(utc, tz);
863            assert_eq!(back, original, "roundtrip failed for {}", tz.name());
864        }
865    }
866
867    #[test]
868    fn to_local_input_value_formats_correctly() {
869        use chrono::TimeZone as ChrTz;
870        let utc = chrono::Utc.with_ymd_and_hms(2025, 6, 14, 6, 30, 0).unwrap();
871        assert_eq!(
872            to_local_input_value(utc, Tz::Asia__Tokyo),
873            "2025-06-14T15:30"
874        );
875        assert_eq!(to_local_input_value(utc, Tz::UTC), "2025-06-14T06:30");
876    }
877
878    // ── Maud view helpers ─────────────────────────────────────────────────────
879
880    #[cfg(feature = "maud")]
881    mod maud_tests {
882        use super::*;
883        use chrono::TimeZone as ChrTz;
884
885        #[allow(clippy::many_single_char_names)]
886        fn utc(y: i32, mo: u32, d: u32, h: u32, m: u32, s: u32) -> DateTime<Utc> {
887            chrono::Utc.with_ymd_and_hms(y, mo, d, h, m, s).unwrap()
888        }
889
890        #[test]
891        fn local_datetime_uses_zone() {
892            let dt = utc(2025, 6, 14, 12, 0, 0);
893            let utc_html = local_datetime(dt, Tz::UTC).into_string();
894            let tokyo_html = local_datetime(dt, Tz::Asia__Tokyo).into_string();
895            let ny_html = local_datetime(dt, Tz::America__New_York).into_string();
896            // Each zone yields a different visible time
897            assert!(utc_html.contains("12:00"), "UTC: {utc_html}");
898            assert!(tokyo_html.contains("21:00"), "Tokyo: {tokyo_html}");
899            assert!(ny_html.contains("08:00"), "New York: {ny_html}");
900        }
901
902        #[test]
903        fn local_datetime_datetime_attr_is_utc() {
904            let dt = utc(2025, 6, 14, 12, 0, 0);
905            let html = local_datetime(dt, Tz::Asia__Tokyo).into_string();
906            // The machine-readable datetime attr should include the UTC timestamp
907            assert!(
908                html.contains("2025-06-14T12:00:00"),
909                "datetime attr must be UTC: {html}"
910            );
911        }
912
913        #[test]
914        fn local_date_uses_zone() {
915            // Close to midnight UTC — the date differs by zone
916            let dt = utc(2025, 6, 14, 23, 30, 0); // Jun 14 23:30 UTC = Jun 15 08:30 Tokyo
917            let utc_html = local_date(dt, Tz::UTC).into_string();
918            let tokyo_html = local_date(dt, Tz::Asia__Tokyo).into_string();
919            assert!(utc_html.contains("2025-06-14"), "UTC: {utc_html}");
920            assert!(tokyo_html.contains("2025-06-15"), "Tokyo: {tokyo_html}");
921        }
922
923        #[test]
924        fn time_ago_seconds() {
925            let now = utc(2025, 6, 14, 12, 0, 30);
926            let dt = utc(2025, 6, 14, 12, 0, 0);
927            let html = time_ago(dt, now, Tz::UTC).into_string();
928            assert!(html.contains("seconds ago"), "{html}");
929        }
930
931        #[test]
932        fn time_ago_minutes() {
933            let now = utc(2025, 6, 14, 12, 5, 0);
934            let dt = utc(2025, 6, 14, 12, 0, 0);
935            let html = time_ago(dt, now, Tz::UTC).into_string();
936            assert!(html.contains("minutes ago"), "{html}");
937        }
938
939        #[test]
940        fn time_ago_hours() {
941            let now = utc(2025, 6, 14, 14, 0, 0);
942            let dt = utc(2025, 6, 14, 12, 0, 0);
943            let html = time_ago(dt, now, Tz::UTC).into_string();
944            assert!(html.contains("hours ago"), "{html}");
945        }
946
947        #[test]
948        fn time_ago_days() {
949            let now = utc(2025, 6, 16, 12, 0, 0);
950            let dt = utc(2025, 6, 14, 12, 0, 0);
951            let html = time_ago(dt, now, Tz::UTC).into_string();
952            assert!(html.contains("days ago"), "{html}");
953        }
954
955        #[test]
956        fn time_ago_future_minutes() {
957            let now = utc(2025, 6, 14, 12, 0, 0);
958            let dt = utc(2025, 6, 14, 12, 5, 0);
959            let html = time_ago(dt, now, Tz::UTC).into_string();
960            assert!(html.contains("in "), "{html}");
961            assert!(html.contains("minutes"), "{html}");
962        }
963
964        #[test]
965        fn time_ago_preserves_utc_datetime_attr() {
966            let now = utc(2025, 6, 14, 12, 5, 0);
967            let dt = utc(2025, 6, 14, 12, 0, 0);
968            let html = time_ago(dt, now, Tz::UTC).into_string();
969            assert!(html.contains("datetime="), "{html}");
970        }
971    }
972
973    // ── Ambient TZ ────────────────────────────────────────────────────────────
974
975    #[tokio::test]
976    async fn ambient_time_zone_defaults_to_utc() {
977        assert_eq!(ambient_time_zone(), Tz::UTC);
978    }
979
980    #[tokio::test]
981    async fn with_request_time_zone_sets_ambient() {
982        let result = with_request_time_zone(Tz::Asia__Tokyo, async { ambient_time_zone() }).await;
983        assert_eq!(result, Tz::Asia__Tokyo);
984    }
985
986    #[tokio::test]
987    async fn ambient_returns_utc_outside_scope() {
988        // Inside scope
989        let inside =
990            with_request_time_zone(Tz::America__New_York, async { ambient_time_zone() }).await;
991        // Outside scope
992        let outside = ambient_time_zone();
993        assert_eq!(inside, Tz::America__New_York);
994        assert_eq!(outside, Tz::UTC);
995    }
996}