Skip to main content

fsqlite_func/
datetime.rs

1//! SQLite date/time functions (§13.3).
2//!
3//! Implements: date(), time(), datetime(), julianday(), unixepoch(),
4//! strftime(), timediff().
5//!
6//! Internal representation is Julian Day Number (f64).  All functions
7//! parse input to JDN, apply modifiers left-to-right, then format.
8//!
9//! Invalid inputs return NULL (never error).
10#![allow(
11    clippy::unnecessary_literal_bound,
12    clippy::too_many_lines,
13    clippy::cast_possible_truncation,
14    clippy::cast_possible_wrap,
15    clippy::cast_sign_loss,
16    clippy::cast_precision_loss,
17    clippy::items_after_statements,
18    clippy::match_same_arms,
19    clippy::float_cmp,
20    clippy::suboptimal_flops,
21    clippy::manual_let_else,
22    clippy::single_match_else,
23    clippy::unnecessary_wraps,
24    clippy::cognitive_complexity,
25    clippy::similar_names,
26    clippy::many_single_char_names,
27    clippy::unreadable_literal,
28    clippy::manual_range_contains,
29    clippy::range_plus_one,
30    clippy::format_push_string,
31    clippy::redundant_else
32)]
33
34use std::{
35    borrow::Cow,
36    fmt::{Arguments, Write as _},
37};
38
39use fsqlite_error::Result;
40use fsqlite_types::{SmallText, SqliteValue};
41
42use crate::{FunctionRegistry, ScalarFunction};
43
44// ── Per-datetime UTC Offset ───────────────────────────────────────────────
45//
46// Used by the 'localtime' and 'utc' modifiers.  We compute the UTC offset
47// for the *specific* datetime being converted so that DST transitions are
48// handled correctly (matching C SQLite's per-call localtime_r behaviour).
49// wasm32 has no stable host-local timezone provider through this crate path,
50// so wasm builds keep these modifiers as explicit UTC no-ops.
51
52/// Return the UTC offset in seconds, interpreting the components as **UTC** time.
53///
54/// Used by the `localtime` modifier (UTC → local): the input JDN is UTC,
55/// so we convert to a UTC instant and ask chrono what the local offset is
56/// at that moment. This correctly handles DST transitions where the UTC
57/// time and the resulting local time fall in different DST phases.
58#[cfg(not(target_arch = "wasm32"))]
59fn utc_offset_for_utc_datetime(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> i64 {
60    use chrono::{Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
61    let date = NaiveDate::from_ymd_opt(y, mo, d).unwrap_or_default();
62    let time = NaiveTime::from_hms_opt(h, mi, s).unwrap_or_default();
63    let naive = NaiveDateTime::new(date, time);
64    let utc_dt = Utc.from_utc_datetime(&naive);
65    let local_dt = utc_dt.with_timezone(&Local);
66    local_dt.offset().local_minus_utc() as i64
67}
68
69#[cfg(target_arch = "wasm32")]
70fn utc_offset_for_utc_datetime(_y: i32, _mo: u32, _d: u32, _h: u32, _mi: u32, _s: u32) -> i64 {
71    0
72}
73
74/// Compute the UTC offset for the `localtime` modifier (UTC → local).
75fn utc_offset_for_utc_jdn(jdn: f64) -> i64 {
76    let (y, mo, d) = jdn_to_ymd(jdn);
77    let (h, mi, s, _frac) = jdn_to_hms(jdn);
78    utc_offset_for_utc_datetime(y as i32, mo as u32, d as u32, h as u32, mi as u32, s as u32)
79}
80
81/// Convert a local-time JDN to UTC for the `utc` modifier, mirroring SQLite's
82/// iterative fixed-point solver (date.c, the `utc` modifier case): find the UTC
83/// instant whose localtime() reproduces the given local wall-clock, iterating up
84/// to 4 times. Unlike a single-offset lookup, this resolves DST-transition
85/// ambiguity to the pre-transition offset exactly as stock does — both the
86/// spring-forward GAP (a nonexistent local time) and the fall-back FOLD (a local
87/// time that occurs twice) — while staying exact for all unambiguous times.
88/// bd-tl6ly.
89#[cfg(not(target_arch = "wasm32"))]
90fn local_jdn_to_utc_jdn(local_jdn: f64) -> f64 {
91    let orig = local_jdn;
92    let mut guess = orig;
93    let mut err = 0.0_f64;
94    let mut cnt = 0u32;
95    loop {
96        guess -= err;
97        // localtime(guess-as-UTC), expressed as a JDN, is `guess` plus the local
98        // UTC offset in effect at that instant.
99        let off = utc_offset_for_utc_jdn(guess);
100        let localized = guess + off as f64 / 86400.0;
101        err = localized - orig;
102        // Mirror sqlite's `while( iErr && cnt++<3 )`: real-timezone offsets are
103        // whole minutes, so a sub-second residual is convergence; the 4-iteration
104        // cap makes the gap's ±1h oscillation terminate on the same UTC value
105        // stock lands on.
106        if err.abs() < 0.5 / 86400.0 || cnt >= 3 {
107            break;
108        }
109        cnt += 1;
110    }
111    guess
112}
113
114#[cfg(target_arch = "wasm32")]
115fn local_jdn_to_utc_jdn(local_jdn: f64) -> f64 {
116    // wasm has no host-local timezone provider through this path; `utc` is a no-op.
117    local_jdn
118}
119
120// ── Julian Day Number Conversions ─────────────────────────────────────────
121//
122// Algorithms from Meeus, "Astronomical Algorithms" (1991).
123
124/// Gregorian (y, m, d, h, min, sec, frac_sec) → Julian Day Number.
125fn ymd_to_jdn(y: i64, m: i64, d: i64) -> f64 {
126    let (y, m) = if m <= 2 {
127        (y.saturating_sub(1), m.saturating_add(12))
128    } else {
129        (y, m)
130    };
131    let a = y / 100;
132    let b = 2_i64.saturating_sub(a).saturating_add(a / 4);
133    (365.25 * y.saturating_add(4716) as f64).floor()
134        + (30.6001 * m.saturating_add(1) as f64).floor()
135        + d as f64
136        + b as f64
137        - 1524.5
138}
139
140/// Julian Day Number → Gregorian (year, month, day).
141///
142/// Uses saturating/wrapping-safe arithmetic so that extreme JDN values
143/// (from overflowed modifier chains) produce deterministic garbage rather
144/// than panicking.  Callers that care about validity should bounds-check
145/// the JDN before calling.
146fn jdn_to_ymd(jdn: f64) -> (i64, i64, i64) {
147    // Mirror SQLite's computeYMD (date.c): a proleptic-Gregorian conversion that
148    // ALWAYS applies the centurial leap correction, using C-style truncation
149    // toward zero (Rust `as i64` casts, not `floor`) so that extreme pre-historic
150    // JDN values near 0 agree with SQLite (JDN 0 → -4713-11-24 12:00) instead of
151    // diverging via a mixed Julian/Gregorian calendar split.
152    let z = (jdn + 0.5).floor() as i64;
153    let alpha = ((z as f64 - 1_867_216.25) / 36524.25) as i64;
154    let a = z
155        .saturating_add(1)
156        .saturating_add(alpha)
157        .saturating_sub(alpha / 4);
158    let b = a.saturating_add(1524);
159    let c = ((b as f64 - 122.1) / 365.25) as i64;
160    let d = (365.25 * c as f64) as i64;
161    let e = ((b.saturating_sub(d)) as f64 / 30.6001) as i64;
162
163    let day = b
164        .saturating_sub(d)
165        .saturating_sub((30.6001 * e as f64) as i64);
166    let month = if e < 14 {
167        e.saturating_sub(1)
168    } else {
169        e.saturating_sub(13)
170    };
171    let year = if month > 2 {
172        c.saturating_sub(4716)
173    } else {
174        c.saturating_sub(4715)
175    };
176    (year, month, day)
177}
178
179/// Julian Day Number → (hour, minute, second, fractional_sec).
180fn jdn_to_hms(jdn: f64) -> (i64, i64, i64, f64) {
181    let frac = jdn + 0.5 - (jdn + 0.5).floor();
182    // Round to nearest millisecond to avoid floating-point drift.
183    let total_ms = (frac * 86_400_000.0).round() as i64;
184    let h = total_ms / 3_600_000;
185    let rem = total_ms % 3_600_000;
186    let m = rem / 60_000;
187    let rem = rem % 60_000;
188    let s = rem / 1000;
189    let ms_frac = (rem % 1000) as f64 / 1000.0;
190    (h, m, s, ms_frac)
191}
192
193/// Build a JDN from date + time components.
194fn ymdhms_to_jdn(y: i64, mo: i64, d: i64, h: i64, mi: i64, s: i64, frac: f64) -> f64 {
195    ymd_to_jdn(y, mo, d) + (h as f64 * 3600.0 + mi as f64 * 60.0 + s as f64 + frac) / 86400.0
196}
197
198/// Unix epoch as JDN.
199const UNIX_EPOCH_JDN: f64 = 2_440_587.5;
200/// Upper bound for values interpreted as Julian day by the `auto` modifier.
201const AUTO_JDN_MAX: f64 = 5_373_484.499_999;
202/// Unix timestamp bounds used by SQLite's `auto` modifier.
203const AUTO_UNIX_MIN: f64 = -210_866_760_000.0;
204const AUTO_UNIX_MAX: f64 = 253_402_300_799.0;
205
206fn jdn_to_unix_millis(jdn: f64) -> i64 {
207    ((jdn - UNIX_EPOCH_JDN) * 86_400_000.0).round() as i64
208}
209
210fn jdn_to_unix(jdn: f64) -> i64 {
211    // SQLite first normalizes its internal Julian day to milliseconds, then
212    // floors to the containing Unix second. `div_euclid` is important before
213    // the epoch: -1ms belongs to Unix second -1, not 0.
214    jdn_to_unix_millis(jdn).div_euclid(1000)
215}
216
217fn jdn_to_unix_subsec(jdn: f64) -> f64 {
218    jdn_to_unix_millis(jdn) as f64 / 1000.0
219}
220
221fn unix_to_jdn(ts: f64) -> f64 {
222    ts / 86400.0 + UNIX_EPOCH_JDN
223}
224
225fn is_leap_year(y: i64) -> bool {
226    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
227}
228
229fn days_in_month(y: i64, m: i64) -> i64 {
230    match m {
231        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
232        4 | 6 | 9 | 11 => 30,
233        2 => {
234            if is_leap_year(y) {
235                29
236            } else {
237                28
238            }
239        }
240        _ => 30,
241    }
242}
243
244fn day_of_year(y: i64, m: i64, d: i64) -> i64 {
245    let mut doy = d;
246    for mo in 1..m {
247        doy = doy.saturating_add(days_in_month(y, mo));
248    }
249    doy
250}
251
252// ── Time String Parsing ───────────────────────────────────────────────────
253
254/// Parse a SQLite time string into a JDN.
255fn parse_timestring(s: &str) -> Option<f64> {
256    // sqlite3_value_text() exposes a NUL-terminated C string to date.c. Bytes
257    // after the first embedded NUL are therefore not part of the time value.
258    let s = sqlite_c_string_str(s);
259
260    // Special values are exact (apart from ASCII case): unlike numeric input,
261    // SQLite does not ignore surrounding whitespace around these keywords.
262    // C SQLite (date.c:451) captures time once per sqlite3_step() via the VFS
263    // and caches it; we use SystemTime::now() which gives per-call resolution.
264    // A future refinement (Track: Cx time source) could freeze time at
265    // statement start for full C SQLite compatibility.
266    if s.eq_ignore_ascii_case("now")
267        || s.eq_ignore_ascii_case("subsec")
268        || s.eq_ignore_ascii_case("subsecond")
269    {
270        return Some(current_time_jdn());
271    }
272
273    // Try as a Julian Day Number (bare float).
274    // Reject non-finite values (NaN/Inf) — sqlite3AtoF doesn't recognize them.
275    let numeric = s.trim_matches(|c: char| c.is_ascii_whitespace());
276    if let Ok(jdn) = numeric.parse::<f64>()
277        && jdn >= 0.0
278        && jdn.is_finite()
279    {
280        return Some(jdn);
281    }
282
283    // SQLite accepts trailing ASCII whitespace on ISO-8601 input, but not
284    // leading whitespace.
285    parse_iso8601(s.trim_end_matches(|c: char| c.is_ascii_whitespace()))
286}
287
288fn current_time_jdn() -> f64 {
289    // GH #175: C SQLite reads the wall clock once per sqlite3_step() and reuses
290    // it for every `'now'`/`CURRENT_*` within that statement. Capture lazily on
291    // the first use this statement and cache it, so `julianday('now')` is stable
292    // across the rows a single statement produces (the Connection resets the
293    // cache at each statement start).
294    if let Some(cached) = crate::builtins::statement_now() {
295        return cached;
296    }
297    use fsqlite_types::sync_primitives::SystemTime;
298
299    let secs = SystemTime::now()
300        .duration_since(SystemTime::UNIX_EPOCH)
301        .unwrap_or_default()
302        .as_secs_f64();
303    let jdn = UNIX_EPOCH_JDN + secs / 86_400.0;
304    crate::builtins::set_statement_now(jdn);
305    jdn
306}
307
308fn sqlite_c_string_bytes(bytes: &[u8]) -> &[u8] {
309    bytes
310        .iter()
311        .position(|&byte| byte == 0)
312        .map_or(bytes, |nul| &bytes[..nul])
313}
314
315fn sqlite_c_string_str(text: &str) -> &str {
316    let bytes = sqlite_c_string_bytes(text.as_bytes());
317    // `bytes` is a prefix of an already-valid UTF-8 string. A NUL is one byte
318    // and therefore always lies on a character boundary.
319    std::str::from_utf8(bytes).unwrap_or("")
320}
321
322fn sqlite_value_datetime_text(value: &SqliteValue) -> Option<Cow<'_, str>> {
323    match value {
324        SqliteValue::Null => None,
325        SqliteValue::Text(text) => Some(Cow::Borrowed(sqlite_c_string_str(text))),
326        SqliteValue::Blob(bytes) => std::str::from_utf8(sqlite_c_string_bytes(bytes))
327            .ok()
328            .map(Cow::Borrowed),
329        SqliteValue::Integer(_) | SqliteValue::Float(_) => Some(Cow::Owned(value.to_text())),
330    }
331}
332
333fn parse_iso8601(s: &str) -> Option<f64> {
334    // YYYY-MM-DD HH:MM:SS.SSS[Z|±HH:MM]  or  YYYY-MM-DDTHH:MM:SS.SSS[Z|±HH:MM]
335    // YYYY-MM-DD HH:MM:SS[Z|±HH:MM]  or  YYYY-MM-DD HH:MM[Z|±HH:MM]
336    // YYYY-MM-DD
337    // HH:MM:SS.SSS[Z|±HH:MM]  (bare time → 2000-01-01)
338    // HH:MM:SS[Z|±HH:MM]
339    // HH:MM[Z|±HH:MM]
340
341    let bytes = s.as_bytes();
342    let len = bytes.len();
343
344    // Try date-only or date+time.
345    if len >= 10 && bytes[4] == b'-' && bytes[7] == b'-' {
346        let y = s[0..4].parse::<i64>().ok()?;
347        let m = s[5..7].parse::<i64>().ok()?;
348        let d = s[8..10].parse::<i64>().ok()?;
349
350        if m < 1 || m > 12 || d < 1 || d > 31 {
351            return None;
352        }
353
354        if len == 10 {
355            return Some(ymd_to_jdn(y, m, d));
356        }
357
358        // Separator: space or 'T'.
359        if len > 10 && (bytes[10] == b' ' || bytes[10] == b'T') {
360            let time_part = &s[11..];
361            let (h, mi, sec, frac, tz_offset_min) = parse_time_part_with_tz(time_part)?;
362            let jdn = ymdhms_to_jdn(y, m, d, h, mi, sec, frac);
363            // Apply TZ offset: subtract the offset to convert local → UTC.
364            // For "+01:00", local = UTC + 1h, so UTC = local - 1h.
365            return Some(jdn - (tz_offset_min as f64) / 1440.0);
366        }
367        return None;
368    }
369
370    // Bare time: HH:MM:SS or HH:MM:SS.SSS or HH:MM, optionally with TZ suffix.
371    if len >= 5 && bytes[2] == b':' {
372        let (h, mi, sec, frac, tz_offset_min) = parse_time_part_with_tz(s)?;
373        let jdn = ymdhms_to_jdn(2000, 1, 1, h, mi, sec, frac);
374        return Some(jdn - (tz_offset_min as f64) / 1440.0);
375    }
376
377    None
378}
379
380/// Split an optional trailing ISO-8601 timezone suffix (`Z`, `+HH:MM`, `-HH:MM`,
381/// or the compact `±HHMM` / `±HH` forms) from a time string.
382///
383/// Returns the time string without the suffix and the offset in minutes east
384/// of UTC.  `+01:00` returns `60`, `-05:30` returns `-330`, `Z` returns `0`.
385///
386/// If no recognised suffix is present, returns `(s, 0)`.
387fn split_tz_suffix(s: &str) -> Option<(&str, i64)> {
388    // Trailing 'Z' or 'z' → UTC.
389    if let Some(stripped) = s.strip_suffix('Z').or_else(|| s.strip_suffix('z')) {
390        return Some((stripped, 0));
391    }
392
393    // Find the last '+' or '-' that could plausibly start a tz offset.
394    // The sign must appear AFTER the seconds (or minutes) portion — i.e.
395    // after the last ':' or after a '.' fractional seconds block — so we
396    // never confuse a negative fractional value with a tz sign.
397    //
398    // Scan from the right: the first '+' or '-' we hit *before* any
399    // alphanumeric mismatch is the tz sign, provided the remainder is a
400    // well-formed HH[:MM] / HHMM offset.
401    let bytes = s.as_bytes();
402    // Look at the trailing 6 chars ("+HH:MM"), 5 chars ("+HHMM"), or 3 chars ("+HH").
403    for width in [6usize, 5, 3] {
404        if bytes.len() < width + 1 {
405            continue;
406        }
407        let split_at = bytes.len() - width;
408        let sign_byte = bytes[split_at];
409        if sign_byte != b'+' && sign_byte != b'-' {
410            continue;
411        }
412        let tz_part = &s[split_at..];
413        if let Some(offset) = parse_tz_offset(tz_part) {
414            return Some((&s[..split_at], offset));
415        }
416    }
417
418    // No recognised TZ suffix.
419    Some((s, 0))
420}
421
422/// Parse an ISO-8601 timezone offset like `+01:00`, `-05:30`, `+0100`, or `+05`.
423/// Returns the offset in minutes east of UTC, or None if not a valid offset.
424fn parse_tz_offset(tz: &str) -> Option<i64> {
425    let bytes = tz.as_bytes();
426    if bytes.is_empty() {
427        return None;
428    }
429    let sign: i64 = match bytes[0] {
430        b'+' => 1,
431        b'-' => -1,
432        _ => return None,
433    };
434    let rest = &tz[1..];
435    let (hours, minutes) = match rest.len() {
436        // ±HH:MM
437        5 if rest.as_bytes()[2] == b':' => (
438            rest[0..2].parse::<i64>().ok()?,
439            rest[3..5].parse::<i64>().ok()?,
440        ),
441        // ±HHMM
442        4 => (
443            rest[0..2].parse::<i64>().ok()?,
444            rest[2..4].parse::<i64>().ok()?,
445        ),
446        // ±HH
447        2 => (rest.parse::<i64>().ok()?, 0),
448        _ => return None,
449    };
450    if !(0..=23).contains(&hours) || !(0..=59).contains(&minutes) {
451        return None;
452    }
453    Some(sign * (hours * 60 + minutes))
454}
455
456/// Parse "HH:MM:SS.SSS" or "HH:MM:SS" or "HH:MM", optionally followed by a
457/// timezone suffix.  Returns `(h, mi, sec, frac, tz_offset_minutes)`.
458fn parse_time_part_with_tz(s: &str) -> Option<(i64, i64, i64, f64, i64)> {
459    let (time_only, tz_offset_min) = split_tz_suffix(s)?;
460    let (h, mi, sec, frac) = parse_time_part(time_only)?;
461    Some((h, mi, sec, frac, tz_offset_min))
462}
463
464/// Parse "HH:MM:SS.SSS" or "HH:MM:SS" or "HH:MM".
465fn parse_time_part(s: &str) -> Option<(i64, i64, i64, f64)> {
466    let [h_tens, h_ones, b':', mi_tens, mi_ones, rest @ ..] = s.as_bytes() else {
467        return None;
468    };
469    // C SQLite's computeHMS uses getDigits with a "20" field width,
470    // meaning exactly 2 bare decimal digits per time component.  Reject
471    // fields that don't match that shape: wrong length, leading signs
472    // (`+01`), or non-digit characters.
473    let h = parse_two_ascii_digits(*h_tens, *h_ones)?;
474    let mi = parse_two_ascii_digits(*mi_tens, *mi_ones)?;
475    if !(0..=23).contains(&h) || !(0..=59).contains(&mi) {
476        return None;
477    }
478
479    // Third part may have fractional seconds: "SS" or "SS.SSS".
480    // Apply the same 2-digit bare-digits constraint as hours/minutes:
481    // C SQLite's computeHMS requires the seconds integer to be exactly
482    // 2 decimal digits.
483    match rest {
484        [] => Some((h, mi, 0, 0.0)),
485        [b':', sec_tens, sec_ones] => {
486            let sec = parse_two_ascii_digits(*sec_tens, *sec_ones)?;
487            if !(0..=59).contains(&sec) {
488                return None;
489            }
490            Some((h, mi, sec, 0.0))
491        }
492        [b':', sec_tens, sec_ones, b'.', ..] => {
493            let sec = parse_two_ascii_digits(*sec_tens, *sec_ones)?;
494            if !(0..=59).contains(&sec) {
495                return None;
496            }
497            let frac = s.get(8..)?.parse::<f64>().ok()?;
498            Some((h, mi, sec, frac))
499        }
500        _ => None,
501    }
502}
503
504#[inline]
505fn parse_two_ascii_digits(tens: u8, ones: u8) -> Option<i64> {
506    if tens.is_ascii_digit() && ones.is_ascii_digit() {
507        Some(i64::from((tens - b'0') * 10 + (ones - b'0')))
508    } else {
509        None
510    }
511}
512
513// ── Modifier Pipeline ─────────────────────────────────────────────────────
514
515/// Apply a single modifier string to a JDN.  Returns None if invalid.
516fn apply_modifier(jdn: f64, modifier: &str) -> Option<f64> {
517    if has_outer_ascii_whitespace(modifier) {
518        return None;
519    }
520    let m = modifier.to_ascii_lowercase();
521
522    // 'start of month' / 'start of year' / 'start of day'
523    if m == "start of month" {
524        let (y, mo, _d) = jdn_to_ymd(jdn);
525        return Some(ymd_to_jdn(y, mo, 1));
526    }
527    if m == "start of year" {
528        let (y, _mo, _d) = jdn_to_ymd(jdn);
529        return Some(ymd_to_jdn(y, 1, 1));
530    }
531    if m == "start of day" {
532        let (y, mo, d) = jdn_to_ymd(jdn);
533        return Some(ymd_to_jdn(y, mo, d));
534    }
535
536    // 'unixepoch' — reinterpret input as Unix timestamp.
537    if m == "unixepoch" {
538        return Some(unix_to_jdn(jdn));
539    }
540
541    // 'julianday' — input is already a JDN (no-op here, but the spec says
542    // it forces interpretation as JDN).
543    if m == "julianday" {
544        return Some(jdn);
545    }
546
547    // 'auto' — apply SQLite numeric auto-detection:
548    //   0.0..=5373484.499999          => Julian day number
549    //   -210866760000..=253402300799  => Unix timestamp
550    //   otherwise                      => NULL
551    if m == "auto" {
552        if (0.0..=AUTO_JDN_MAX).contains(&jdn) {
553            return Some(jdn);
554        }
555        if (AUTO_UNIX_MIN..=AUTO_UNIX_MAX).contains(&jdn) {
556            return Some(unix_to_jdn(jdn));
557        }
558        return None;
559    }
560
561    // 'localtime' — convert UTC to local time.  The input JDN is UTC, so we
562    // must compute the offset by interpreting the datetime as UTC (not local).
563    if m == "localtime" {
564        let offset = utc_offset_for_utc_jdn(jdn);
565        return Some(jdn + offset as f64 / 86400.0);
566    }
567    // 'utc' — convert local time to UTC via sqlite's iterative fixed-point
568    // solver, so a DST-transition-ambiguous local time resolves to the
569    // pre-transition offset exactly as stock does (bd-tl6ly).
570    if m == "utc" {
571        return Some(local_jdn_to_utc_jdn(jdn));
572    }
573
574    // 'subsec' / 'subsecond' — this is a flag that affects output formatting,
575    // not the JDN.  We pass it through unchanged.
576    if m == "subsec" || m == "subsecond" {
577        return Some(jdn);
578    }
579
580    // 'weekday N' — advance to the next day that is weekday N (0=Sunday).
581    if let Some(rest) = m.strip_prefix("weekday ") {
582        let wd = rest.trim().parse::<i64>().ok()?;
583        if !(0..=6).contains(&wd) {
584            return None;
585        }
586        // Current day of week: 0=Monday in JDN, but SQLite uses 0=Sunday.
587        let current_jdn_int = (jdn + 0.5).floor() as i64;
588        let current_wd = (current_jdn_int + 1) % 7; // 0=Sunday
589        let mut diff = wd - current_wd;
590        if diff < 0 {
591            diff += 7;
592        }
593        // If already the target weekday, this is a no-op (SQLite behavior).
594        return Some(jdn + diff as f64);
595    }
596
597    // Arithmetic: '+NNN days', '-NNN hours', etc.
598    parse_arithmetic_modifier(&m).map(|delta| jdn + delta)
599}
600
601/// Parse "NNN unit" / "+NNN unit" / "-NNN unit" and return the JDN delta.
602/// SQLite's date grammar makes the sign optional: a bare `NNN units` modifier is
603/// positive (`date('now', '7 days')` == `date('now', '+7 days')`).
604fn parse_arithmetic_modifier(m: &str) -> Option<f64> {
605    let (sign, rest) = if let Some(r) = m.strip_prefix('+') {
606        (1.0, r.trim())
607    } else if let Some(r) = m.strip_prefix('-') {
608        (-1.0, r.trim())
609    } else {
610        (1.0, m.trim())
611    };
612
613    let mut parts = rest.splitn(2, ' ');
614    let num_str = parts.next()?;
615    let unit = parts.next()?.trim();
616
617    // Reject non-finite (NaN/Inf) — sqlite3AtoF doesn't recognize them.
618    let num = num_str.parse::<f64>().ok().filter(|f| f.is_finite())?;
619    let delta = num * sign;
620
621    match unit.trim_end_matches('s') {
622        "day" => Some(delta),
623        "hour" => Some(delta / 24.0),
624        "minute" => Some(delta / 1440.0),
625        "second" => Some(delta / 86400.0),
626        "month" => Some(apply_month_delta(delta)),
627        "year" => Some(apply_month_delta(delta * 12.0)),
628        _ => None,
629    }
630}
631
632/// For month/year arithmetic, we can't simply add a JDN delta because months
633/// have variable lengths.  This returns a JDN delta that is *approximately*
634/// correct.  A fully correct implementation requires decomposing and
635/// recomposing, which we handle in `apply_modifier_full` for month/year cases.
636fn apply_month_delta(months: f64) -> f64 {
637    // Average month ≈ 30.436875 days.
638    months * 30.436875
639}
640
641fn has_outer_ascii_whitespace(value: &str) -> bool {
642    value
643        .as_bytes()
644        .first()
645        .is_some_and(u8::is_ascii_whitespace)
646        || value.as_bytes().last().is_some_and(u8::is_ascii_whitespace)
647}
648
649/// SQLite's `computeFloor` (date.c): given a (possibly day-of-month-overflowing)
650/// Y-M-D, return how many days must be subtracted to bring the date back to the
651/// last valid day of month `m`.  Consumed by the `'floor'` datetime modifier.
652fn compute_floor(y: i64, m: i64, d: i64) -> i64 {
653    if d <= 28 {
654        0
655    } else if ((1_i64 << m) & 0x15aa) != 0 {
656        // 31-day month (Jan, Mar, May, Jul, Aug, Oct, Dec): days 29-31 all valid.
657        0
658    } else if m != 2 {
659        // 30-day month (Apr, Jun, Sep, Nov): only day 31 overflows, by 1.
660        i64::from(d == 31)
661    } else if y % 4 != 0 || (y % 100 == 0 && y % 400 != 0) {
662        d - 28 // non-leap February
663    } else {
664        d - 29 // leap February
665    }
666}
667
668/// Apply a sequence of modifiers, also tracking the 'subsec' flag.
669fn apply_modifiers(jdn: f64, modifiers: &[String], mut raw_numeric: bool) -> Option<(f64, bool)> {
670    let mut j = jdn;
671    let mut subsec = false;
672    // Pending day-of-month overflow from the most recent +N month/year shift,
673    // consumed by a later 'floor' modifier (mirrors SQLite's DateTime.nFloor).
674    let mut n_floor: i64 = 0;
675    for (index, m) in modifiers.iter().enumerate() {
676        if has_outer_ascii_whitespace(m) {
677            return None;
678        }
679        let m_lower = m.to_ascii_lowercase();
680        if matches!(m_lower.as_str(), "unixepoch" | "julianday" | "auto") {
681            // These reinterpretations are valid only as the first modifier of
682            // a raw numeric time value (date.c parseModifier's `idx>1` and
683            // `rawS` checks).
684            if index != 0 || !raw_numeric {
685                return None;
686            }
687            raw_numeric = false;
688        } else if m_lower != "subsec" && m_lower != "subsecond" {
689            raw_numeric = false;
690        }
691        if m_lower == "subsec" || m_lower == "subsecond" {
692            subsec = true;
693            continue;
694        }
695        // 'ceiling' is the default day-of-month-overflow behavior (roll forward
696        // into the next month); it merely clears any pending floor adjustment.
697        // 'floor' rolls the date back to the last day of the prior month by
698        // subtracting the overflow days that the preceding month/year shift left.
699        if m_lower == "ceiling" {
700            n_floor = 0;
701            continue;
702        }
703        if m_lower == "floor" {
704            j -= n_floor as f64;
705            n_floor = 0;
706            continue;
707        }
708        // Month/year modifiers need special handling for exact date math.
709        // If exact arithmetic overflows (returns None), the modifier is
710        // out of representable range — return NULL rather than falling
711        // through to the approximate path which would produce overflow
712        // panics in jdn_to_ymd.
713        if is_month_year_modifier(&m_lower) {
714            match apply_month_year_exact(j, &m_lower) {
715                Ok(Some((new_jdn, nf))) => {
716                    j = new_jdn;
717                    n_floor = nf;
718                    continue;
719                }
720                Ok(None) => return None,
721                Err(()) => {
722                    // Fall through to `apply_modifier` for fractional values
723                }
724            }
725        }
726        // Any other date-changing modifier clears the pending floor.
727        n_floor = 0;
728        j = apply_modifier(j, m)?;
729    }
730    Some((j, subsec))
731}
732
733fn is_month_year_modifier(m: &str) -> bool {
734    // A month/year arithmetic modifier, signed OR unsigned (SQLite treats a bare
735    // `NNN months` as positive). Named modifiers that merely contain the words
736    // ("start of month"/"start of year") reach `apply_month_year_exact`, fail its
737    // numeric parse, and fall through to `apply_modifier` unchanged.
738    m.contains("month") || m.contains("year")
739}
740
741/// Exact month/year arithmetic by decomposing to YMD.
742/// Returns Ok(Some((jdn, n_floor))) for exact application, where `n_floor` is
743/// the day-of-month overflow (days to subtract for a later `'floor'` modifier).
744/// Returns Ok(None) for overflow.
745/// Returns Err(()) if the modifier is not an integer, so it should fall back.
746fn apply_month_year_exact(jdn: f64, m: &str) -> std::result::Result<Option<(f64, i64)>, ()> {
747    let (sign, rest) = if let Some(r) = m.strip_prefix('+') {
748        (1_i64, r.trim())
749    } else if let Some(r) = m.strip_prefix('-') {
750        (-1_i64, r.trim())
751    } else {
752        // Unsigned `NNN months/years` is positive (SQLite grammar). A non-numeric
753        // leading token (e.g. "start of month") fails the parse below and the
754        // caller falls through to `apply_modifier`.
755        (1_i64, m.trim())
756    };
757
758    let mut parts = rest.splitn(2, ' ');
759    let num_str = parts.next().ok_or(())?;
760    let unit = parts.next().ok_or(())?.trim();
761
762    // SQLite splits a month/year modifier into an INTEGER count, applied by
763    // calendar month/year recomposition, plus a fractional remainder applied as
764    // FLAT days AFTER that recomposition: a fractional month adds `frac * 30`
765    // days, a fractional year adds `frac * 365` days, signed like the modifier
766    // (bd-datetime-fractional-month-year-ag5fo). So `+1.5 months` on 2020-01-15 is +1 month (→ 02-15) then +15
767    // days (→ 03-01), NOT `1.5 * average-month-length`. Integer-only values leave
768    // the fractional term at zero and take the pure calendar path unchanged.
769    let (int_num, num_frac) = if let Ok(n) = num_str.parse::<i64>() {
770        (n, 0.0_f64)
771    } else if let Ok(f) = num_str.parse::<f64>() {
772        let int_part = f.trunc();
773        if !f.is_finite() || int_part < i64::MIN as f64 || int_part > i64::MAX as f64 {
774            return Err(());
775        }
776        // `num_str` is the unsigned magnitude (the sign was stripped above), so
777        // `int_part >= 0` and `num_frac` is in [0, 1); the modifier `sign` gives
778        // both the recomposition direction and the fractional-day direction.
779        (int_part as i64, f - int_part)
780    } else {
781        return Err(());
782    };
783
784    let (y, mo, d) = jdn_to_ymd(jdn);
785    let (h, mi, s, frac) = jdn_to_hms(jdn);
786
787    let unit = unit.trim_end_matches('s');
788    let (total_months, frac_days) = match unit {
789        "month" => {
790            let Some(months) = int_num.checked_mul(sign) else {
791                return Ok(None);
792            };
793            (months, num_frac * sign as f64 * 30.0)
794        }
795        "year" => {
796            let Some(months) = int_num.checked_mul(sign).and_then(|v| v.checked_mul(12)) else {
797                return Ok(None);
798            };
799            (months, num_frac * sign as f64 * 365.0)
800        }
801        _ => return Err(()),
802    };
803
804    // (y * 12 + (mo - 1)) + total_months
805    let current_months = if let Some(val) = y.checked_mul(12).and_then(|v| v.checked_add(mo - 1)) {
806        val
807    } else {
808        return Ok(None);
809    };
810    let new_total = if let Some(val) = current_months.checked_add(total_months) {
811        val
812    } else {
813        return Ok(None);
814    };
815
816    let new_y = new_total.div_euclid(12);
817    let new_mo = new_total.rem_euclid(12) + 1;
818    // Do NOT clamp `d` to the target month's day count.  C SQLite lets
819    // out-of-range days overflow via JDN arithmetic (e.g. Feb 31 → Mar 3) for
820    // the default/`'ceiling'` behavior; the `'floor'` modifier later subtracts
821    // the reported `n_floor` overflow days to clamp back to end-of-month.
822    let n_floor = compute_floor(new_y, new_mo, d);
823    Ok(Some((
824        ymdhms_to_jdn(new_y, new_mo, d, h, mi, s, frac) + frac_days,
825        n_floor,
826    )))
827}
828
829// ── Output Formatters ─────────────────────────────────────────────────────
830
831/// Fixed-capacity stack `fmt::Write` target. Any date/time output is at most ~26 bytes
832/// (a multi-digit year plus `-MM-DD HH:MM:SS.SSS`), so 48 bytes never overflows for a
833/// valid date; `write_str` returns `Err` on overflow so `build_small_text` can fall back.
834struct StackStr {
835    buf: [u8; 48],
836    len: usize,
837}
838
839impl StackStr {
840    fn new() -> Self {
841        Self {
842            buf: [0; 48],
843            len: 0,
844        }
845    }
846
847    fn as_str(&self) -> &str {
848        // Only ASCII digits/separators are ever written, so this is always valid UTF-8.
849        core::str::from_utf8(&self.buf[..self.len]).unwrap_or("")
850    }
851}
852
853impl core::fmt::Write for StackStr {
854    fn write_str(&mut self, s: &str) -> core::fmt::Result {
855        let end = self.len + s.len();
856        if end > self.buf.len() {
857            return Err(core::fmt::Error);
858        }
859        self.buf[self.len..end].copy_from_slice(s.as_bytes());
860        self.len = end;
861        Ok(())
862    }
863}
864
865/// Render `write` into a stack buffer and build an inline `SmallText` (no heap) when it
866/// fits the 23-byte inline capacity — the common case for every real date/time. Falls
867/// back to a heap `String` only when the stack buffer overflows (an absurdly large year),
868/// re-running `write` (hence `Fn`, not `FnOnce`). Mirrors the stack-backed Soundex result
869/// (bd-t2sf9.1): the transient `format!` heap allocation that `SmallText` immediately
870/// inlined is eliminated for the hot path.
871fn build_small_text(write: impl Fn(&mut dyn core::fmt::Write) -> core::fmt::Result) -> SmallText {
872    let mut buf = StackStr::new();
873    if write(&mut buf).is_ok() {
874        SmallText::new(buf.as_str())
875    } else {
876        let mut heap = String::new();
877        let _ = write(&mut heap);
878        SmallText::from_string(heap)
879    }
880}
881
882/// Write a calendar year the way C SQLite's `date()`/`datetime()` render it: a
883/// 4-digit zero-padded field, but with the minus sign kept *outside* the padding
884/// for negative (proleptic BC) years — e.g. -1 renders as `-0001`, not `-001`
885/// (Rust's `{:04}` would count the sign inside the width). `strftime('%Y')`
886/// differs — it keeps the sign inside a 4-wide field — and is handled separately.
887fn write_year(w: &mut dyn core::fmt::Write, y: i64) -> core::fmt::Result {
888    if y < 0 {
889        write!(w, "-{:04}", y.unsigned_abs())
890    } else {
891        write!(w, "{y:04}")
892    }
893}
894
895fn format_date(jdn: f64) -> SmallText {
896    let (y, m, d) = jdn_to_ymd(jdn);
897    build_small_text(move |w| {
898        write_year(w, y)?;
899        write!(w, "-{m:02}-{d:02}")
900    })
901}
902
903#[derive(Clone, Copy)]
904struct UnmodifiedHms {
905    hour: i64,
906    minute: i64,
907    second: i64,
908    fraction: f64,
909}
910
911fn hms_for_output(jdn: f64, unmodified: Option<UnmodifiedHms>) -> UnmodifiedHms {
912    unmodified.unwrap_or_else(|| {
913        let (hour, minute, second, fraction) = jdn_to_hms(jdn);
914        UnmodifiedHms {
915            hour,
916            minute,
917            second,
918            fraction,
919        }
920    })
921}
922
923fn rounded_second_and_millis(hms: UnmodifiedHms) -> (i64, i64) {
924    // date.c rounds the seconds field independently of hours/minutes. Thus an
925    // input ending in 59.9995 is deliberately rendered as 60.000 rather than
926    // carrying into the next minute.
927    let total_millis = ((hms.second as f64 + hms.fraction) * 1000.0 + 0.5).floor() as i64;
928    (total_millis / 1000, total_millis % 1000)
929}
930
931fn format_time(jdn: f64, subsec: bool, unmodified: Option<UnmodifiedHms>) -> SmallText {
932    let hms = hms_for_output(jdn, unmodified);
933    let (h, m) = (hms.hour, hms.minute);
934    if subsec {
935        let (s, ms) = rounded_second_and_millis(hms);
936        build_small_text(move |w| write!(w, "{h:02}:{m:02}:{s:02}.{ms:03}"))
937    } else {
938        let s = hms.second;
939        build_small_text(move |w| write!(w, "{h:02}:{m:02}:{s:02}"))
940    }
941}
942
943fn format_datetime(jdn: f64, subsec: bool, unmodified: Option<UnmodifiedHms>) -> SmallText {
944    let (y, mo, d) = jdn_to_ymd(jdn);
945    let hms = hms_for_output(jdn, unmodified);
946    let (h, mi) = (hms.hour, hms.minute);
947    if subsec {
948        let (s, ms) = rounded_second_and_millis(hms);
949        build_small_text(move |w| {
950            write_year(w, y)?;
951            write!(w, "-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}.{ms:03}")
952        })
953    } else {
954        let s = hms.second;
955        build_small_text(move |w| {
956            write_year(w, y)?;
957            write!(w, "-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}")
958        })
959    }
960}
961
962#[inline]
963fn push_format(result: &mut String, args: Arguments<'_>) {
964    let _ = result.write_fmt(args);
965}
966
967#[inline]
968fn push_zero_padded_2(result: &mut String, value: i64) {
969    if (0..=99).contains(&value) {
970        let value = value as u8;
971        result.push(char::from(b'0' + value / 10));
972        result.push(char::from(b'0' + value % 10));
973    } else {
974        push_format(result, format_args!("{value:02}"));
975    }
976}
977
978#[inline]
979fn push_space_padded_2(result: &mut String, value: i64) {
980    if (0..=99).contains(&value) {
981        let value = value as u8;
982        if value >= 10 {
983            result.push(char::from(b'0' + value / 10));
984        } else {
985            result.push(' ');
986        }
987        result.push(char::from(b'0' + value % 10));
988    } else {
989        push_format(result, format_args!("{value:>2}"));
990    }
991}
992
993#[inline]
994fn push_zero_padded_3(result: &mut String, value: i64) {
995    if (0..=999).contains(&value) {
996        let value = value as u16;
997        result.push(char::from(b'0' + (value / 100) as u8));
998        result.push(char::from(b'0' + ((value / 10) % 10) as u8));
999        result.push(char::from(b'0' + (value % 10) as u8));
1000    } else {
1001        push_format(result, format_args!("{value:03}"));
1002    }
1003}
1004
1005#[inline]
1006fn push_zero_padded_4(result: &mut String, value: i64) {
1007    if (0..=9999).contains(&value) {
1008        let value = value as u16;
1009        result.push(char::from(b'0' + (value / 1000) as u8));
1010        result.push(char::from(b'0' + ((value / 100) % 10) as u8));
1011        result.push(char::from(b'0' + ((value / 10) % 10) as u8));
1012        result.push(char::from(b'0' + (value % 10) as u8));
1013    } else {
1014        push_format(result, format_args!("{value:04}"));
1015    }
1016}
1017
1018/// strftime format engine.
1019fn format_strftime(
1020    fmt: &str,
1021    jdn: f64,
1022    subsec: bool,
1023    unmodified: Option<UnmodifiedHms>,
1024) -> Option<String> {
1025    let (y, mo, d) = jdn_to_ymd(jdn);
1026    let hms = hms_for_output(jdn, unmodified);
1027    let (h, mi, s, frac) = (hms.hour, hms.minute, hms.second, hms.fraction);
1028    let doy = day_of_year(y, mo, d);
1029    // Day of week: 0=Sunday.
1030    let jdn_int = (jdn + 0.5).floor() as i64;
1031    let dow = (jdn_int + 1) % 7; // 0=Sunday, 6=Saturday
1032
1033    let mut result = String::with_capacity(fmt.len().saturating_add(8));
1034    let bytes = fmt.as_bytes();
1035    let mut i = 0;
1036    let mut literal_start = 0;
1037
1038    while i < bytes.len() {
1039        if bytes[i] != b'%' || i + 1 >= bytes.len() {
1040            i += 1;
1041            continue;
1042        }
1043
1044        result.push_str(&fmt[literal_start..i]);
1045
1046        let spec_suffix = &fmt[i + 1..];
1047        let Some(spec) = spec_suffix.chars().next() else {
1048            break;
1049        };
1050        i += 1 + spec.len_utf8();
1051        literal_start = i;
1052
1053        match spec {
1054            'd' => push_zero_padded_2(&mut result, d),
1055            'e' => push_space_padded_2(&mut result, d),
1056            'F' => {
1057                // ISO 8601 date: %Y-%m-%d (bd-luvv8).
1058                push_zero_padded_4(&mut result, y);
1059                result.push('-');
1060                push_zero_padded_2(&mut result, mo);
1061                result.push('-');
1062                push_zero_padded_2(&mut result, d);
1063            }
1064            'f' => {
1065                // Seconds with fractional part.
1066                let total = (s as f64 + frac).min(59.999);
1067                push_format(&mut result, format_args!("{total:06.3}"));
1068            }
1069            'H' => push_zero_padded_2(&mut result, h),
1070            'I' => {
1071                // 12-hour clock.
1072                let h12 = if h == 0 {
1073                    12
1074                } else if h > 12 {
1075                    h - 12
1076                } else {
1077                    h
1078                };
1079                push_zero_padded_2(&mut result, h12);
1080            }
1081            'j' => push_zero_padded_3(&mut result, doy),
1082            'J' => {
1083                // Julian day number. Stock renders %J as C `%.16g`: 16
1084                // significant figures, trailing zeros stripped (so an integer
1085                // JDN has no ".0"). The previous fixed `{:.15}` emitted 15
1086                // decimal *places* (≈22 significant digits); the general
1087                // REAL->text form over-renders (17 figs) and keeps ".0".
1088                // bd-565ji.
1089                result.push_str(&crate::builtins::format_float_g(jdn, 16, false, false, 16));
1090            }
1091            'k' => {
1092                // Space-padded 24-hour.
1093                push_space_padded_2(&mut result, h);
1094            }
1095            'l' => {
1096                // Space-padded 12-hour.
1097                let h12 = if h == 0 {
1098                    12
1099                } else if h > 12 {
1100                    h - 12
1101                } else {
1102                    h
1103                };
1104                push_space_padded_2(&mut result, h12);
1105            }
1106            'm' => push_zero_padded_2(&mut result, mo),
1107            'M' => push_zero_padded_2(&mut result, mi),
1108            'p' => {
1109                result.push_str(if h < 12 { "AM" } else { "PM" });
1110            }
1111            'P' => {
1112                result.push_str(if h < 12 { "am" } else { "pm" });
1113            }
1114            'R' => {
1115                push_zero_padded_2(&mut result, h);
1116                result.push(':');
1117                push_zero_padded_2(&mut result, mi);
1118            }
1119            's' => {
1120                if subsec {
1121                    let unix = jdn_to_unix_subsec(jdn);
1122                    push_format(&mut result, format_args!("{unix:.3}"));
1123                } else {
1124                    let unix = jdn_to_unix(jdn);
1125                    push_format(&mut result, format_args!("{unix}"));
1126                }
1127            }
1128            'S' => push_zero_padded_2(&mut result, s),
1129            'T' => {
1130                push_zero_padded_2(&mut result, h);
1131                result.push(':');
1132                push_zero_padded_2(&mut result, mi);
1133                result.push(':');
1134                push_zero_padded_2(&mut result, s);
1135            }
1136            'u' => {
1137                // ISO 8601 day of week: 1=Monday, 7=Sunday.
1138                let u = if dow == 0 { 7 } else { dow };
1139                push_format(&mut result, format_args!("{u}"));
1140            }
1141            'w' => push_format(&mut result, format_args!("{dow}")),
1142            'W' => {
1143                // Week of year (Monday as first day of week, 00-53).
1144                let w = (doy + 6 - ((dow + 6) % 7)) / 7;
1145                push_zero_padded_2(&mut result, w);
1146            }
1147            'U' => {
1148                // Week of year (Sunday as first day of week, 00-53): the C
1149                // strftime rule (0-based yday + 7 - wday)/7, with `dow`
1150                // already 0=Sunday. Days before the first Sunday are week 00.
1151                // bd-zv4ra.
1152                let u = (doy + 6 - dow) / 7;
1153                push_zero_padded_2(&mut result, u);
1154            }
1155            'Y' => push_zero_padded_4(&mut result, y),
1156            'G' | 'g' | 'V' => {
1157                // ISO 8601 week-based year/week.
1158                let (iso_y, iso_w) = iso_week(y, mo, d);
1159                match spec {
1160                    'G' => push_zero_padded_4(&mut result, iso_y),
1161                    'g' => push_zero_padded_2(&mut result, iso_y % 100),
1162                    'V' => push_zero_padded_2(&mut result, iso_w),
1163                    _ => unreachable!(),
1164                }
1165            }
1166            '%' => result.push('%'),
1167            // An unknown/undefined specifier makes stock strftime return NULL
1168            // for the ENTIRE result (it does not pass the specifier through
1169            // literally). bd-13mqo.
1170            _ => return None,
1171        }
1172    }
1173
1174    if literal_start < fmt.len() {
1175        result.push_str(&fmt[literal_start..]);
1176    }
1177
1178    Some(result)
1179}
1180
1181/// ISO 8601 week number and year.
1182fn iso_week(y: i64, m: i64, d: i64) -> (i64, i64) {
1183    let jdn = ymd_to_jdn(y, m, d);
1184    let jdn_int = (jdn + 0.5).floor() as i64;
1185    // ISO day of week: 1=Monday, 7=Sunday.
1186    let dow = (jdn_int + 1) % 7;
1187    let iso_dow = if dow == 0 { 7 } else { dow };
1188
1189    // Thursday of the same week determines the year.
1190    let thu_jdn = jdn_int + (4 - iso_dow);
1191    let (thu_y, _, _) = jdn_to_ymd(thu_jdn as f64);
1192
1193    // Jan 4 is always in week 1 (ISO 8601).
1194    let jan4_jdn = (ymd_to_jdn(thu_y, 1, 4) + 0.5).floor() as i64;
1195    let jan4_dow = (jan4_jdn + 1) % 7;
1196    let jan4_iso_dow = if jan4_dow == 0 { 7 } else { jan4_dow };
1197    let week1_start = jan4_jdn - (jan4_iso_dow - 1);
1198
1199    let week = (thu_jdn - week1_start) / 7 + 1;
1200    (thu_y, week)
1201}
1202
1203// ── timediff ──────────────────────────────────────────────────────────────
1204
1205fn timediff_impl(jdn1: f64, jdn2: f64) -> String {
1206    let (sign, start_jdn, end_jdn) = if jdn1 >= jdn2 {
1207        ('+', jdn2, jdn1)
1208    } else {
1209        ('-', jdn1, jdn2)
1210    };
1211
1212    let (start_y, start_mo, start_d) = jdn_to_ymd(start_jdn);
1213    let (start_h, start_mi, mut start_s, start_frac) = jdn_to_hms(start_jdn);
1214    let mut start_ms = (start_frac * 1000.0).round() as i64;
1215    if start_ms >= 1000 {
1216        start_ms = 0;
1217        start_s += 1;
1218    }
1219
1220    let (end_y, end_mo, end_d) = jdn_to_ymd(end_jdn);
1221    let (end_h, end_mi, mut end_s, end_frac) = jdn_to_hms(end_jdn);
1222    let mut end_ms = (end_frac * 1000.0).round() as i64;
1223    if end_ms >= 1000 {
1224        end_ms = 0;
1225        end_s += 1;
1226    }
1227
1228    let mut years = end_y - start_y;
1229    let mut months = end_mo - start_mo;
1230    let mut days = end_d - start_d;
1231    let mut hours = end_h - start_h;
1232    let mut minutes = end_mi - start_mi;
1233    let mut seconds = end_s - start_s;
1234    let mut millis = end_ms - start_ms;
1235
1236    if millis < 0 {
1237        millis += 1000;
1238        seconds -= 1;
1239    }
1240    if seconds < 0 {
1241        seconds += 60;
1242        minutes -= 1;
1243    }
1244    if minutes < 0 {
1245        minutes += 60;
1246        hours -= 1;
1247    }
1248    if hours < 0 {
1249        hours += 24;
1250        days -= 1;
1251    }
1252    if days < 0 {
1253        months -= 1;
1254        let (borrow_y, borrow_mo) = if end_mo == 1 {
1255            (end_y - 1, 12)
1256        } else {
1257            (end_y, end_mo - 1)
1258        };
1259        days += days_in_month(borrow_y, borrow_mo);
1260    }
1261    if months < 0 {
1262        months += 12;
1263        years -= 1;
1264    }
1265
1266    format!(
1267        "{sign}{years:04}-{months:02}-{days:02} {hours:02}:{minutes:02}:{seconds:02}.{millis:03}"
1268    )
1269}
1270
1271// ── Scalar Function Implementations ───────────────────────────────────────
1272
1273/// Return whether an evaluated built-in date/time call is safe in a schema
1274/// expression whose value must remain stable for the lifetime of the schema.
1275///
1276/// SQLite's date/time functions are conditionally deterministic. Fixed text,
1277/// numeric, and row-derived values are safe, but the following forms consult
1278/// wall-clock or host-local state and must not be used while maintaining an
1279/// expression index, partial index, generated column, or CHECK constraint:
1280///
1281/// - an omitted time value (for example, `date()` or `strftime('%Y')`);
1282/// - `now` as a time value;
1283/// - `subsec` or `subsecond` in the first time-value position, where SQLite
1284///   treats it as shorthand for the current time with subsecond precision;
1285/// - `localtime` or `utc` in a modifier position.
1286///
1287/// The argument layout is function-specific: `strftime`'s format occupies
1288/// `args[0]`, while both `timediff` arguments are time values and neither is a
1289/// modifier. NULLs preserve SQLite's left-to-right short-circuit behaviour: a
1290/// NULL encountered before a conditional feature makes the call return NULL
1291/// without consulting that feature.
1292///
1293/// Names other than the seven built-in date/time functions return `true`.
1294/// Callers that permit user-defined overrides must resolve function identity
1295/// before relying on this helper; a custom function merely named `date` is not
1296/// necessarily governed by SQLite's built-in date/time rules.
1297#[must_use]
1298pub fn is_datetime_invocation_safe_for_schema(function_name: &str, args: &[SqliteValue]) -> bool {
1299    if function_name.eq_ignore_ascii_case("strftime") {
1300        // C SQLite returns NULL before evaluating the time arguments when the
1301        // format is absent or NULL. With a non-NULL format, an omitted time
1302        // value means "now".
1303        return args.first().is_none_or(SqliteValue::is_null)
1304            || datetime_arguments_are_schema_safe(&args[1..]);
1305    }
1306
1307    if function_name.eq_ignore_ascii_case("timediff") {
1308        // Wrong-arity calls are rejected by function resolution and never
1309        // invoke timediff. For the valid arity, each argument is independently
1310        // parsed as a time value; neither position accepts modifiers.
1311        if args.len() != 2 {
1312            return true;
1313        }
1314        for time_value in args {
1315            match classify_time_value_for_schema(time_value) {
1316                SchemaTimeValue::Dynamic => return false,
1317                SchemaTimeValue::NullOrInvalid => return true,
1318                SchemaTimeValue::Fixed { .. } => {}
1319            }
1320        }
1321        return true;
1322    }
1323
1324    if function_name.eq_ignore_ascii_case("date")
1325        || function_name.eq_ignore_ascii_case("time")
1326        || function_name.eq_ignore_ascii_case("datetime")
1327        || function_name.eq_ignore_ascii_case("julianday")
1328        || function_name.eq_ignore_ascii_case("unixepoch")
1329    {
1330        return datetime_arguments_are_schema_safe(args);
1331    }
1332
1333    true
1334}
1335
1336/// Check the common `(time-value, modifier...)` date/time call shape.
1337fn datetime_arguments_are_schema_safe(args: &[SqliteValue]) -> bool {
1338    let Some(time_value) = args.first() else {
1339        return false;
1340    };
1341    let (input, raw_numeric) = match classify_time_value_for_schema(time_value) {
1342        SchemaTimeValue::Dynamic => return false,
1343        SchemaTimeValue::NullOrInvalid => return true,
1344        SchemaTimeValue::Fixed { input, raw_numeric } => (input, raw_numeric),
1345    };
1346
1347    let mut reached_modifiers = Vec::with_capacity(args.len().saturating_sub(1));
1348    for modifier in &args[1..] {
1349        if modifier.is_null() {
1350            return true;
1351        }
1352        if sqlite_value_is_keyword(modifier, "localtime")
1353            || sqlite_value_is_keyword(modifier, "utc")
1354        {
1355            return false;
1356        }
1357        let Some(modifier) = sqlite_value_datetime_text(modifier) else {
1358            return true;
1359        };
1360        reached_modifiers.push(modifier.into_owned());
1361        if apply_modifiers(input, &reached_modifiers, raw_numeric).is_none() {
1362            // Evaluation stops at the first invalid modifier. A conditional
1363            // token farther to the right is therefore unreachable.
1364            return true;
1365        }
1366    }
1367    true
1368}
1369
1370enum SchemaTimeValue {
1371    Dynamic,
1372    NullOrInvalid,
1373    Fixed { input: f64, raw_numeric: bool },
1374}
1375
1376fn classify_time_value_for_schema(value: &SqliteValue) -> SchemaTimeValue {
1377    if value.is_null() {
1378        return SchemaTimeValue::NullOrInvalid;
1379    }
1380    if is_implicit_now_time_value(value) {
1381        return SchemaTimeValue::Dynamic;
1382    }
1383    match parse_time_value(value) {
1384        Some(parsed) => SchemaTimeValue::Fixed {
1385            input: parsed.jdn,
1386            raw_numeric: parsed.raw_numeric,
1387        },
1388        None => SchemaTimeValue::NullOrInvalid,
1389    }
1390}
1391
1392fn is_implicit_now_time_value(value: &SqliteValue) -> bool {
1393    sqlite_value_is_keyword(value, "now")
1394        || sqlite_value_is_keyword(value, "subsec")
1395        || sqlite_value_is_keyword(value, "subsecond")
1396}
1397
1398/// Compare SQLite's NUL-terminated text view without allocating. Special
1399/// date/time words are ASCII-case-insensitive but do not permit surrounding
1400/// whitespace. Numeric values cannot equal any keyword checked here.
1401fn sqlite_value_is_keyword(value: &SqliteValue, keyword: &str) -> bool {
1402    let bytes = match value {
1403        SqliteValue::Text(text) => sqlite_c_string_bytes(text.as_bytes_direct()),
1404        SqliteValue::Blob(bytes) => sqlite_c_string_bytes(bytes),
1405        SqliteValue::Null | SqliteValue::Integer(_) | SqliteValue::Float(_) => return false,
1406    };
1407    bytes.eq_ignore_ascii_case(keyword.as_bytes())
1408}
1409
1410#[derive(Clone, Copy)]
1411struct ParsedTimeValue {
1412    jdn: f64,
1413    raw_numeric: bool,
1414    unmodified_hms: Option<UnmodifiedHms>,
1415}
1416
1417fn parse_time_value(value: &SqliteValue) -> Option<ParsedTimeValue> {
1418    match value {
1419        SqliteValue::Null => None,
1420        SqliteValue::Integer(integer) => Some(ParsedTimeValue {
1421            jdn: *integer as f64,
1422            raw_numeric: true,
1423            unmodified_hms: None,
1424        }),
1425        SqliteValue::Float(float) if float.is_finite() => Some(ParsedTimeValue {
1426            jdn: *float,
1427            raw_numeric: true,
1428            unmodified_hms: None,
1429        }),
1430        SqliteValue::Float(_) => None,
1431        SqliteValue::Text(_) | SqliteValue::Blob(_) => {
1432            let text = sqlite_value_datetime_text(value)?;
1433            let numeric = text.trim_matches(|c: char| c.is_ascii_whitespace());
1434            if let Ok(number) = numeric.parse::<f64>()
1435                && number.is_finite()
1436            {
1437                return Some(ParsedTimeValue {
1438                    jdn: number,
1439                    raw_numeric: true,
1440                    unmodified_hms: None,
1441                });
1442            }
1443            Some(ParsedTimeValue {
1444                jdn: parse_timestring(text.as_ref())?,
1445                raw_numeric: false,
1446                unmodified_hms: unmodified_hms_from_timestring(text.as_ref()),
1447            })
1448        }
1449    }
1450}
1451
1452fn unmodified_hms_from_timestring(value: &str) -> Option<UnmodifiedHms> {
1453    let value = sqlite_c_string_str(value).trim_end_matches(|c: char| c.is_ascii_whitespace());
1454    let time = if value.len() > 10
1455        && value.as_bytes().get(4) == Some(&b'-')
1456        && value.as_bytes().get(7) == Some(&b'-')
1457        && value
1458            .as_bytes()
1459            .get(10)
1460            .is_some_and(|separator| matches!(*separator, b' ' | b'T'))
1461    {
1462        &value[11..]
1463    } else if value.len() >= 5 && value.as_bytes().get(2) == Some(&b':') {
1464        value
1465    } else {
1466        return None;
1467    };
1468    let (hour, minute, second, fraction, timezone_offset) = parse_time_part_with_tz(time)?;
1469    (timezone_offset == 0).then_some(UnmodifiedHms {
1470        hour,
1471        minute,
1472        second,
1473        fraction,
1474    })
1475}
1476
1477struct ParsedDateTimeArgs {
1478    jdn: f64,
1479    subsec: bool,
1480    unmodified_hms: Option<UnmodifiedHms>,
1481}
1482
1483/// Parse the common `(time-value, modifier...)` argument layout.
1484fn parse_args(args: &[SqliteValue]) -> Option<ParsedDateTimeArgs> {
1485    let first_position_subsec = args.first().is_some_and(|value| {
1486        sqlite_value_is_keyword(value, "subsec") || sqlite_value_is_keyword(value, "subsecond")
1487    });
1488    let parsed = match args.first() {
1489        None => ParsedTimeValue {
1490            jdn: current_time_jdn(),
1491            raw_numeric: false,
1492            unmodified_hms: None,
1493        },
1494        Some(value) => parse_time_value(value)?,
1495    };
1496
1497    let mut modifiers = Vec::with_capacity(args.len().saturating_sub(1));
1498    for modifier in args.get(1..).unwrap_or_default() {
1499        modifiers.push(sqlite_value_datetime_text(modifier)?.into_owned());
1500    }
1501
1502    // C SQLite rejects a numeric argument outside the representable
1503    // Julian-day range (date.c validJulianDay), returning NULL rather than
1504    // formatting a saturated garbage date — unless the first modifier
1505    // reinterprets the raw number ('unixepoch', 'julianday', 'auto').
1506    if parsed.raw_numeric {
1507        let first = modifiers
1508            .first()
1509            .map(|modifier| modifier.to_ascii_lowercase());
1510        let reinterprets_raw = matches!(first.as_deref(), Some("unixepoch" | "julianday" | "auto"));
1511        if !reinterprets_raw && !(0.0..=AUTO_JDN_MAX).contains(&parsed.jdn) {
1512            return None;
1513        }
1514    }
1515
1516    let preserves_input_hms = modifiers.iter().all(|modifier| {
1517        modifier.eq_ignore_ascii_case("subsec") || modifier.eq_ignore_ascii_case("subsecond")
1518    });
1519    let (jdn, modifier_subsec) = apply_modifiers(parsed.jdn, &modifiers, parsed.raw_numeric)?;
1520    // C SQLite's date functions return NULL when the *computed* result (after
1521    // every modifier) leaves the representable Julian-day range — not only when
1522    // the raw numeric input does (checked above). Mirror date.c's validJulianDay
1523    // on the final value so a modifier that overflows past 9999-12-31 23:59:59
1524    // (or below Julian day 0) yields NULL rather than a malformed 5-digit-year
1525    // string or an out-of-range julianday()/unixepoch() number. Verified against
1526    // SQLite 3.53: e.g. datetime('9999-12-31 23:59:59','+1 second') is NULL.
1527    if !(0.0..=AUTO_JDN_MAX).contains(&jdn) {
1528        return None;
1529    }
1530    Some(ParsedDateTimeArgs {
1531        jdn,
1532        subsec: first_position_subsec || modifier_subsec,
1533        unmodified_hms: preserves_input_hms
1534            .then_some(parsed.unmodified_hms)
1535            .flatten(),
1536    })
1537}
1538
1539// ── date() ────────────────────────────────────────────────────────────────
1540
1541pub struct DateFunc;
1542
1543impl ScalarFunction for DateFunc {
1544    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1545        match parse_args(args) {
1546            Some(parsed) => Ok(SqliteValue::Text(format_date(parsed.jdn))),
1547            None => Ok(SqliteValue::Null),
1548        }
1549    }
1550
1551    fn num_args(&self) -> i32 {
1552        -1
1553    }
1554
1555    fn is_deterministic(&self) -> bool {
1556        false
1557    }
1558
1559    fn name(&self) -> &str {
1560        "date"
1561    }
1562}
1563
1564// ── time() ────────────────────────────────────────────────────────────────
1565
1566pub struct TimeFunc;
1567
1568impl ScalarFunction for TimeFunc {
1569    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1570        match parse_args(args) {
1571            Some(parsed) => Ok(SqliteValue::Text(format_time(
1572                parsed.jdn,
1573                parsed.subsec,
1574                parsed.unmodified_hms,
1575            ))),
1576            None => Ok(SqliteValue::Null),
1577        }
1578    }
1579
1580    fn num_args(&self) -> i32 {
1581        -1
1582    }
1583
1584    fn is_deterministic(&self) -> bool {
1585        false
1586    }
1587
1588    fn name(&self) -> &str {
1589        "time"
1590    }
1591}
1592
1593// ── datetime() ────────────────────────────────────────────────────────────
1594
1595pub struct DateTimeFunc;
1596
1597impl ScalarFunction for DateTimeFunc {
1598    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1599        match parse_args(args) {
1600            Some(parsed) => Ok(SqliteValue::Text(format_datetime(
1601                parsed.jdn,
1602                parsed.subsec,
1603                parsed.unmodified_hms,
1604            ))),
1605            None => Ok(SqliteValue::Null),
1606        }
1607    }
1608
1609    fn num_args(&self) -> i32 {
1610        -1
1611    }
1612
1613    fn is_deterministic(&self) -> bool {
1614        false
1615    }
1616
1617    fn name(&self) -> &str {
1618        "datetime"
1619    }
1620}
1621
1622// ── julianday() ───────────────────────────────────────────────────────────
1623
1624pub struct JuliandayFunc;
1625
1626impl ScalarFunction for JuliandayFunc {
1627    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1628        match parse_args(args) {
1629            Some(parsed) => Ok(SqliteValue::Float(parsed.jdn)),
1630            None => Ok(SqliteValue::Null),
1631        }
1632    }
1633
1634    fn num_args(&self) -> i32 {
1635        -1
1636    }
1637
1638    fn is_deterministic(&self) -> bool {
1639        false
1640    }
1641
1642    fn name(&self) -> &str {
1643        "julianday"
1644    }
1645}
1646
1647// ── unixepoch() ───────────────────────────────────────────────────────────
1648
1649pub struct UnixepochFunc;
1650
1651impl ScalarFunction for UnixepochFunc {
1652    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1653        match parse_args(args) {
1654            // bd-855l7: the 'subsec'/'subsecond' modifier makes unixepoch return
1655            // a floating-point value carrying the fractional seconds.
1656            Some(parsed) if parsed.subsec => Ok(SqliteValue::Float(jdn_to_unix_subsec(parsed.jdn))),
1657            Some(parsed) => Ok(SqliteValue::Integer(jdn_to_unix(parsed.jdn))),
1658            None => Ok(SqliteValue::Null),
1659        }
1660    }
1661
1662    fn num_args(&self) -> i32 {
1663        -1
1664    }
1665
1666    fn is_deterministic(&self) -> bool {
1667        false
1668    }
1669
1670    fn name(&self) -> &str {
1671        "unixepoch"
1672    }
1673}
1674
1675// ── strftime() ────────────────────────────────────────────────────────────
1676
1677pub struct StrftimeFunc;
1678
1679impl ScalarFunction for StrftimeFunc {
1680    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1681        let Some(format_value) = args.first() else {
1682            return Ok(SqliteValue::Null);
1683        };
1684        let Some(fmt) = sqlite_value_datetime_text(format_value) else {
1685            return Ok(SqliteValue::Null);
1686        };
1687        let rest = &args[1..];
1688        match parse_args(rest) {
1689            Some(parsed) => Ok(
1690                match format_strftime(
1691                    fmt.as_ref(),
1692                    parsed.jdn,
1693                    parsed.subsec,
1694                    parsed.unmodified_hms,
1695                ) {
1696                    // An unknown specifier in the format yields NULL (bd-13mqo).
1697                    Some(text) => SqliteValue::Text(text.into()),
1698                    None => SqliteValue::Null,
1699                },
1700            ),
1701            None => Ok(SqliteValue::Null),
1702        }
1703    }
1704
1705    fn num_args(&self) -> i32 {
1706        -1
1707    }
1708
1709    fn is_deterministic(&self) -> bool {
1710        false
1711    }
1712
1713    fn name(&self) -> &str {
1714        "strftime"
1715    }
1716}
1717
1718// ── timediff() ────────────────────────────────────────────────────────────
1719
1720pub struct TimediffFunc;
1721
1722impl ScalarFunction for TimediffFunc {
1723    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1724        if args.len() < 2 || args[0].is_null() || args[1].is_null() {
1725            return Ok(SqliteValue::Null);
1726        }
1727
1728        let jdn1 = parse_time_value(&args[0])
1729            .map(|parsed| parsed.jdn)
1730            .filter(|jdn| (0.0..=AUTO_JDN_MAX).contains(jdn));
1731        let jdn2 = parse_time_value(&args[1])
1732            .map(|parsed| parsed.jdn)
1733            .filter(|jdn| (0.0..=AUTO_JDN_MAX).contains(jdn));
1734
1735        match (jdn1, jdn2) {
1736            (Some(j1), Some(j2)) => Ok(SqliteValue::Text(timediff_impl(j1, j2).into())),
1737            _ => Ok(SqliteValue::Null),
1738        }
1739    }
1740
1741    fn num_args(&self) -> i32 {
1742        2
1743    }
1744
1745    fn is_deterministic(&self) -> bool {
1746        false
1747    }
1748
1749    fn name(&self) -> &str {
1750        "timediff"
1751    }
1752}
1753
1754// ── Registration ──────────────────────────────────────────────────────────
1755
1756/// Register all §13.3 date/time functions.
1757pub fn register_datetime_builtins(registry: &mut FunctionRegistry) {
1758    registry.register_conditionally_deterministic_scalar(DateFunc);
1759    registry.register_conditionally_deterministic_scalar(TimeFunc);
1760    registry.register_conditionally_deterministic_scalar(DateTimeFunc);
1761    registry.register_conditionally_deterministic_scalar(JuliandayFunc);
1762    registry.register_conditionally_deterministic_scalar(UnixepochFunc);
1763    registry.register_conditionally_deterministic_scalar(StrftimeFunc);
1764    registry.register_conditionally_deterministic_scalar(TimediffFunc);
1765}
1766
1767// ── Tests ─────────────────────────────────────────────────────────────────
1768
1769#[cfg(test)]
1770mod tests {
1771    use super::*;
1772
1773    // bd-tl6ly: the `utc` modifier must resolve DST-transition ambiguity to the
1774    // pre-transition offset exactly as stock sqlite3 does. This is TZ-dependent,
1775    // so it is gated on TZ=America/New_York (set_var is unsafe / forbidden here,
1776    // so the harness/CI must run the process with that TZ); it skips otherwise
1777    // so it stays portable. Expected values were captured from sqlite3 3.46.1.
1778    #[test]
1779    #[cfg(not(target_arch = "wasm32"))]
1780    fn utc_modifier_dst_transition_matches_stock_bd_tl6ly() {
1781        if std::env::var("TZ").ok().as_deref() != Some("America/New_York") {
1782            eprintln!(
1783                "SKIP utc_modifier_dst_transition_matches_stock_bd_tl6ly: needs TZ=America/New_York"
1784            );
1785            return;
1786        }
1787        let utc_of = |y, mo, d, h, mi, s| -> (i64, i64, i64, i64, i64, i64) {
1788            let jdn = ymdhms_to_jdn(y, mo, d, h, mi, s, 0.0);
1789            let out = apply_modifier(jdn, "utc").expect("utc modifier");
1790            let (yy, mm, dd) = jdn_to_ymd(out);
1791            let (hh, nn, ss, _f) = jdn_to_hms(out);
1792            (yy, mm, dd, hh, nn, ss)
1793        };
1794        // normal winter EST(-5): 2024-01-15 12:00 local -> 17:00 UTC
1795        assert_eq!(utc_of(2024, 1, 15, 12, 0, 0), (2024, 1, 15, 17, 0, 0));
1796        // normal summer EDT(-4): 2024-07-15 12:00 local -> 16:00 UTC
1797        assert_eq!(utc_of(2024, 7, 15, 12, 0, 0), (2024, 7, 15, 16, 0, 0));
1798        // near-transition, unambiguous EDT: 2024-03-10 06:30 -> 10:30 (-4)
1799        assert_eq!(utc_of(2024, 3, 10, 6, 30, 0), (2024, 3, 10, 10, 30, 0));
1800        // spring-forward GAP (02:30 is nonexistent) -> pre-transition EST(-5) -> 07:30
1801        assert_eq!(utc_of(2024, 3, 10, 2, 30, 0), (2024, 3, 10, 7, 30, 0));
1802        // fall-back FOLD (01:30 occurs twice) -> pre-transition EDT(-4) -> 05:30
1803        assert_eq!(utc_of(2024, 11, 3, 1, 30, 0), (2024, 11, 3, 5, 30, 0));
1804    }
1805
1806    fn text(s: &str) -> SqliteValue {
1807        SqliteValue::Text(s.into())
1808    }
1809
1810    fn int(v: i64) -> SqliteValue {
1811        SqliteValue::Integer(v)
1812    }
1813
1814    fn float(v: f64) -> SqliteValue {
1815        SqliteValue::Float(v)
1816    }
1817
1818    fn null() -> SqliteValue {
1819        SqliteValue::Null
1820    }
1821
1822    fn assert_text(result: &SqliteValue, expected: &str) {
1823        match result {
1824            SqliteValue::Text(s) => assert_eq!(s.as_ref(), expected, "text mismatch"),
1825            other => panic!("expected Text(\"{expected}\"), got {other:?}"),
1826        }
1827    }
1828
1829    // ── Schema-expression safety ─────────────────────────────────────────
1830
1831    fn blob(s: &str) -> SqliteValue {
1832        SqliteValue::Blob(s.as_bytes().into())
1833    }
1834
1835    #[test]
1836    fn test_schema_safety_common_datetime_argument_layout() {
1837        for name in ["date", "time", "datetime", "julianday", "unixepoch"] {
1838            assert!(
1839                !is_datetime_invocation_safe_for_schema(name, &[]),
1840                "{name}() implicitly reads the current time"
1841            );
1842            assert!(is_datetime_invocation_safe_for_schema(
1843                name,
1844                &[text("2024-03-15 12:34:56")]
1845            ));
1846            assert!(is_datetime_invocation_safe_for_schema(name, &[int(0)]));
1847            assert!(is_datetime_invocation_safe_for_schema(
1848                name,
1849                &[float(2_460_384.5)]
1850            ));
1851            assert!(is_datetime_invocation_safe_for_schema(name, &[null()]));
1852
1853            for current_time in ["now", "NOW", "subsec", "SUBSECOND"] {
1854                assert!(
1855                    !is_datetime_invocation_safe_for_schema(name, &[text(current_time)]),
1856                    "{name}({current_time:?}) must be conditional"
1857                );
1858            }
1859            assert!(!is_datetime_invocation_safe_for_schema(
1860                name,
1861                &[blob("NOW")]
1862            ));
1863            assert!(!is_datetime_invocation_safe_for_schema(
1864                name,
1865                &[text("now\0ignored")]
1866            ));
1867            assert!(!is_datetime_invocation_safe_for_schema(
1868                name,
1869                &[SqliteValue::Blob(b"subsec\0\xff".as_slice().into())]
1870            ));
1871            for invalid_padded in [" now ", "subsec ", " subsecond"] {
1872                assert!(is_datetime_invocation_safe_for_schema(
1873                    name,
1874                    &[text(invalid_padded)]
1875                ));
1876            }
1877            assert!(is_datetime_invocation_safe_for_schema(
1878                name,
1879                &[blob(" NoW ")]
1880            ));
1881            assert!(is_datetime_invocation_safe_for_schema(
1882                name,
1883                &[text("nowhere")]
1884            ));
1885
1886            // These words only have conditional meaning in modifier position.
1887            assert!(is_datetime_invocation_safe_for_schema(
1888                name,
1889                &[text("localtime")]
1890            ));
1891            assert!(is_datetime_invocation_safe_for_schema(name, &[text("utc")]));
1892            assert!(is_datetime_invocation_safe_for_schema(
1893                name,
1894                &[text("2024-03-15"), text("subsec")]
1895            ));
1896            assert!(is_datetime_invocation_safe_for_schema(
1897                name,
1898                &[text("2024-03-15"), text("subsecond")]
1899            ));
1900
1901            for modifier in ["localtime", "LOCALTIME", "utc"] {
1902                assert!(
1903                    !is_datetime_invocation_safe_for_schema(
1904                        name,
1905                        &[text("2024-03-15"), text(modifier)]
1906                    ),
1907                    "{name} modifier {modifier:?} depends on host-local state"
1908                );
1909            }
1910            assert!(!is_datetime_invocation_safe_for_schema(
1911                name,
1912                &[text("2024-03-15"), blob("UTC")]
1913            ));
1914            assert!(!is_datetime_invocation_safe_for_schema(
1915                name,
1916                &[text("2024-03-15"), text("localtime\0ignored")]
1917            ));
1918            assert!(is_datetime_invocation_safe_for_schema(
1919                name,
1920                &[text("2024-03-15"), text(" utc ")]
1921            ));
1922
1923            // A prior NULL returns NULL without reaching later modifiers.
1924            assert!(is_datetime_invocation_safe_for_schema(
1925                name,
1926                &[text("2024-03-15"), null(), text("localtime")]
1927            ));
1928            assert!(!is_datetime_invocation_safe_for_schema(
1929                name,
1930                &[text("2024-03-15"), text("localtime"), null()]
1931            ));
1932
1933            // Invalid input or an invalid modifier stops evaluation before a
1934            // later conditional token is reached.
1935            assert!(is_datetime_invocation_safe_for_schema(
1936                name,
1937                &[text("bogus"), text("localtime")]
1938            ));
1939            assert!(is_datetime_invocation_safe_for_schema(
1940                name,
1941                &[text("2000-01-01"), text("bogus"), text("localtime")]
1942            ));
1943        }
1944    }
1945
1946    #[test]
1947    fn test_schema_safety_strftime_uses_shifted_time_arguments() {
1948        assert!(is_datetime_invocation_safe_for_schema("strftime", &[]));
1949        assert!(is_datetime_invocation_safe_for_schema(
1950            "strftime",
1951            &[null()]
1952        ));
1953        assert!(is_datetime_invocation_safe_for_schema(
1954            "strftime",
1955            &[null(), text("now")]
1956        ));
1957
1958        // A format without a time value implicitly formats the current time.
1959        assert!(!is_datetime_invocation_safe_for_schema(
1960            "strftime",
1961            &[text("%Y")]
1962        ));
1963        assert!(!is_datetime_invocation_safe_for_schema(
1964            "STRFTIME",
1965            &[text("%Y"), text("now")]
1966        ));
1967        assert!(!is_datetime_invocation_safe_for_schema(
1968            "strftime",
1969            &[text("%s"), text("subsecond")]
1970        ));
1971
1972        // Keywords in the format slot are ordinary fixed format strings.
1973        assert!(is_datetime_invocation_safe_for_schema(
1974            "strftime",
1975            &[text("now localtime utc"), text("2024-03-15")]
1976        ));
1977        assert!(is_datetime_invocation_safe_for_schema(
1978            "strftime",
1979            &[text("%Y"), int(0), text("unixepoch")]
1980        ));
1981        assert!(is_datetime_invocation_safe_for_schema(
1982            "strftime",
1983            &[text("%f"), text("2024-03-15"), text("subsec")]
1984        ));
1985        assert!(!is_datetime_invocation_safe_for_schema(
1986            "strftime",
1987            &[text("%Y"), text("2024-03-15"), text("localtime")]
1988        ));
1989        assert!(is_datetime_invocation_safe_for_schema(
1990            "strftime",
1991            &[text("%Y"), text("2024-03-15"), null(), text("localtime")]
1992        ));
1993    }
1994
1995    #[test]
1996    fn test_schema_safety_timediff_treats_both_inputs_as_time_values() {
1997        assert!(is_datetime_invocation_safe_for_schema("timediff", &[]));
1998        assert!(is_datetime_invocation_safe_for_schema(
1999            "timediff",
2000            &[text("2024-03-15")]
2001        ));
2002        assert!(is_datetime_invocation_safe_for_schema(
2003            "timediff",
2004            &[text("2024-03-15"), text("2024-03-14")]
2005        ));
2006        assert!(is_datetime_invocation_safe_for_schema(
2007            "timediff",
2008            &[int(2_460_384), float(2_460_383.5)]
2009        ));
2010
2011        for current_time in ["now", "subsec", "subsecond"] {
2012            assert!(!is_datetime_invocation_safe_for_schema(
2013                "timediff",
2014                &[text(current_time), text("2024-03-14")]
2015            ));
2016            assert!(!is_datetime_invocation_safe_for_schema(
2017                "timediff",
2018                &[text("2024-03-15"), text(current_time)]
2019            ));
2020        }
2021
2022        // timediff has no modifier positions, so these are fixed (invalid)
2023        // time strings rather than requests for host-local conversion.
2024        assert!(is_datetime_invocation_safe_for_schema(
2025            "timediff",
2026            &[text("localtime"), text("utc")]
2027        ));
2028        assert!(is_datetime_invocation_safe_for_schema(
2029            "timediff",
2030            &[null(), text("now")]
2031        ));
2032        assert!(is_datetime_invocation_safe_for_schema(
2033            "timediff",
2034            &[text("bogus"), text("now")]
2035        ));
2036        assert!(!is_datetime_invocation_safe_for_schema(
2037            "timediff",
2038            &[blob("NOW"), null()]
2039        ));
2040    }
2041
2042    #[test]
2043    fn test_schema_safety_ignores_non_datetime_function_names() {
2044        for name in ["", "my_date", "current_date", "date ", "random"] {
2045            assert!(is_datetime_invocation_safe_for_schema(
2046                name,
2047                &[text("now"), text("localtime")]
2048            ));
2049        }
2050    }
2051
2052    // ── Basic functions ───────────────────────────────────────────────
2053
2054    #[test]
2055    fn test_omitted_time_value_uses_current_time() {
2056        let date = DateFunc.invoke(&[]).unwrap();
2057        let time = TimeFunc.invoke(&[]).unwrap();
2058        let datetime = DateTimeFunc.invoke(&[]).unwrap();
2059        let julianday = JuliandayFunc.invoke(&[]).unwrap();
2060        let unixepoch = UnixepochFunc.invoke(&[]).unwrap();
2061        let year = StrftimeFunc.invoke(&[text("%Y")]).unwrap();
2062
2063        assert!(matches!(&date, SqliteValue::Text(value) if value.len() == 10));
2064        assert!(matches!(&time, SqliteValue::Text(value) if value.len() == 8));
2065        assert!(matches!(&datetime, SqliteValue::Text(value) if value.len() == 19));
2066        assert!(matches!(julianday, SqliteValue::Float(_)));
2067        assert!(matches!(unixepoch, SqliteValue::Integer(_)));
2068        assert!(matches!(&year, SqliteValue::Text(value)
2069            if value.len() == 4 && value.as_bytes().iter().all(u8::is_ascii_digit)));
2070
2071        assert_eq!(StrftimeFunc.invoke(&[]).unwrap(), SqliteValue::Null);
2072        assert_eq!(StrftimeFunc.invoke(&[null()]).unwrap(), SqliteValue::Null);
2073    }
2074
2075    #[test]
2076    fn test_datetime_oracle_edges_2026_08() {
2077        // Oracle: sqlite3 3.46.1. Deterministic (TZ-independent) date/time edge
2078        // semantics — month/year overflow, floor, weekday, invalid dates, ISO
2079        // 8601 week/year boundaries, and the newer strftime codes. Some(text)
2080        // for a TEXT result, None for SQL NULL.
2081        let date = |args: &[SqliteValue]| -> Option<String> {
2082            match DateFunc.invoke(args).unwrap() {
2083                SqliteValue::Text(t) => Some(t.as_str().to_owned()),
2084                SqliteValue::Null => None,
2085                other => panic!("date -> {other:?}"),
2086            }
2087        };
2088        let dtime = |args: &[SqliteValue]| -> Option<String> {
2089            match DateTimeFunc.invoke(args).unwrap() {
2090                SqliteValue::Text(t) => Some(t.as_str().to_owned()),
2091                SqliteValue::Null => None,
2092                other => panic!("datetime -> {other:?}"),
2093            }
2094        };
2095        let strf = |args: &[SqliteValue]| -> Option<String> {
2096            match StrftimeFunc.invoke(args).unwrap() {
2097                SqliteValue::Text(t) => Some(t.as_str().to_owned()),
2098                SqliteValue::Null => None,
2099                other => panic!("strftime -> {other:?}"),
2100            }
2101        };
2102        let s = |x: &str| Some(x.to_owned());
2103
2104        // Month/year overflow normalizes forward (ceiling is the default).
2105        assert_eq!(
2106            date(&[text("2020-01-31"), text("+1 month")]),
2107            s("2020-03-02")
2108        );
2109        assert_eq!(
2110            date(&[text("2020-01-31"), text("+1 month"), text("+1 month")]),
2111            s("2020-04-02")
2112        );
2113        assert_eq!(date(&[text("2021-02-28"), text("+1 day")]), s("2021-03-01"));
2114        assert_eq!(
2115            date(&[text("2020-02-29"), text("+1 year")]),
2116            s("2021-03-01")
2117        );
2118        // A literal date validates day<=31 (NOT days-in-month), then JDN math
2119        // normalizes: Feb 30 -> Mar 1.
2120        assert_eq!(date(&[text("2020-02-30")]), s("2020-03-01"));
2121        // `floor` rolls a month/year overflow back to the last valid day.
2122        assert_eq!(
2123            date(&[text("2020-01-31"), text("+1 month"), text("floor")]),
2124            s("2020-02-29")
2125        );
2126        assert_eq!(
2127            date(&[text("2020-02-29"), text("+1.0 year"), text("floor")]),
2128            s("2021-02-28")
2129        );
2130        // start-of / weekday (0..=6; 7 is out of range -> NULL).
2131        assert_eq!(
2132            date(&[text("2020-01-31"), text("start of month")]),
2133            s("2020-01-01")
2134        );
2135        assert_eq!(
2136            date(&[text("2020-06-15"), text("weekday 0")]),
2137            s("2020-06-21")
2138        );
2139        assert_eq!(
2140            date(&[text("2020-06-15"), text("weekday 6")]),
2141            s("2020-06-20")
2142        );
2143        assert_eq!(date(&[text("2020-06-15"), text("weekday 7")]), None);
2144        // Fractional month/year (bd-datetime-fractional-month-year-ag5fo): the integer part recomposes the
2145        // calendar; the remainder adds FLAT days (30/month, 365/year) AFTER,
2146        // signed like the modifier — NOT `value * average-month-length`.
2147        assert_eq!(
2148            date(&[text("2020-01-15"), text("+1.5 months")]),
2149            s("2020-03-01")
2150        );
2151        assert_eq!(
2152            dtime(&[text("2020-01-15 00:00:00"), text("+0.5 months")]),
2153            s("2020-01-30 00:00:00")
2154        );
2155        assert_eq!(
2156            dtime(&[text("2020-01-15 00:00:00"), text("+1.5 months")]),
2157            s("2020-03-01 00:00:00")
2158        );
2159        assert_eq!(
2160            dtime(&[text("2020-01-15 00:00:00"), text("+2.5 months")]),
2161            s("2020-03-30 00:00:00")
2162        );
2163        assert_eq!(
2164            dtime(&[text("2020-01-15 00:00:00"), text("+1.25 months")]),
2165            s("2020-02-22 12:00:00")
2166        );
2167        assert_eq!(
2168            dtime(&[text("2020-01-15 00:00:00"), text("+1.75 months")]),
2169            s("2020-03-08 12:00:00")
2170        );
2171        assert_eq!(
2172            dtime(&[text("2020-01-15 00:00:00"), text("-0.5 months")]),
2173            s("2019-12-31 00:00:00")
2174        );
2175        assert_eq!(
2176            dtime(&[text("2020-01-15 00:00:00"), text("-1.5 months")]),
2177            s("2019-11-30 00:00:00")
2178        );
2179        // Integer recomposition (ceiling) happens BEFORE the fractional days.
2180        assert_eq!(
2181            dtime(&[text("2020-01-31 00:00:00"), text("+1.5 months")]),
2182            s("2020-03-17 00:00:00")
2183        );
2184        // Fractional years use 365 flat days for the remainder.
2185        assert_eq!(
2186            dtime(&[text("2020-01-15 00:00:00"), text("+1.5 years")]),
2187            s("2021-07-16 12:00:00")
2188        );
2189        assert_eq!(
2190            dtime(&[text("2020-01-15 00:00:00"), text("+0.5 years")]),
2191            s("2020-07-15 12:00:00")
2192        );
2193        // Invalid month / unparseable -> NULL.
2194        assert_eq!(date(&[text("2020-13-01")]), None);
2195        assert_eq!(date(&[text("2020-00-10")]), None);
2196        assert_eq!(date(&[text("not-a-date")]), None);
2197
2198        // datetime rendering.
2199        assert_eq!(
2200            dtime(&[text("2020-06-15 12:34:56"), text("start of day")]),
2201            s("2020-06-15 00:00:00")
2202        );
2203        assert_eq!(
2204            dtime(&[text("2020-01-01"), text("+90 minutes")]),
2205            s("2020-01-01 01:30:00")
2206        );
2207        assert_eq!(
2208            dtime(&[text("2020-01-01"), text("+1.5 days")]),
2209            s("2020-01-02 12:00:00")
2210        );
2211        assert_eq!(
2212            dtime(&[int(0), text("unixepoch")]),
2213            s("1970-01-01 00:00:00")
2214        );
2215
2216        // strftime format codes.
2217        assert_eq!(
2218            strf(&[text("%f"), text("2020-01-01 00:00:01.25")]),
2219            s("01.250")
2220        );
2221        assert_eq!(strf(&[text("%j"), text("2020-03-01")]), s("061"));
2222        assert_eq!(strf(&[text("%w"), text("2020-06-15")]), s("1"));
2223        assert_eq!(strf(&[text("%W"), text("2020-01-01")]), s("00"));
2224        assert_eq!(
2225            strf(&[text("%s"), text("2020-01-01 00:00:00")]),
2226            s("1577836800")
2227        );
2228        assert_eq!(strf(&[text("%e"), text("2020-06-05")]), s(" 5"));
2229        assert_eq!(strf(&[text("%I"), text("2020-06-15 13:00:00")]), s("01"));
2230        assert_eq!(strf(&[text("%p"), text("2020-06-15 13:00:00")]), s("PM"));
2231        assert_eq!(strf(&[text("%P"), text("2020-06-15 13:00:00")]), s("pm"));
2232        assert_eq!(strf(&[text("%R"), text("2020-06-15 13:05:00")]), s("13:05"));
2233        assert_eq!(
2234            strf(&[text("%T"), text("2020-06-15 13:05:07")]),
2235            s("13:05:07")
2236        );
2237        // ISO 8601 week/year boundaries (the trickiest: a January date whose ISO
2238        // year is the PRIOR calendar year, and week 53).
2239        assert_eq!(strf(&[text("%G"), text("2020-12-31")]), s("2020"));
2240        assert_eq!(strf(&[text("%V"), text("2020-12-31")]), s("53"));
2241        assert_eq!(strf(&[text("%u"), text("2020-06-15")]), s("1"));
2242        assert_eq!(strf(&[text("%g"), text("2021-01-01")]), s("20"));
2243        assert_eq!(strf(&[text("%V"), text("2021-01-01")]), s("53"));
2244        assert_eq!(
2245            strf(&[text("%G-%V-%u"), text("2021-01-01")]),
2246            s("2020-53-5")
2247        );
2248        assert_eq!(strf(&[text("%V"), text("2016-01-01")]), s("53"));
2249        assert_eq!(strf(&[text("%V"), text("2018-12-31")]), s("01"));
2250
2251        // julianday is exact for a round value.
2252        assert_eq!(
2253            JuliandayFunc
2254                .invoke(&[text("2000-01-01 12:00:00")])
2255                .unwrap(),
2256            SqliteValue::Float(2451545.0)
2257        );
2258    }
2259
2260    #[test]
2261    fn test_datetime_more_edges_2026_08() {
2262        // Oracle: sqlite3 3.46.1. timediff format, %J/%k/%l/%f/%s, subsec, and
2263        // out-of-range (past year 9999) -> NULL.
2264        let dtime = |args: &[SqliteValue]| -> Option<String> {
2265            match DateTimeFunc.invoke(args).unwrap() {
2266                SqliteValue::Text(t) => Some(t.as_str().to_owned()),
2267                SqliteValue::Null => None,
2268                other => panic!("datetime -> {other:?}"),
2269            }
2270        };
2271        let strf = |args: &[SqliteValue]| -> String {
2272            match StrftimeFunc.invoke(args).unwrap() {
2273                SqliteValue::Text(t) => t.as_str().to_owned(),
2274                other => panic!("strftime -> {other:?}"),
2275            }
2276        };
2277        let s = |x: &str| Some(x.to_owned());
2278        let txt = |x: &str| SqliteValue::Text(SmallText::from_string(x));
2279
2280        // timediff(A, B) = A - B, formatted +YYYY-MM-DD HH:MM:SS.SSS.
2281        assert_eq!(
2282            TimediffFunc
2283                .invoke(&[text("2020-03-01"), text("2020-01-15")])
2284                .unwrap(),
2285            txt("+0000-01-15 00:00:00.000")
2286        );
2287        assert_eq!(
2288            TimediffFunc
2289                .invoke(&[text("2020-01-15 12:00:00"), text("2020-01-15 10:30:00")])
2290                .unwrap(),
2291            txt("+0000-00-00 01:30:00.000")
2292        );
2293
2294        // strftime codes: %J full julian day, %k/%l space-padded hours, %f, %s.
2295        assert_eq!(
2296            strf(&[text("%J"), text("2000-01-01 18:00:00")]),
2297            "2451545.25"
2298        );
2299        assert_eq!(strf(&[text("%k"), text("2020-06-15 09:00:00")]), " 9");
2300        assert_eq!(strf(&[text("%l"), text("2020-06-15 13:00:00")]), " 1");
2301        assert_eq!(
2302            strf(&[text("%f"), text("2020-01-01 00:00:00.999")]),
2303            "00.999"
2304        );
2305        assert_eq!(
2306            strf(&[text("%s"), text("2020-01-01 00:00:00.5")]),
2307            "1577836800"
2308        );
2309
2310        // subsec / unixepoch-of-float / out-of-range.
2311        assert_eq!(
2312            dtime(&[text("2020-01-01 00:00:00"), text("subsec")]),
2313            s("2020-01-01 00:00:00.000")
2314        );
2315        assert_eq!(
2316            dtime(&[float(1_577_836_800.5), text("unixepoch")]),
2317            s("2020-01-01 00:00:00")
2318        );
2319        assert_eq!(dtime(&[text("2020-01-01"), text("+100000000 days")]), None);
2320
2321        // julianday / unixepoch subsec (exact float).
2322        assert_eq!(
2323            JuliandayFunc.invoke(&[text("1970-01-01")]).unwrap(),
2324            SqliteValue::Float(2440587.5)
2325        );
2326        assert_eq!(
2327            UnixepochFunc
2328                .invoke(&[text("2020-01-01"), text("subsec")])
2329                .unwrap(),
2330            SqliteValue::Float(1_577_836_800.0)
2331        );
2332    }
2333
2334    #[test]
2335    fn test_first_position_subsec_aliases_use_current_time() {
2336        for alias in ["subsec", "subsecond"] {
2337            assert!(matches!(
2338                DateFunc.invoke(&[text(alias)]).unwrap(),
2339                SqliteValue::Text(value) if value.len() == 10
2340            ));
2341            assert!(matches!(
2342                TimeFunc.invoke(&[text(alias)]).unwrap(),
2343                SqliteValue::Text(value)
2344                    if value.len() == 12 && value.as_bytes_direct()[8] == b'.'
2345            ));
2346            assert!(matches!(
2347                DateTimeFunc.invoke(&[text(alias)]).unwrap(),
2348                SqliteValue::Text(value)
2349                    if value.len() == 23 && value.as_bytes_direct()[19] == b'.'
2350            ));
2351            assert!(matches!(
2352                JuliandayFunc.invoke(&[text(alias)]).unwrap(),
2353                SqliteValue::Float(_)
2354            ));
2355            assert!(matches!(
2356                UnixepochFunc.invoke(&[text(alias)]).unwrap(),
2357                SqliteValue::Float(_)
2358            ));
2359            assert!(matches!(
2360                StrftimeFunc.invoke(&[text("%s"), text(alias)]).unwrap(),
2361                SqliteValue::Text(value)
2362                    if value.rsplit_once('.').is_some_and(|(_, fraction)| fraction.len() == 3)
2363            ));
2364        }
2365    }
2366
2367    #[test]
2368    fn test_padded_and_nul_terminated_special_values() {
2369        for invalid in [" now ", "subsec ", " subsecond"] {
2370            assert_eq!(
2371                DateFunc.invoke(&[text(invalid)]).unwrap(),
2372                SqliteValue::Null
2373            );
2374        }
2375        assert_eq!(
2376            DateFunc
2377                .invoke(&[text("2000-01-01"), text(" localtime ")])
2378                .unwrap(),
2379            SqliteValue::Null
2380        );
2381        assert!(matches!(
2382            DateFunc.invoke(&[text("now\0ignored")]).unwrap(),
2383            SqliteValue::Text(value) if value.len() == 10
2384        ));
2385        assert!(matches!(
2386            TimeFunc
2387                .invoke(&[SqliteValue::Blob(b"subsec\0\xff".as_slice().into())])
2388                .unwrap(),
2389            SqliteValue::Text(value) if value.len() == 12
2390        ));
2391    }
2392
2393    #[test]
2394    fn test_date_basic() {
2395        let r = DateFunc.invoke(&[text("2024-03-15 14:30:00")]).unwrap();
2396        assert_text(&r, "2024-03-15");
2397    }
2398
2399    #[test]
2400    fn test_time_basic() {
2401        let r = TimeFunc.invoke(&[text("2024-03-15 14:30:45")]).unwrap();
2402        assert_text(&r, "14:30:45");
2403    }
2404
2405    #[test]
2406    fn test_datetime_basic() {
2407        let r = DateTimeFunc.invoke(&[text("2024-03-15 14:30:00")]).unwrap();
2408        assert_text(&r, "2024-03-15 14:30:00");
2409    }
2410
2411    #[test]
2412    fn test_julianday_basic() {
2413        let r = JuliandayFunc.invoke(&[text("2024-03-15")]).unwrap();
2414        match r {
2415            SqliteValue::Float(jdn) => {
2416                // JDN for 2024-03-15 should be approximately 2460384.5
2417                assert!((jdn - 2_460_384.5).abs() < 0.01, "unexpected JDN: {jdn}");
2418            }
2419            other => panic!("expected Float, got {other:?}"),
2420        }
2421    }
2422
2423    #[test]
2424    fn test_computed_result_out_of_range_returns_null() {
2425        // C SQLite returns NULL when a modifier pushes the *computed* time
2426        // outside the representable Julian-day range — past 9999-12-31 23:59:59
2427        // or below Julian day 0 — for every date function, rather than emitting
2428        // a malformed 5-digit-year string or an out-of-range julianday()/
2429        // unixepoch() number. Verified against SQLite 3.53. Guards the
2430        // post-modifier range check in `parse_args`.
2431        let over_datetime = &[text("9999-12-31 23:59:59"), text("+1 second")];
2432        assert_eq!(
2433            DateTimeFunc.invoke(over_datetime).unwrap(),
2434            SqliteValue::Null
2435        );
2436        assert_eq!(TimeFunc.invoke(over_datetime).unwrap(), SqliteValue::Null);
2437        assert_eq!(
2438            JuliandayFunc.invoke(over_datetime).unwrap(),
2439            SqliteValue::Null
2440        );
2441        assert_eq!(
2442            UnixepochFunc.invoke(over_datetime).unwrap(),
2443            SqliteValue::Null
2444        );
2445        assert_eq!(
2446            DateFunc
2447                .invoke(&[text("9999-12-31"), text("+1 day")])
2448                .unwrap(),
2449            SqliteValue::Null
2450        );
2451        assert_eq!(
2452            DateTimeFunc
2453                .invoke(&[text("9999-12-31"), text("+1 month")])
2454                .unwrap(),
2455            SqliteValue::Null
2456        );
2457        assert_eq!(
2458            StrftimeFunc
2459                .invoke(&[
2460                    text("%Y-%m-%d"),
2461                    text("9999-12-31 23:59:59"),
2462                    text("+1 second")
2463                ])
2464                .unwrap(),
2465            SqliteValue::Null
2466        );
2467        // Lower bound: a result below Julian day 0 (before ~4714 BC) is NULL.
2468        assert_eq!(
2469            DateTimeFunc
2470                .invoke(&[text("-4714-11-24 12:00:00"), text("-1 day")])
2471                .unwrap(),
2472            SqliteValue::Null
2473        );
2474
2475        // Valid boundary / in-range cases must still succeed (no over-rejection):
2476        assert_text(
2477            &DateTimeFunc.invoke(&[text("9999-12-31 23:59:59")]).unwrap(),
2478            "9999-12-31 23:59:59",
2479        );
2480        // A negative *year* whose Julian day is still >= 0 stays valid (rendered,
2481        // not NULL) — SQLite formats such instants (e.g. -0001-12-31 ...).
2482        assert!(matches!(
2483            DateTimeFunc
2484                .invoke(&[text("0000-01-01"), text("-1 second")])
2485                .unwrap(),
2486            SqliteValue::Text(_)
2487        ));
2488    }
2489
2490    #[test]
2491    fn test_negative_year_padding_matches_sqlite() {
2492        // C SQLite's date()/datetime() render a negative (proleptic BC) year with
2493        // the sign OUTSIDE a 4-digit zero-padded magnitude: -1 -> "-0001", not the
2494        // "-001" that a naive `{:04}` (sign inside the width) produces. Verified
2495        // against SQLite 3.53.
2496        assert_text(
2497            &DateTimeFunc
2498                .invoke(&[text("0000-01-01"), text("-1 second")])
2499                .unwrap(),
2500            "-0001-12-31 23:59:59",
2501        );
2502        assert_text(
2503            &DateFunc
2504                .invoke(&[text("0000-01-01"), text("-1 day")])
2505                .unwrap(),
2506            "-0001-12-31",
2507        );
2508        assert_text(
2509            &DateTimeFunc
2510                .invoke(&[text("0000-01-01"), text("-100 years")])
2511                .unwrap(),
2512            "-0100-01-01 00:00:00",
2513        );
2514        assert_text(
2515            &DateTimeFunc
2516                .invoke(&[text("0000-01-01"), text("-4000 years")])
2517                .unwrap(),
2518            "-4000-01-01 00:00:00",
2519        );
2520        // Non-negative years are unchanged (regression guard).
2521        assert_text(
2522            &DateTimeFunc.invoke(&[text("2024-03-15 14:30:00")]).unwrap(),
2523            "2024-03-15 14:30:00",
2524        );
2525        assert_text(
2526            &DateFunc.invoke(&[text("0000-01-01")]).unwrap(),
2527            "0000-01-01",
2528        );
2529        // strftime('%Y') keeps the sign INSIDE a 4-wide field ("-001"), matching
2530        // SQLite's separate strftime path — this fix must not touch it.
2531        assert_text(
2532            &StrftimeFunc
2533                .invoke(&[text("%Y"), text("0000-01-01"), text("-1 day")])
2534                .unwrap(),
2535            "-001",
2536        );
2537    }
2538
2539    // ── RFC3339 / ISO-8601 timezone suffix parsing ────────────────────
2540    //
2541    // Regression coverage for issue #64: julianday() must accept
2542    // Z / ±HH:MM / ±HHMM / ±HH timezone-bearing timestamps and convert
2543    // them to UTC before computing the Julian day.  The expected JDN
2544    // values below match the C SQLite reference implementation.
2545
2546    fn julianday_float(input: &str) -> f64 {
2547        match JuliandayFunc.invoke(&[text(input)]).unwrap() {
2548            SqliteValue::Float(v) => v,
2549            other => panic!("expected Float, got {other:?} for input {input:?}"),
2550        }
2551    }
2552
2553    fn assert_jdn_close(actual: f64, expected: f64, ctx: &str) {
2554        // 1 µs precision (86400e6 µs / day) is well within float epsilon.
2555        assert!(
2556            (actual - expected).abs() < 1e-6,
2557            "JDN mismatch for {ctx}: got {actual}, expected {expected}"
2558        );
2559    }
2560
2561    #[test]
2562    fn test_julianday_rfc3339_z_suffix() {
2563        // Zulu (UTC) — should match the equivalent naive form exactly.
2564        let naive = julianday_float("2026-04-07 16:00:00");
2565        assert_jdn_close(julianday_float("2026-04-07T16:00:00Z"), naive, "T...Z");
2566        assert_jdn_close(
2567            julianday_float("2026-04-07T16:00:00z"),
2568            naive,
2569            "lowercase z",
2570        );
2571    }
2572
2573    #[test]
2574    fn test_julianday_rfc3339_zero_offset() {
2575        let naive = julianday_float("2026-04-07 16:00:00");
2576        assert_jdn_close(
2577            julianday_float("2026-04-07T16:00:00+00:00"),
2578            naive,
2579            "+00:00",
2580        );
2581        assert_jdn_close(
2582            julianday_float("2026-04-07T16:00:00-00:00"),
2583            naive,
2584            "-00:00",
2585        );
2586    }
2587
2588    #[test]
2589    fn test_julianday_rfc3339_positive_offset() {
2590        // 16:00 +01:00 = 15:00 UTC → JDN is 1 hour (1/24) earlier.
2591        let base = julianday_float("2026-04-07 16:00:00");
2592        let expected = base - 1.0 / 24.0;
2593        assert_jdn_close(
2594            julianday_float("2026-04-07T16:00:00+01:00"),
2595            expected,
2596            "+01:00",
2597        );
2598    }
2599
2600    #[test]
2601    fn test_julianday_rfc3339_negative_offset() {
2602        // 16:00 -05:00 = 21:00 UTC → JDN is 5 hours later.
2603        let base = julianday_float("2026-04-07 16:00:00");
2604        let expected = base + 5.0 / 24.0;
2605        assert_jdn_close(
2606            julianday_float("2026-04-07T16:00:00-05:00"),
2607            expected,
2608            "-05:00",
2609        );
2610    }
2611
2612    #[test]
2613    fn test_julianday_rfc3339_half_hour_offset() {
2614        // India Standard Time is UTC+05:30.
2615        let base = julianday_float("2026-04-07 16:00:00");
2616        let expected = base - 5.5 / 24.0;
2617        assert_jdn_close(
2618            julianday_float("2026-04-07T16:00:00+05:30"),
2619            expected,
2620            "+05:30",
2621        );
2622    }
2623
2624    #[test]
2625    fn test_julianday_rfc3339_compact_offsets() {
2626        // Compact ISO-8601 offsets: ±HHMM and ±HH.
2627        let base = julianday_float("2026-04-07 16:00:00");
2628        assert_jdn_close(
2629            julianday_float("2026-04-07T16:00:00+0100"),
2630            base - 1.0 / 24.0,
2631            "+0100",
2632        );
2633        assert_jdn_close(
2634            julianday_float("2026-04-07T16:00:00-0530"),
2635            base + 5.5 / 24.0,
2636            "-0530",
2637        );
2638        assert_jdn_close(
2639            julianday_float("2026-04-07T16:00:00+09"),
2640            base - 9.0 / 24.0,
2641            "+09",
2642        );
2643    }
2644
2645    #[test]
2646    fn test_julianday_rfc3339_fractional_seconds_with_tz() {
2647        // Fractional seconds must play nicely with the TZ suffix split.
2648        let base = julianday_float("2026-04-07 16:00:00.500");
2649        assert_jdn_close(
2650            julianday_float("2026-04-07T16:00:00.500Z"),
2651            base,
2652            "fractional + Z",
2653        );
2654        assert_jdn_close(
2655            julianday_float("2026-04-07T16:00:00.500+01:00"),
2656            base - 1.0 / 24.0,
2657            "fractional + +01:00",
2658        );
2659    }
2660
2661    #[test]
2662    fn test_date_and_time_rfc3339_round_trip() {
2663        // date()/time()/datetime() all flow through parse_timestring, so
2664        // they should agree with the timezone conversion above.
2665        assert_text(
2666            &DateFunc
2667                .invoke(&[text("2026-04-07T16:00:00+05:00")])
2668                .unwrap(),
2669            // 16:00 +05:00 = 11:00 UTC on the same date.
2670            "2026-04-07",
2671        );
2672        assert_text(
2673            &TimeFunc
2674                .invoke(&[text("2026-04-07T16:00:00+05:00")])
2675                .unwrap(),
2676            "11:00:00",
2677        );
2678        assert_text(
2679            &DateTimeFunc
2680                .invoke(&[text("2026-04-07T16:00:00+05:00")])
2681                .unwrap(),
2682            "2026-04-07 11:00:00",
2683        );
2684    }
2685
2686    #[test]
2687    fn test_julianday_rfc3339_invalid_offsets_return_null() {
2688        // Malformed offsets fall through and return NULL (invalid input).
2689        for bad in &[
2690            "2026-04-07T16:00:00+25:00", // hour out of range
2691            "2026-04-07T16:00:00+01:99", // minute out of range
2692            "2026-04-07T16:00:00+1",     // too short
2693            "2026-04-07T16:00:00+123",   // wrong width
2694        ] {
2695            let result = JuliandayFunc.invoke(&[text(bad)]).unwrap();
2696            assert_eq!(
2697                result,
2698                SqliteValue::Null,
2699                "expected NULL for malformed offset {bad:?}, got {result:?}"
2700            );
2701        }
2702    }
2703
2704    #[test]
2705    fn test_julianday_rejects_malformed_time_fields() {
2706        // C SQLite's computeHMS requires exactly 2 bare decimal digits
2707        // for each HH, MM, SS field.  Inputs with wrong digit counts,
2708        // leading signs, or non-digit characters must return NULL.
2709        for bad in &[
2710            "+01:00",        // leading + on hour
2711            "-05:30",        // leading - on hour
2712            "+12:30:00",     // leading + on hour (with seconds)
2713            "12:+30:00",     // leading + on minute
2714            "12:30:+45",     // leading + on seconds (integer)
2715            "12:30:+45.123", // leading + on seconds (fractional)
2716            "0:00:00",       // 1-digit hour
2717            "12:0:00",       // 1-digit minute
2718            "12:30:0",       // 1-digit second
2719            "123:00:00",     // 3-digit hour
2720            "12:345:00",     // 3-digit minute
2721        ] {
2722            let result = JuliandayFunc.invoke(&[text(bad)]).unwrap();
2723            assert_eq!(
2724                result,
2725                SqliteValue::Null,
2726                "expected NULL for signed time field {bad:?}, got {result:?}"
2727            );
2728        }
2729    }
2730
2731    #[test]
2732    fn test_unixepoch_basic() {
2733        let r = UnixepochFunc
2734            .invoke(&[text("1970-01-01 00:00:00")])
2735            .unwrap();
2736        assert_eq!(r, int(0));
2737    }
2738
2739    #[test]
2740    fn test_unixepoch_known_date() {
2741        let r = UnixepochFunc
2742            .invoke(&[text("2024-01-01 00:00:00")])
2743            .unwrap();
2744        // 2024-01-01 00:00:00 UTC = 1704067200
2745        assert_eq!(r, int(1_704_067_200));
2746    }
2747
2748    // ── Modifiers ─────────────────────────────────────────────────────
2749
2750    #[test]
2751    fn test_modifier_days() {
2752        let r = DateFunc
2753            .invoke(&[text("2024-01-15"), text("+10 days")])
2754            .unwrap();
2755        assert_text(&r, "2024-01-25");
2756    }
2757
2758    #[test]
2759    fn test_modifier_months() {
2760        // 2024-01-31 + 1 month: C SQLite lets day=31 overflow via JDN
2761        // arithmetic → Feb 31 wraps to Mar 2 (2024 is a leap year).
2762        let r = DateFunc
2763            .invoke(&[text("2024-01-31"), text("+1 months")])
2764            .unwrap();
2765        assert_text(&r, "2024-03-02");
2766    }
2767
2768    #[test]
2769    fn test_modifier_years() {
2770        // 2024-02-29 + 1 year: 2025 is not a leap year, day=29 overflows
2771        // via JDN arithmetic → Mar 1.
2772        let r = DateFunc
2773            .invoke(&[text("2024-02-29"), text("+1 years")])
2774            .unwrap();
2775        assert_text(&r, "2025-03-01");
2776    }
2777
2778    #[test]
2779    fn test_modifier_hours() {
2780        let r = DateTimeFunc
2781            .invoke(&[text("2024-01-01 23:00:00"), text("+2 hours")])
2782            .unwrap();
2783        assert_text(&r, "2024-01-02 01:00:00");
2784    }
2785
2786    #[test]
2787    fn test_modifier_unsigned_is_positive_bd_t8g1e() {
2788        // bd-t8g1e: SQLite's date grammar makes the modifier sign optional — a
2789        // bare `NNN units` is positive, identical to a `+`-prefixed modifier.
2790        // Frank previously required a sign and returned NULL for the unsigned
2791        // form (probe: date('2024-01-01','30 days') -> NULL vs sqlite
2792        // '2024-01-31'). Oracle: sqlite3 3.46.1.
2793        assert_text(
2794            &DateFunc
2795                .invoke(&[text("2024-01-15"), text("10 days")])
2796                .unwrap(),
2797            "2024-01-25",
2798        );
2799        assert_text(
2800            &DateTimeFunc
2801                .invoke(&[text("2024-01-01"), text("5 hours")])
2802                .unwrap(),
2803            "2024-01-01 05:00:00",
2804        );
2805        assert_text(
2806            &DateTimeFunc
2807                .invoke(&[text("2024-01-01"), text("90 minutes")])
2808                .unwrap(),
2809            "2024-01-01 01:30:00",
2810        );
2811        assert_text(
2812            &DateTimeFunc
2813                .invoke(&[text("2024-01-01"), text("86400 seconds")])
2814                .unwrap(),
2815            "2024-01-02 00:00:00",
2816        );
2817        // Month/year use exact YMD math (not the approximate day-delta) even
2818        // unsigned: + 2 months = 2024-03-01, + 1 year = 2025-01-01.
2819        assert_text(
2820            &DateFunc
2821                .invoke(&[text("2024-01-01"), text("2 months")])
2822                .unwrap(),
2823            "2024-03-01",
2824        );
2825        assert_text(
2826            &DateFunc
2827                .invoke(&[text("2024-01-01"), text("1 year")])
2828                .unwrap(),
2829            "2025-01-01",
2830        );
2831        // Fractional unsigned modifier.
2832        assert_text(
2833            &DateTimeFunc
2834                .invoke(&[text("2024-01-01 12:00"), text("1.5 hours")])
2835                .unwrap(),
2836            "2024-01-01 13:30:00",
2837        );
2838        // Regression guard: named modifiers containing "month"/"year" still work
2839        // (they reach apply_month_year_exact, fail its numeric parse, and fall
2840        // through to apply_modifier unchanged).
2841        assert_text(
2842            &DateFunc
2843                .invoke(&[text("2024-03-15"), text("start of month")])
2844                .unwrap(),
2845            "2024-03-01",
2846        );
2847        assert_text(
2848            &DateFunc
2849                .invoke(&[text("2024-06-15"), text("start of year")])
2850                .unwrap(),
2851            "2024-01-01",
2852        );
2853        // Signed forms unchanged.
2854        assert_text(
2855            &DateFunc
2856                .invoke(&[text("2024-01-15"), text("-10 days")])
2857                .unwrap(),
2858            "2024-01-05",
2859        );
2860    }
2861
2862    #[test]
2863    fn test_modifier_start_of_month() {
2864        let r = DateFunc
2865            .invoke(&[text("2024-03-15"), text("start of month")])
2866            .unwrap();
2867        assert_text(&r, "2024-03-01");
2868    }
2869
2870    #[test]
2871    fn test_modifier_start_of_year() {
2872        let r = DateFunc
2873            .invoke(&[text("2024-06-15"), text("start of year")])
2874            .unwrap();
2875        assert_text(&r, "2024-01-01");
2876    }
2877
2878    #[test]
2879    fn test_modifier_start_of_day() {
2880        let r = DateTimeFunc
2881            .invoke(&[text("2024-03-15 14:30:00"), text("start of day")])
2882            .unwrap();
2883        assert_text(&r, "2024-03-15 00:00:00");
2884    }
2885
2886    #[test]
2887    fn test_modifier_unixepoch() {
2888        let r = DateTimeFunc.invoke(&[int(0), text("unixepoch")]).unwrap();
2889        assert_text(&r, "1970-01-01 00:00:00");
2890    }
2891
2892    #[test]
2893    fn test_modifier_weekday() {
2894        // 2024-03-15 is Friday. `weekday 0` advances to the next Sunday.
2895        let r = DateFunc
2896            .invoke(&[text("2024-03-15"), text("weekday 0")])
2897            .unwrap();
2898        assert_text(&r, "2024-03-17");
2899    }
2900
2901    #[test]
2902    fn test_modifier_auto_unixepoch() {
2903        let ts = int(1_710_531_045);
2904        let r = DateTimeFunc.invoke(&[ts.clone(), text("auto")]).unwrap();
2905        let expected = DateTimeFunc.invoke(&[ts, text("unixepoch")]).unwrap();
2906        assert_eq!(
2907            r, expected,
2908            "auto and unixepoch should agree for unix-like values"
2909        );
2910    }
2911
2912    #[test]
2913    fn test_modifier_auto_julian_day() {
2914        let r = DateFunc
2915            .invoke(&[float(2_460_384.5), text("auto")])
2916            .unwrap();
2917        assert_text(&r, "2024-03-15");
2918    }
2919
2920    #[test]
2921    fn test_modifier_localtime_utc_roundtrip() {
2922        // localtime→utc should roundtrip back to the original value.
2923        let r = DateTimeFunc
2924            .invoke(&[text("2024-03-15 14:30:45"), text("localtime"), text("utc")])
2925            .unwrap();
2926        assert_text(&r, "2024-03-15 14:30:45");
2927    }
2928
2929    #[test]
2930    fn test_modifier_localtime_shifts_value() {
2931        // When system offset != 0, 'localtime' should actually shift the value.
2932        let offset = utc_offset_for_utc_jdn(ymdhms_to_jdn(2024, 3, 15, 12, 0, 0, 0.0));
2933        if offset != 0 {
2934            let r = DateTimeFunc
2935                .invoke(&[text("2024-03-15 12:00:00"), text("localtime")])
2936                .unwrap();
2937            // The shifted value should differ from the input.
2938            let shifted = match &r {
2939                SqliteValue::Text(s) => s.clone(),
2940                _ => panic!("expected text"),
2941            };
2942            assert_ne!(&*shifted, "2024-03-15 12:00:00");
2943        }
2944    }
2945
2946    #[test]
2947    fn test_modifier_auto_out_of_range_returns_null() {
2948        let r = DateTimeFunc.invoke(&[float(1.0e20), text("auto")]).unwrap();
2949        assert_eq!(r, SqliteValue::Null);
2950    }
2951
2952    #[test]
2953    fn test_modifier_order_matters() {
2954        // 'start of month' then '+1 day' = March 2nd.
2955        let r1 = DateFunc
2956            .invoke(&[text("2024-03-15"), text("start of month"), text("+1 days")])
2957            .unwrap();
2958        assert_text(&r1, "2024-03-02");
2959
2960        // '+1 day' then 'start of month' = March 1st.
2961        let r2 = DateFunc
2962            .invoke(&[text("2024-03-15"), text("+1 days"), text("start of month")])
2963            .unwrap();
2964        assert_text(&r2, "2024-03-01");
2965    }
2966
2967    #[test]
2968    fn test_modifier_weekday_same_day_is_noop() {
2969        // 2024-03-17 is Sunday; SQLite semantics: already on target weekday, no-op.
2970        let r = DateFunc
2971            .invoke(&[text("2024-03-17"), text("weekday 0")])
2972            .unwrap();
2973        assert_text(&r, "2024-03-17");
2974    }
2975
2976    // ── Input formats ─────────────────────────────────────────────────
2977
2978    #[test]
2979    fn test_bare_time_defaults() {
2980        let r = DateFunc.invoke(&[text("12:30:00")]).unwrap();
2981        assert_text(&r, "2000-01-01");
2982    }
2983
2984    #[test]
2985    fn test_t_separator() {
2986        let r = DateTimeFunc.invoke(&[text("2024-03-15T14:30:00")]).unwrap();
2987        assert_text(&r, "2024-03-15 14:30:00");
2988    }
2989
2990    #[test]
2991    fn test_julian_day_input() {
2992        // 2460384.5 is 2024-03-15.
2993        let r = DateFunc.invoke(&[float(2_460_384.5)]).unwrap();
2994        assert_text(&r, "2024-03-15");
2995    }
2996
2997    #[test]
2998    fn test_null_input() {
2999        assert_eq!(DateFunc.invoke(&[null()]).unwrap(), SqliteValue::Null);
3000    }
3001
3002    #[test]
3003    fn test_invalid_input() {
3004        assert_eq!(
3005            DateFunc.invoke(&[text("not-a-date")]).unwrap(),
3006            SqliteValue::Null
3007        );
3008    }
3009
3010    #[test]
3011    fn test_negative_time_component_invalid() {
3012        let r = TimeFunc.invoke(&[text("-01:00")]).unwrap();
3013        assert_eq!(r, SqliteValue::Null);
3014    }
3015
3016    // ── Leap year ─────────────────────────────────────────────────────
3017
3018    #[test]
3019    fn test_leap_year() {
3020        let r = DateFunc
3021            .invoke(&[text("2024-02-28"), text("+1 days")])
3022            .unwrap();
3023        assert_text(&r, "2024-02-29");
3024    }
3025
3026    #[test]
3027    fn test_non_leap_year() {
3028        let r = DateFunc
3029            .invoke(&[text("2023-02-28"), text("+1 days")])
3030            .unwrap();
3031        assert_text(&r, "2023-03-01");
3032    }
3033
3034    // ── strftime ──────────────────────────────────────────────────────
3035
3036    #[test]
3037    fn test_strftime_basic() {
3038        let r = StrftimeFunc
3039            .invoke(&[text("%Y-%m-%d"), text("2024-03-15")])
3040            .unwrap();
3041        assert_text(&r, "2024-03-15");
3042    }
3043
3044    #[test]
3045    fn test_strftime_time_specifiers() {
3046        let r = StrftimeFunc
3047            .invoke(&[text("%H:%M:%S"), text("2024-03-15 14:30:45")])
3048            .unwrap();
3049        assert_text(&r, "14:30:45");
3050    }
3051
3052    #[test]
3053    fn test_strftime_unix_seconds() {
3054        let r = StrftimeFunc
3055            .invoke(&[text("%s"), text("1970-01-01 00:00:00")])
3056            .unwrap();
3057        assert_text(&r, "0");
3058    }
3059
3060    #[test]
3061    fn test_strftime_day_of_year() {
3062        let r = StrftimeFunc
3063            .invoke(&[text("%j"), text("2024-03-15")])
3064            .unwrap();
3065        // 2024-03-15: Jan(31) + Feb(29) + 15 = 75
3066        assert_text(&r, "075");
3067    }
3068
3069    #[test]
3070    fn test_strftime_day_of_week() {
3071        // 2024-03-15 is a Friday → w=5 (0=Sunday), u=5 (1=Monday)
3072        let r = StrftimeFunc
3073            .invoke(&[text("%w"), text("2024-03-15")])
3074            .unwrap();
3075        assert_text(&r, "5");
3076
3077        let r = StrftimeFunc
3078            .invoke(&[text("%u"), text("2024-03-15")])
3079            .unwrap();
3080        assert_text(&r, "5");
3081    }
3082
3083    #[test]
3084    fn test_strftime_12hour() {
3085        let r = StrftimeFunc
3086            .invoke(&[text("%I %p"), text("2024-03-15 14:30:00")])
3087            .unwrap();
3088        assert_text(&r, "02 PM");
3089
3090        let r = StrftimeFunc
3091            .invoke(&[text("%I %P"), text("2024-03-15 09:30:00")])
3092            .unwrap();
3093        assert_text(&r, "09 am");
3094    }
3095
3096    #[test]
3097    fn test_strftime_all_specifiers_presence() {
3098        let fmt = "%d|%e|%f|%H|%I|%j|%J|%k|%l|%m|%M|%p|%P|%R|%s|%S|%T|%u|%w|%W|%G|%g|%V|%Y|%%";
3099        let r = StrftimeFunc
3100            .invoke(&[text(fmt), text("2024-03-15 14:30:45.123")])
3101            .unwrap();
3102
3103        let s = match r {
3104            SqliteValue::Text(v) => v,
3105            other => panic!("expected Text, got {other:?}"),
3106        };
3107        let parts: Vec<&str> = s.split('|').collect();
3108        assert_eq!(parts.len(), 25, "unexpected specifier output: {s}");
3109        assert_eq!(parts[0], "15"); // %d
3110        assert_eq!(parts[1], "15"); // %e
3111        assert_eq!(parts[2], "45.123"); // %f
3112        assert_eq!(parts[3], "14"); // %H
3113        assert_eq!(parts[4], "02"); // %I
3114        assert_eq!(parts[5], "075"); // %j
3115        assert!(
3116            parts[6].parse::<f64>().is_ok(),
3117            "expected numeric %J output, got {}",
3118            parts[6]
3119        );
3120        assert_eq!(parts[7], "14"); // %k
3121        assert_eq!(parts[8], " 2"); // %l
3122        assert_eq!(parts[9], "03"); // %m
3123        assert_eq!(parts[10], "30"); // %M
3124        assert_eq!(parts[11], "PM"); // %p
3125        assert_eq!(parts[12], "pm"); // %P
3126        assert_eq!(parts[13], "14:30"); // %R
3127        assert!(
3128            parts[14].parse::<i64>().is_ok(),
3129            "expected numeric %s output, got {}",
3130            parts[14]
3131        );
3132        assert_eq!(parts[15], "45"); // %S
3133        assert_eq!(parts[16], "14:30:45"); // %T
3134        assert_eq!(parts[17], "5"); // %u
3135        assert_eq!(parts[18], "5"); // %w
3136        assert_eq!(parts[19], "11"); // %W
3137        assert_eq!(parts[20], "2024"); // %G
3138        assert_eq!(parts[21], "24"); // %g
3139        assert_eq!(parts[22], "11"); // %V
3140        assert_eq!(parts[23], "2024"); // %Y
3141        assert_eq!(parts[24], "%"); // %%
3142    }
3143
3144    #[test]
3145    fn test_strftime_null() {
3146        assert_eq!(
3147            StrftimeFunc.invoke(&[null(), text("2024-01-01")]).unwrap(),
3148            SqliteValue::Null
3149        );
3150        assert_eq!(
3151            StrftimeFunc.invoke(&[text("%Y"), null()]).unwrap(),
3152            SqliteValue::Null
3153        );
3154    }
3155
3156    #[test]
3157    #[ignore = "perf-only benchmark"]
3158    fn perf_strftime_timestamp_rows() {
3159        use std::hint::black_box;
3160        use std::time::Instant;
3161
3162        const ROWS: usize = 200_000;
3163        const REPEATS: usize = 5;
3164        const FORMAT: &str = "%Y-%m-%d %H:%M:%S";
3165        const INPUT: &str = "2024-03-15 14:30:45";
3166
3167        let func = StrftimeFunc;
3168        let fmt = text(FORMAT);
3169        let input = text(INPUT);
3170        let mut best_ns = u128::MAX;
3171        let mut output_len = 0usize;
3172
3173        for _ in 0..REPEATS {
3174            let started = Instant::now();
3175            for _ in 0..ROWS {
3176                let result = black_box(
3177                    func.invoke(black_box(&[fmt.clone(), input.clone()]))
3178                        .expect("strftime benchmark invocation must succeed"),
3179                );
3180                output_len = match result {
3181                    SqliteValue::Text(text) => text.len(),
3182                    SqliteValue::Null
3183                    | SqliteValue::Integer(_)
3184                    | SqliteValue::Float(_)
3185                    | SqliteValue::Blob(_) => 0,
3186                };
3187            }
3188            let elapsed_ns = started.elapsed().as_nanos();
3189            if elapsed_ns < best_ns {
3190                best_ns = elapsed_ns;
3191            }
3192        }
3193
3194        println!(
3195            "strftime_timestamp_rows rows={ROWS} repeats={REPEATS} best_ns={best_ns} output_len={output_len}"
3196        );
3197    }
3198
3199    // ── timediff ──────────────────────────────────────────────────────
3200
3201    #[test]
3202    fn test_timediff_basic() {
3203        let r = TimediffFunc
3204            .invoke(&[text("2024-03-15"), text("2024-03-10")])
3205            .unwrap();
3206        assert_text(&r, "+0000-00-05 00:00:00.000");
3207    }
3208
3209    #[test]
3210    fn test_timediff_negative() {
3211        let r = TimediffFunc
3212            .invoke(&[text("2024-03-10"), text("2024-03-15")])
3213            .unwrap();
3214        assert_text(&r, "-0000-00-05 00:00:00.000");
3215    }
3216
3217    #[test]
3218    fn test_timediff_year_boundary() {
3219        let r = TimediffFunc
3220            .invoke(&[text("2024-01-01 01:00:00"), text("2023-12-31 23:00:00")])
3221            .unwrap();
3222        assert_text(&r, "+0000-00-00 02:00:00.000");
3223    }
3224
3225    // ── Subsec modifier ───────────────────────────────────────────────
3226
3227    #[test]
3228    fn test_modifier_subsec() {
3229        assert_text(
3230            &TimeFunc
3231                .invoke(&[text("2024-01-01 12:00:00"), text("subsec")])
3232                .unwrap(),
3233            "12:00:00.000",
3234        );
3235        assert_text(
3236            &DateTimeFunc
3237                .invoke(&[text("2024-01-01 12:00:00"), text("subsecond")])
3238                .unwrap(),
3239            "2024-01-01 12:00:00.000",
3240        );
3241        assert_text(
3242            &TimeFunc
3243                .invoke(&[text("2024-01-01 12:00:00.123"), text("subsec")])
3244                .unwrap(),
3245            "12:00:00.123",
3246        );
3247    }
3248
3249    #[test]
3250    fn test_subsec_unix_seconds_and_integer_flooring() {
3251        assert_text(
3252            &StrftimeFunc
3253                .invoke(&[text("%s"), text("1970-01-01 00:00:00.125"), text("subsec")])
3254                .unwrap(),
3255            "0.125",
3256        );
3257        assert_eq!(
3258            UnixepochFunc
3259                .invoke(&[text("1970-01-01 00:00:00.125"), text("subsec")])
3260                .unwrap(),
3261            float(0.125)
3262        );
3263
3264        for input in ["1970-01-01 00:00:00.500", "1970-01-01 00:00:00.999"] {
3265            assert_eq!(UnixepochFunc.invoke(&[text(input)]).unwrap(), int(0));
3266            assert_text(
3267                &StrftimeFunc.invoke(&[text("%s"), text(input)]).unwrap(),
3268                "0",
3269            );
3270        }
3271        assert_eq!(
3272            UnixepochFunc
3273                .invoke(&[text("1969-12-31 23:59:59.999")])
3274                .unwrap(),
3275            int(-1)
3276        );
3277        assert_text(
3278            &StrftimeFunc
3279                .invoke(&[text("%s"), text("1969-12-31 23:59:59.999")])
3280                .unwrap(),
3281            "-1",
3282        );
3283    }
3284
3285    #[test]
3286    fn test_subsec_rounding_preserves_sqlite_second_60() {
3287        assert_text(
3288            &TimeFunc
3289                .invoke(&[text("12:34:59.9995"), text("subsec")])
3290                .unwrap(),
3291            "12:34:60.000",
3292        );
3293        assert_text(
3294            &DateTimeFunc
3295                .invoke(&[text("1970-01-01 23:59:59.9995"), text("subsec")])
3296                .unwrap(),
3297            "1970-01-01 23:59:60.000",
3298        );
3299        assert_text(
3300            &StrftimeFunc
3301                .invoke(&[
3302                    text("%H:%M:%f|%s"),
3303                    text("1970-01-01 23:59:59.9995"),
3304                    text("subsec"),
3305                ])
3306                .unwrap(),
3307            "23:59:59.999|86400.000",
3308        );
3309    }
3310
3311    // ── Registration ──────────────────────────────────────────────────
3312
3313    #[test]
3314    fn test_register_datetime_builtins_all_present() {
3315        let mut reg = FunctionRegistry::new();
3316        register_datetime_builtins(&mut reg);
3317
3318        let expected = [
3319            "date",
3320            "time",
3321            "datetime",
3322            "julianday",
3323            "unixepoch",
3324            "strftime",
3325            "timediff",
3326        ];
3327
3328        for name in expected {
3329            assert!(
3330                reg.find_scalar(name, 1).is_some() || reg.find_scalar(name, 2).is_some(),
3331                "datetime function '{name}' not registered"
3332            );
3333        }
3334    }
3335
3336    #[test]
3337    fn test_public_datetime_function_metadata_fails_closed_for_direct_registration() {
3338        assert!(!DateFunc.is_deterministic());
3339        assert!(!TimeFunc.is_deterministic());
3340        assert!(!DateTimeFunc.is_deterministic());
3341        assert!(!JuliandayFunc.is_deterministic());
3342        assert!(!UnixepochFunc.is_deterministic());
3343        assert!(!StrftimeFunc.is_deterministic());
3344        assert!(!TimediffFunc.is_deterministic());
3345
3346        let mut registry = FunctionRegistry::new();
3347        registry.register_scalar(DateFunc);
3348        registry.register_scalar(TimeFunc);
3349        registry.register_scalar(DateTimeFunc);
3350        registry.register_scalar(JuliandayFunc);
3351        registry.register_scalar(UnixepochFunc);
3352        registry.register_scalar(StrftimeFunc);
3353        registry.register_scalar(TimediffFunc);
3354        for (name, num_args) in [
3355            ("date", 0),
3356            ("time", 0),
3357            ("datetime", 0),
3358            ("julianday", 0),
3359            ("unixepoch", 0),
3360            ("strftime", 1),
3361            ("timediff", 2),
3362        ] {
3363            assert_eq!(
3364                registry.scalar_schema_safety(name, num_args),
3365                Some(crate::ScalarSchemaSafety::Never),
3366                "generic registration of {name}/{num_args} must fail closed"
3367            );
3368        }
3369    }
3370
3371    // ── JDN roundtrip ─────────────────────────────────────────────────
3372
3373    #[test]
3374    fn test_modifier_year_overflow() {
3375        // "+9223372036854775807 years" causes i64 overflow in year calculation.
3376        // Should return NULL, not panic.
3377        let huge = i64::MAX;
3378        let modifier = format!("+{huge} years");
3379        let r = DateFunc.invoke(&[text("2000-01-01"), text(&modifier)]);
3380        // The implementation should catch overflow and return Ok(Null), or at least not panic.
3381        // If it panics, the test harness catches it (but we want to prevent panics).
3382        assert_eq!(r.unwrap(), SqliteValue::Null);
3383    }
3384
3385    #[test]
3386    fn test_jdn_roundtrip() {
3387        // Test that ymd → jdn → ymd roundtrips correctly.
3388        let dates = [
3389            (2024, 3, 15),
3390            (2000, 1, 1),
3391            (1970, 1, 1),
3392            (2024, 2, 29),
3393            (1900, 1, 1),
3394            (2099, 12, 31),
3395        ];
3396        for (y, m, d) in dates {
3397            let jdn = ymd_to_jdn(y, m, d);
3398            let (y2, m2, d2) = jdn_to_ymd(jdn);
3399            assert_eq!(
3400                (y, m, d),
3401                (y2, m2, d2),
3402                "roundtrip failed for {y}-{m}-{d} (JDN={jdn})"
3403            );
3404        }
3405    }
3406
3407    #[test]
3408    fn test_unix_epoch_roundtrip() {
3409        let jdn = ymd_to_jdn(1970, 1, 1);
3410        let unix = jdn_to_unix(jdn);
3411        assert_eq!(unix, 0, "Unix epoch should be 0");
3412
3413        let jdn2 = unix_to_jdn(0.0);
3414        assert!((jdn2 - UNIX_EPOCH_JDN).abs() < 1e-10, "roundtrip failed");
3415    }
3416}