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