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" and return the JDN delta.
591fn parse_arithmetic_modifier(m: &str) -> Option<f64> {
592    let (sign, rest) = if let Some(r) = m.strip_prefix('+') {
593        (1.0, r.trim())
594    } else {
595        let r = m.strip_prefix('-')?;
596        (-1.0, r.trim())
597    };
598
599    let mut parts = rest.splitn(2, ' ');
600    let num_str = parts.next()?;
601    let unit = parts.next()?.trim();
602
603    // Reject non-finite (NaN/Inf) — sqlite3AtoF doesn't recognize them.
604    let num = num_str.parse::<f64>().ok().filter(|f| f.is_finite())?;
605    let delta = num * sign;
606
607    match unit.trim_end_matches('s') {
608        "day" => Some(delta),
609        "hour" => Some(delta / 24.0),
610        "minute" => Some(delta / 1440.0),
611        "second" => Some(delta / 86400.0),
612        "month" => Some(apply_month_delta(delta)),
613        "year" => Some(apply_month_delta(delta * 12.0)),
614        _ => None,
615    }
616}
617
618/// For month/year arithmetic, we can't simply add a JDN delta because months
619/// have variable lengths.  This returns a JDN delta that is *approximately*
620/// correct.  A fully correct implementation requires decomposing and
621/// recomposing, which we handle in `apply_modifier_full` for month/year cases.
622fn apply_month_delta(months: f64) -> f64 {
623    // Average month ≈ 30.436875 days.
624    months * 30.436875
625}
626
627fn has_outer_ascii_whitespace(value: &str) -> bool {
628    value
629        .as_bytes()
630        .first()
631        .is_some_and(u8::is_ascii_whitespace)
632        || value.as_bytes().last().is_some_and(u8::is_ascii_whitespace)
633}
634
635/// SQLite's `computeFloor` (date.c): given a (possibly day-of-month-overflowing)
636/// Y-M-D, return how many days must be subtracted to bring the date back to the
637/// last valid day of month `m`.  Consumed by the `'floor'` datetime modifier.
638fn compute_floor(y: i64, m: i64, d: i64) -> i64 {
639    if d <= 28 {
640        0
641    } else if ((1_i64 << m) & 0x15aa) != 0 {
642        // 31-day month (Jan, Mar, May, Jul, Aug, Oct, Dec): days 29-31 all valid.
643        0
644    } else if m != 2 {
645        // 30-day month (Apr, Jun, Sep, Nov): only day 31 overflows, by 1.
646        i64::from(d == 31)
647    } else if y % 4 != 0 || (y % 100 == 0 && y % 400 != 0) {
648        d - 28 // non-leap February
649    } else {
650        d - 29 // leap February
651    }
652}
653
654/// Apply a sequence of modifiers, also tracking the 'subsec' flag.
655fn apply_modifiers(jdn: f64, modifiers: &[String], mut raw_numeric: bool) -> Option<(f64, bool)> {
656    let mut j = jdn;
657    let mut subsec = false;
658    // Pending day-of-month overflow from the most recent +N month/year shift,
659    // consumed by a later 'floor' modifier (mirrors SQLite's DateTime.nFloor).
660    let mut n_floor: i64 = 0;
661    for (index, m) in modifiers.iter().enumerate() {
662        if has_outer_ascii_whitespace(m) {
663            return None;
664        }
665        let m_lower = m.to_ascii_lowercase();
666        if matches!(m_lower.as_str(), "unixepoch" | "julianday" | "auto") {
667            // These reinterpretations are valid only as the first modifier of
668            // a raw numeric time value (date.c parseModifier's `idx>1` and
669            // `rawS` checks).
670            if index != 0 || !raw_numeric {
671                return None;
672            }
673            raw_numeric = false;
674        } else if m_lower != "subsec" && m_lower != "subsecond" {
675            raw_numeric = false;
676        }
677        if m_lower == "subsec" || m_lower == "subsecond" {
678            subsec = true;
679            continue;
680        }
681        // 'ceiling' is the default day-of-month-overflow behavior (roll forward
682        // into the next month); it merely clears any pending floor adjustment.
683        // 'floor' rolls the date back to the last day of the prior month by
684        // subtracting the overflow days that the preceding month/year shift left.
685        if m_lower == "ceiling" {
686            n_floor = 0;
687            continue;
688        }
689        if m_lower == "floor" {
690            j -= n_floor as f64;
691            n_floor = 0;
692            continue;
693        }
694        // Month/year modifiers need special handling for exact date math.
695        // If exact arithmetic overflows (returns None), the modifier is
696        // out of representable range — return NULL rather than falling
697        // through to the approximate path which would produce overflow
698        // panics in jdn_to_ymd.
699        if is_month_year_modifier(&m_lower) {
700            match apply_month_year_exact(j, &m_lower) {
701                Ok(Some((new_jdn, nf))) => {
702                    j = new_jdn;
703                    n_floor = nf;
704                    continue;
705                }
706                Ok(None) => return None,
707                Err(()) => {
708                    // Fall through to `apply_modifier` for fractional values
709                }
710            }
711        }
712        // Any other date-changing modifier clears the pending floor.
713        n_floor = 0;
714        j = apply_modifier(j, m)?;
715    }
716    Some((j, subsec))
717}
718
719fn is_month_year_modifier(m: &str) -> bool {
720    (m.contains("month") || m.contains("year")) && (m.starts_with('+') || m.starts_with('-'))
721}
722
723/// Exact month/year arithmetic by decomposing to YMD.
724/// Returns Ok(Some((jdn, n_floor))) for exact application, where `n_floor` is
725/// the day-of-month overflow (days to subtract for a later `'floor'` modifier).
726/// Returns Ok(None) for overflow.
727/// Returns Err(()) if the modifier is not an integer, so it should fall back.
728fn apply_month_year_exact(jdn: f64, m: &str) -> std::result::Result<Option<(f64, i64)>, ()> {
729    let (sign, rest) = if let Some(r) = m.strip_prefix('+') {
730        (1_i64, r.trim())
731    } else if let Some(r) = m.strip_prefix('-') {
732        (-1_i64, r.trim())
733    } else {
734        return Err(());
735    };
736
737    let mut parts = rest.splitn(2, ' ');
738    let num_str = parts.next().ok_or(())?;
739    let unit = parts.next().ok_or(())?.trim();
740
741    // SQLite uses exact math only if the value is an integer.
742    let num = if let Ok(n) = num_str.parse::<i64>() {
743        n
744    } else if let Ok(f) = num_str.parse::<f64>() {
745        if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
746            f as i64
747        } else {
748            return Err(());
749        }
750    } else {
751        return Err(());
752    };
753
754    let (y, mo, d) = jdn_to_ymd(jdn);
755    let (h, mi, s, frac) = jdn_to_hms(jdn);
756
757    let total_months = match unit.trim_end_matches('s') {
758        "month" => {
759            if let Some(val) = num.checked_mul(sign) {
760                val
761            } else {
762                return Ok(None);
763            }
764        }
765        "year" => {
766            if let Some(val) = num.checked_mul(sign).and_then(|v| v.checked_mul(12)) {
767                val
768            } else {
769                return Ok(None);
770            }
771        }
772        _ => return Err(()),
773    };
774
775    // (y * 12 + (mo - 1)) + total_months
776    let current_months = if let Some(val) = y.checked_mul(12).and_then(|v| v.checked_add(mo - 1)) {
777        val
778    } else {
779        return Ok(None);
780    };
781    let new_total = if let Some(val) = current_months.checked_add(total_months) {
782        val
783    } else {
784        return Ok(None);
785    };
786
787    let new_y = new_total.div_euclid(12);
788    let new_mo = new_total.rem_euclid(12) + 1;
789    // Do NOT clamp `d` to the target month's day count.  C SQLite lets
790    // out-of-range days overflow via JDN arithmetic (e.g. Feb 31 → Mar 3) for
791    // the default/`'ceiling'` behavior; the `'floor'` modifier later subtracts
792    // the reported `n_floor` overflow days to clamp back to end-of-month.
793    let n_floor = compute_floor(new_y, new_mo, d);
794    Ok(Some((
795        ymdhms_to_jdn(new_y, new_mo, d, h, mi, s, frac),
796        n_floor,
797    )))
798}
799
800// ── Output Formatters ─────────────────────────────────────────────────────
801
802/// Fixed-capacity stack `fmt::Write` target. Any date/time output is at most ~26 bytes
803/// (a multi-digit year plus `-MM-DD HH:MM:SS.SSS`), so 48 bytes never overflows for a
804/// valid date; `write_str` returns `Err` on overflow so `build_small_text` can fall back.
805struct StackStr {
806    buf: [u8; 48],
807    len: usize,
808}
809
810impl StackStr {
811    fn new() -> Self {
812        Self {
813            buf: [0; 48],
814            len: 0,
815        }
816    }
817
818    fn as_str(&self) -> &str {
819        // Only ASCII digits/separators are ever written, so this is always valid UTF-8.
820        core::str::from_utf8(&self.buf[..self.len]).unwrap_or("")
821    }
822}
823
824impl core::fmt::Write for StackStr {
825    fn write_str(&mut self, s: &str) -> core::fmt::Result {
826        let end = self.len + s.len();
827        if end > self.buf.len() {
828            return Err(core::fmt::Error);
829        }
830        self.buf[self.len..end].copy_from_slice(s.as_bytes());
831        self.len = end;
832        Ok(())
833    }
834}
835
836/// Render `write` into a stack buffer and build an inline `SmallText` (no heap) when it
837/// fits the 23-byte inline capacity — the common case for every real date/time. Falls
838/// back to a heap `String` only when the stack buffer overflows (an absurdly large year),
839/// re-running `write` (hence `Fn`, not `FnOnce`). Mirrors the stack-backed Soundex result
840/// (bd-t2sf9.1): the transient `format!` heap allocation that `SmallText` immediately
841/// inlined is eliminated for the hot path.
842fn build_small_text(write: impl Fn(&mut dyn core::fmt::Write) -> core::fmt::Result) -> SmallText {
843    let mut buf = StackStr::new();
844    if write(&mut buf).is_ok() {
845        SmallText::new(buf.as_str())
846    } else {
847        let mut heap = String::new();
848        let _ = write(&mut heap);
849        SmallText::from_string(heap)
850    }
851}
852
853fn format_date(jdn: f64) -> SmallText {
854    let (y, m, d) = jdn_to_ymd(jdn);
855    build_small_text(move |w| write!(w, "{y:04}-{m:02}-{d:02}"))
856}
857
858#[derive(Clone, Copy)]
859struct UnmodifiedHms {
860    hour: i64,
861    minute: i64,
862    second: i64,
863    fraction: f64,
864}
865
866fn hms_for_output(jdn: f64, unmodified: Option<UnmodifiedHms>) -> UnmodifiedHms {
867    unmodified.unwrap_or_else(|| {
868        let (hour, minute, second, fraction) = jdn_to_hms(jdn);
869        UnmodifiedHms {
870            hour,
871            minute,
872            second,
873            fraction,
874        }
875    })
876}
877
878fn rounded_second_and_millis(hms: UnmodifiedHms) -> (i64, i64) {
879    // date.c rounds the seconds field independently of hours/minutes. Thus an
880    // input ending in 59.9995 is deliberately rendered as 60.000 rather than
881    // carrying into the next minute.
882    let total_millis = ((hms.second as f64 + hms.fraction) * 1000.0 + 0.5).floor() as i64;
883    (total_millis / 1000, total_millis % 1000)
884}
885
886fn format_time(jdn: f64, subsec: bool, unmodified: Option<UnmodifiedHms>) -> SmallText {
887    let hms = hms_for_output(jdn, unmodified);
888    let (h, m) = (hms.hour, hms.minute);
889    if subsec {
890        let (s, ms) = rounded_second_and_millis(hms);
891        build_small_text(move |w| write!(w, "{h:02}:{m:02}:{s:02}.{ms:03}"))
892    } else {
893        let s = hms.second;
894        build_small_text(move |w| write!(w, "{h:02}:{m:02}:{s:02}"))
895    }
896}
897
898fn format_datetime(jdn: f64, subsec: bool, unmodified: Option<UnmodifiedHms>) -> SmallText {
899    let (y, mo, d) = jdn_to_ymd(jdn);
900    let hms = hms_for_output(jdn, unmodified);
901    let (h, mi) = (hms.hour, hms.minute);
902    if subsec {
903        let (s, ms) = rounded_second_and_millis(hms);
904        build_small_text(move |w| write!(w, "{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}.{ms:03}"))
905    } else {
906        let s = hms.second;
907        build_small_text(move |w| write!(w, "{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}"))
908    }
909}
910
911#[inline]
912fn push_format(result: &mut String, args: Arguments<'_>) {
913    let _ = result.write_fmt(args);
914}
915
916#[inline]
917fn push_zero_padded_2(result: &mut String, value: i64) {
918    if (0..=99).contains(&value) {
919        let value = value as u8;
920        result.push(char::from(b'0' + value / 10));
921        result.push(char::from(b'0' + value % 10));
922    } else {
923        push_format(result, format_args!("{value:02}"));
924    }
925}
926
927#[inline]
928fn push_space_padded_2(result: &mut String, value: i64) {
929    if (0..=99).contains(&value) {
930        let value = value as u8;
931        if value >= 10 {
932            result.push(char::from(b'0' + value / 10));
933        } else {
934            result.push(' ');
935        }
936        result.push(char::from(b'0' + value % 10));
937    } else {
938        push_format(result, format_args!("{value:>2}"));
939    }
940}
941
942#[inline]
943fn push_zero_padded_3(result: &mut String, value: i64) {
944    if (0..=999).contains(&value) {
945        let value = value as u16;
946        result.push(char::from(b'0' + (value / 100) as u8));
947        result.push(char::from(b'0' + ((value / 10) % 10) as u8));
948        result.push(char::from(b'0' + (value % 10) as u8));
949    } else {
950        push_format(result, format_args!("{value:03}"));
951    }
952}
953
954#[inline]
955fn push_zero_padded_4(result: &mut String, value: i64) {
956    if (0..=9999).contains(&value) {
957        let value = value as u16;
958        result.push(char::from(b'0' + (value / 1000) as u8));
959        result.push(char::from(b'0' + ((value / 100) % 10) as u8));
960        result.push(char::from(b'0' + ((value / 10) % 10) as u8));
961        result.push(char::from(b'0' + (value % 10) as u8));
962    } else {
963        push_format(result, format_args!("{value:04}"));
964    }
965}
966
967/// strftime format engine.
968fn format_strftime(fmt: &str, jdn: f64, subsec: bool, unmodified: Option<UnmodifiedHms>) -> String {
969    let (y, mo, d) = jdn_to_ymd(jdn);
970    let hms = hms_for_output(jdn, unmodified);
971    let (h, mi, s, frac) = (hms.hour, hms.minute, hms.second, hms.fraction);
972    let doy = day_of_year(y, mo, d);
973    // Day of week: 0=Sunday.
974    let jdn_int = (jdn + 0.5).floor() as i64;
975    let dow = (jdn_int + 1) % 7; // 0=Sunday, 6=Saturday
976
977    let mut result = String::with_capacity(fmt.len().saturating_add(8));
978    let bytes = fmt.as_bytes();
979    let mut i = 0;
980    let mut literal_start = 0;
981
982    while i < bytes.len() {
983        if bytes[i] != b'%' || i + 1 >= bytes.len() {
984            i += 1;
985            continue;
986        }
987
988        result.push_str(&fmt[literal_start..i]);
989
990        let spec_suffix = &fmt[i + 1..];
991        let Some(spec) = spec_suffix.chars().next() else {
992            break;
993        };
994        i += 1 + spec.len_utf8();
995        literal_start = i;
996
997        match spec {
998            'd' => push_zero_padded_2(&mut result, d),
999            'e' => push_space_padded_2(&mut result, d),
1000            'F' => {
1001                // ISO 8601 date: %Y-%m-%d (bd-luvv8).
1002                push_zero_padded_4(&mut result, y);
1003                result.push('-');
1004                push_zero_padded_2(&mut result, mo);
1005                result.push('-');
1006                push_zero_padded_2(&mut result, d);
1007            }
1008            'f' => {
1009                // Seconds with fractional part.
1010                let total = (s as f64 + frac).min(59.999);
1011                push_format(&mut result, format_args!("{total:06.3}"));
1012            }
1013            'H' => push_zero_padded_2(&mut result, h),
1014            'I' => {
1015                // 12-hour clock.
1016                let h12 = if h == 0 {
1017                    12
1018                } else if h > 12 {
1019                    h - 12
1020                } else {
1021                    h
1022                };
1023                push_zero_padded_2(&mut result, h12);
1024            }
1025            'j' => push_zero_padded_3(&mut result, doy),
1026            'J' => {
1027                // C SQLite uses %.15g which strips trailing zeros.
1028                push_format(&mut result, format_args!("{jdn:.15}"));
1029                while result.as_bytes().last() == Some(&b'0') {
1030                    result.pop();
1031                }
1032                if result.as_bytes().last() == Some(&b'.') {
1033                    result.pop();
1034                }
1035            }
1036            'k' => {
1037                // Space-padded 24-hour.
1038                push_space_padded_2(&mut result, h);
1039            }
1040            'l' => {
1041                // Space-padded 12-hour.
1042                let h12 = if h == 0 {
1043                    12
1044                } else if h > 12 {
1045                    h - 12
1046                } else {
1047                    h
1048                };
1049                push_space_padded_2(&mut result, h12);
1050            }
1051            'm' => push_zero_padded_2(&mut result, mo),
1052            'M' => push_zero_padded_2(&mut result, mi),
1053            'p' => {
1054                result.push_str(if h < 12 { "AM" } else { "PM" });
1055            }
1056            'P' => {
1057                result.push_str(if h < 12 { "am" } else { "pm" });
1058            }
1059            'R' => {
1060                push_zero_padded_2(&mut result, h);
1061                result.push(':');
1062                push_zero_padded_2(&mut result, mi);
1063            }
1064            's' => {
1065                if subsec {
1066                    let unix = jdn_to_unix_subsec(jdn);
1067                    push_format(&mut result, format_args!("{unix:.3}"));
1068                } else {
1069                    let unix = jdn_to_unix(jdn);
1070                    push_format(&mut result, format_args!("{unix}"));
1071                }
1072            }
1073            'S' => push_zero_padded_2(&mut result, s),
1074            'T' => {
1075                push_zero_padded_2(&mut result, h);
1076                result.push(':');
1077                push_zero_padded_2(&mut result, mi);
1078                result.push(':');
1079                push_zero_padded_2(&mut result, s);
1080            }
1081            'u' => {
1082                // ISO 8601 day of week: 1=Monday, 7=Sunday.
1083                let u = if dow == 0 { 7 } else { dow };
1084                push_format(&mut result, format_args!("{u}"));
1085            }
1086            'w' => push_format(&mut result, format_args!("{dow}")),
1087            'W' => {
1088                // Week of year (Monday as first day of week, 00-53).
1089                let w = (doy + 6 - ((dow + 6) % 7)) / 7;
1090                push_zero_padded_2(&mut result, w);
1091            }
1092            'Y' => push_zero_padded_4(&mut result, y),
1093            'G' | 'g' | 'V' => {
1094                // ISO 8601 week-based year/week.
1095                let (iso_y, iso_w) = iso_week(y, mo, d);
1096                match spec {
1097                    'G' => push_zero_padded_4(&mut result, iso_y),
1098                    'g' => push_zero_padded_2(&mut result, iso_y % 100),
1099                    'V' => push_zero_padded_2(&mut result, iso_w),
1100                    _ => unreachable!(),
1101                }
1102            }
1103            '%' => result.push('%'),
1104            other => {
1105                result.push('%');
1106                result.push(other);
1107            }
1108        }
1109    }
1110
1111    if literal_start < fmt.len() {
1112        result.push_str(&fmt[literal_start..]);
1113    }
1114
1115    result
1116}
1117
1118/// ISO 8601 week number and year.
1119fn iso_week(y: i64, m: i64, d: i64) -> (i64, i64) {
1120    let jdn = ymd_to_jdn(y, m, d);
1121    let jdn_int = (jdn + 0.5).floor() as i64;
1122    // ISO day of week: 1=Monday, 7=Sunday.
1123    let dow = (jdn_int + 1) % 7;
1124    let iso_dow = if dow == 0 { 7 } else { dow };
1125
1126    // Thursday of the same week determines the year.
1127    let thu_jdn = jdn_int + (4 - iso_dow);
1128    let (thu_y, _, _) = jdn_to_ymd(thu_jdn as f64);
1129
1130    // Jan 4 is always in week 1 (ISO 8601).
1131    let jan4_jdn = (ymd_to_jdn(thu_y, 1, 4) + 0.5).floor() as i64;
1132    let jan4_dow = (jan4_jdn + 1) % 7;
1133    let jan4_iso_dow = if jan4_dow == 0 { 7 } else { jan4_dow };
1134    let week1_start = jan4_jdn - (jan4_iso_dow - 1);
1135
1136    let week = (thu_jdn - week1_start) / 7 + 1;
1137    (thu_y, week)
1138}
1139
1140// ── timediff ──────────────────────────────────────────────────────────────
1141
1142fn timediff_impl(jdn1: f64, jdn2: f64) -> String {
1143    let (sign, start_jdn, end_jdn) = if jdn1 >= jdn2 {
1144        ('+', jdn2, jdn1)
1145    } else {
1146        ('-', jdn1, jdn2)
1147    };
1148
1149    let (start_y, start_mo, start_d) = jdn_to_ymd(start_jdn);
1150    let (start_h, start_mi, mut start_s, start_frac) = jdn_to_hms(start_jdn);
1151    let mut start_ms = (start_frac * 1000.0).round() as i64;
1152    if start_ms >= 1000 {
1153        start_ms = 0;
1154        start_s += 1;
1155    }
1156
1157    let (end_y, end_mo, end_d) = jdn_to_ymd(end_jdn);
1158    let (end_h, end_mi, mut end_s, end_frac) = jdn_to_hms(end_jdn);
1159    let mut end_ms = (end_frac * 1000.0).round() as i64;
1160    if end_ms >= 1000 {
1161        end_ms = 0;
1162        end_s += 1;
1163    }
1164
1165    let mut years = end_y - start_y;
1166    let mut months = end_mo - start_mo;
1167    let mut days = end_d - start_d;
1168    let mut hours = end_h - start_h;
1169    let mut minutes = end_mi - start_mi;
1170    let mut seconds = end_s - start_s;
1171    let mut millis = end_ms - start_ms;
1172
1173    if millis < 0 {
1174        millis += 1000;
1175        seconds -= 1;
1176    }
1177    if seconds < 0 {
1178        seconds += 60;
1179        minutes -= 1;
1180    }
1181    if minutes < 0 {
1182        minutes += 60;
1183        hours -= 1;
1184    }
1185    if hours < 0 {
1186        hours += 24;
1187        days -= 1;
1188    }
1189    if days < 0 {
1190        months -= 1;
1191        let (borrow_y, borrow_mo) = if end_mo == 1 {
1192            (end_y - 1, 12)
1193        } else {
1194            (end_y, end_mo - 1)
1195        };
1196        days += days_in_month(borrow_y, borrow_mo);
1197    }
1198    if months < 0 {
1199        months += 12;
1200        years -= 1;
1201    }
1202
1203    format!(
1204        "{sign}{years:04}-{months:02}-{days:02} {hours:02}:{minutes:02}:{seconds:02}.{millis:03}"
1205    )
1206}
1207
1208// ── Scalar Function Implementations ───────────────────────────────────────
1209
1210/// Return whether an evaluated built-in date/time call is safe in a schema
1211/// expression whose value must remain stable for the lifetime of the schema.
1212///
1213/// SQLite's date/time functions are conditionally deterministic. Fixed text,
1214/// numeric, and row-derived values are safe, but the following forms consult
1215/// wall-clock or host-local state and must not be used while maintaining an
1216/// expression index, partial index, generated column, or CHECK constraint:
1217///
1218/// - an omitted time value (for example, `date()` or `strftime('%Y')`);
1219/// - `now` as a time value;
1220/// - `subsec` or `subsecond` in the first time-value position, where SQLite
1221///   treats it as shorthand for the current time with subsecond precision;
1222/// - `localtime` or `utc` in a modifier position.
1223///
1224/// The argument layout is function-specific: `strftime`'s format occupies
1225/// `args[0]`, while both `timediff` arguments are time values and neither is a
1226/// modifier. NULLs preserve SQLite's left-to-right short-circuit behaviour: a
1227/// NULL encountered before a conditional feature makes the call return NULL
1228/// without consulting that feature.
1229///
1230/// Names other than the seven built-in date/time functions return `true`.
1231/// Callers that permit user-defined overrides must resolve function identity
1232/// before relying on this helper; a custom function merely named `date` is not
1233/// necessarily governed by SQLite's built-in date/time rules.
1234#[must_use]
1235pub fn is_datetime_invocation_safe_for_schema(function_name: &str, args: &[SqliteValue]) -> bool {
1236    if function_name.eq_ignore_ascii_case("strftime") {
1237        // C SQLite returns NULL before evaluating the time arguments when the
1238        // format is absent or NULL. With a non-NULL format, an omitted time
1239        // value means "now".
1240        return args.first().is_none_or(SqliteValue::is_null)
1241            || datetime_arguments_are_schema_safe(&args[1..]);
1242    }
1243
1244    if function_name.eq_ignore_ascii_case("timediff") {
1245        // Wrong-arity calls are rejected by function resolution and never
1246        // invoke timediff. For the valid arity, each argument is independently
1247        // parsed as a time value; neither position accepts modifiers.
1248        if args.len() != 2 {
1249            return true;
1250        }
1251        for time_value in args {
1252            match classify_time_value_for_schema(time_value) {
1253                SchemaTimeValue::Dynamic => return false,
1254                SchemaTimeValue::NullOrInvalid => return true,
1255                SchemaTimeValue::Fixed { .. } => {}
1256            }
1257        }
1258        return true;
1259    }
1260
1261    if function_name.eq_ignore_ascii_case("date")
1262        || function_name.eq_ignore_ascii_case("time")
1263        || function_name.eq_ignore_ascii_case("datetime")
1264        || function_name.eq_ignore_ascii_case("julianday")
1265        || function_name.eq_ignore_ascii_case("unixepoch")
1266    {
1267        return datetime_arguments_are_schema_safe(args);
1268    }
1269
1270    true
1271}
1272
1273/// Check the common `(time-value, modifier...)` date/time call shape.
1274fn datetime_arguments_are_schema_safe(args: &[SqliteValue]) -> bool {
1275    let Some(time_value) = args.first() else {
1276        return false;
1277    };
1278    let (input, raw_numeric) = match classify_time_value_for_schema(time_value) {
1279        SchemaTimeValue::Dynamic => return false,
1280        SchemaTimeValue::NullOrInvalid => return true,
1281        SchemaTimeValue::Fixed { input, raw_numeric } => (input, raw_numeric),
1282    };
1283
1284    let mut reached_modifiers = Vec::with_capacity(args.len().saturating_sub(1));
1285    for modifier in &args[1..] {
1286        if modifier.is_null() {
1287            return true;
1288        }
1289        if sqlite_value_is_keyword(modifier, "localtime")
1290            || sqlite_value_is_keyword(modifier, "utc")
1291        {
1292            return false;
1293        }
1294        let Some(modifier) = sqlite_value_datetime_text(modifier) else {
1295            return true;
1296        };
1297        reached_modifiers.push(modifier.into_owned());
1298        if apply_modifiers(input, &reached_modifiers, raw_numeric).is_none() {
1299            // Evaluation stops at the first invalid modifier. A conditional
1300            // token farther to the right is therefore unreachable.
1301            return true;
1302        }
1303    }
1304    true
1305}
1306
1307enum SchemaTimeValue {
1308    Dynamic,
1309    NullOrInvalid,
1310    Fixed { input: f64, raw_numeric: bool },
1311}
1312
1313fn classify_time_value_for_schema(value: &SqliteValue) -> SchemaTimeValue {
1314    if value.is_null() {
1315        return SchemaTimeValue::NullOrInvalid;
1316    }
1317    if is_implicit_now_time_value(value) {
1318        return SchemaTimeValue::Dynamic;
1319    }
1320    match parse_time_value(value) {
1321        Some(parsed) => SchemaTimeValue::Fixed {
1322            input: parsed.jdn,
1323            raw_numeric: parsed.raw_numeric,
1324        },
1325        None => SchemaTimeValue::NullOrInvalid,
1326    }
1327}
1328
1329fn is_implicit_now_time_value(value: &SqliteValue) -> bool {
1330    sqlite_value_is_keyword(value, "now")
1331        || sqlite_value_is_keyword(value, "subsec")
1332        || sqlite_value_is_keyword(value, "subsecond")
1333}
1334
1335/// Compare SQLite's NUL-terminated text view without allocating. Special
1336/// date/time words are ASCII-case-insensitive but do not permit surrounding
1337/// whitespace. Numeric values cannot equal any keyword checked here.
1338fn sqlite_value_is_keyword(value: &SqliteValue, keyword: &str) -> bool {
1339    let bytes = match value {
1340        SqliteValue::Text(text) => sqlite_c_string_bytes(text.as_bytes_direct()),
1341        SqliteValue::Blob(bytes) => sqlite_c_string_bytes(bytes),
1342        SqliteValue::Null | SqliteValue::Integer(_) | SqliteValue::Float(_) => return false,
1343    };
1344    bytes.eq_ignore_ascii_case(keyword.as_bytes())
1345}
1346
1347#[derive(Clone, Copy)]
1348struct ParsedTimeValue {
1349    jdn: f64,
1350    raw_numeric: bool,
1351    unmodified_hms: Option<UnmodifiedHms>,
1352}
1353
1354fn parse_time_value(value: &SqliteValue) -> Option<ParsedTimeValue> {
1355    match value {
1356        SqliteValue::Null => None,
1357        SqliteValue::Integer(integer) => Some(ParsedTimeValue {
1358            jdn: *integer as f64,
1359            raw_numeric: true,
1360            unmodified_hms: None,
1361        }),
1362        SqliteValue::Float(float) if float.is_finite() => Some(ParsedTimeValue {
1363            jdn: *float,
1364            raw_numeric: true,
1365            unmodified_hms: None,
1366        }),
1367        SqliteValue::Float(_) => None,
1368        SqliteValue::Text(_) | SqliteValue::Blob(_) => {
1369            let text = sqlite_value_datetime_text(value)?;
1370            let numeric = text.trim_matches(|c: char| c.is_ascii_whitespace());
1371            if let Ok(number) = numeric.parse::<f64>()
1372                && number.is_finite()
1373            {
1374                return Some(ParsedTimeValue {
1375                    jdn: number,
1376                    raw_numeric: true,
1377                    unmodified_hms: None,
1378                });
1379            }
1380            Some(ParsedTimeValue {
1381                jdn: parse_timestring(text.as_ref())?,
1382                raw_numeric: false,
1383                unmodified_hms: unmodified_hms_from_timestring(text.as_ref()),
1384            })
1385        }
1386    }
1387}
1388
1389fn unmodified_hms_from_timestring(value: &str) -> Option<UnmodifiedHms> {
1390    let value = sqlite_c_string_str(value).trim_end_matches(|c: char| c.is_ascii_whitespace());
1391    let time = if value.len() > 10
1392        && value.as_bytes().get(4) == Some(&b'-')
1393        && value.as_bytes().get(7) == Some(&b'-')
1394        && value
1395            .as_bytes()
1396            .get(10)
1397            .is_some_and(|separator| matches!(*separator, b' ' | b'T'))
1398    {
1399        &value[11..]
1400    } else if value.len() >= 5 && value.as_bytes().get(2) == Some(&b':') {
1401        value
1402    } else {
1403        return None;
1404    };
1405    let (hour, minute, second, fraction, timezone_offset) = parse_time_part_with_tz(time)?;
1406    (timezone_offset == 0).then_some(UnmodifiedHms {
1407        hour,
1408        minute,
1409        second,
1410        fraction,
1411    })
1412}
1413
1414struct ParsedDateTimeArgs {
1415    jdn: f64,
1416    subsec: bool,
1417    unmodified_hms: Option<UnmodifiedHms>,
1418}
1419
1420/// Parse the common `(time-value, modifier...)` argument layout.
1421fn parse_args(args: &[SqliteValue]) -> Option<ParsedDateTimeArgs> {
1422    let first_position_subsec = args.first().is_some_and(|value| {
1423        sqlite_value_is_keyword(value, "subsec") || sqlite_value_is_keyword(value, "subsecond")
1424    });
1425    let parsed = match args.first() {
1426        None => ParsedTimeValue {
1427            jdn: current_time_jdn(),
1428            raw_numeric: false,
1429            unmodified_hms: None,
1430        },
1431        Some(value) => parse_time_value(value)?,
1432    };
1433
1434    let mut modifiers = Vec::with_capacity(args.len().saturating_sub(1));
1435    for modifier in args.get(1..).unwrap_or_default() {
1436        modifiers.push(sqlite_value_datetime_text(modifier)?.into_owned());
1437    }
1438
1439    // C SQLite rejects a numeric argument outside the representable
1440    // Julian-day range (date.c validJulianDay), returning NULL rather than
1441    // formatting a saturated garbage date — unless the first modifier
1442    // reinterprets the raw number ('unixepoch', 'julianday', 'auto').
1443    if parsed.raw_numeric {
1444        let first = modifiers
1445            .first()
1446            .map(|modifier| modifier.to_ascii_lowercase());
1447        let reinterprets_raw = matches!(first.as_deref(), Some("unixepoch" | "julianday" | "auto"));
1448        if !reinterprets_raw && !(0.0..=AUTO_JDN_MAX).contains(&parsed.jdn) {
1449            return None;
1450        }
1451    }
1452
1453    let preserves_input_hms = modifiers.iter().all(|modifier| {
1454        modifier.eq_ignore_ascii_case("subsec") || modifier.eq_ignore_ascii_case("subsecond")
1455    });
1456    let (jdn, modifier_subsec) = apply_modifiers(parsed.jdn, &modifiers, parsed.raw_numeric)?;
1457    Some(ParsedDateTimeArgs {
1458        jdn,
1459        subsec: first_position_subsec || modifier_subsec,
1460        unmodified_hms: preserves_input_hms
1461            .then_some(parsed.unmodified_hms)
1462            .flatten(),
1463    })
1464}
1465
1466// ── date() ────────────────────────────────────────────────────────────────
1467
1468pub struct DateFunc;
1469
1470impl ScalarFunction for DateFunc {
1471    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1472        match parse_args(args) {
1473            Some(parsed) => Ok(SqliteValue::Text(format_date(parsed.jdn))),
1474            None => Ok(SqliteValue::Null),
1475        }
1476    }
1477
1478    fn num_args(&self) -> i32 {
1479        -1
1480    }
1481
1482    fn is_deterministic(&self) -> bool {
1483        false
1484    }
1485
1486    fn name(&self) -> &str {
1487        "date"
1488    }
1489}
1490
1491// ── time() ────────────────────────────────────────────────────────────────
1492
1493pub struct TimeFunc;
1494
1495impl ScalarFunction for TimeFunc {
1496    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1497        match parse_args(args) {
1498            Some(parsed) => Ok(SqliteValue::Text(format_time(
1499                parsed.jdn,
1500                parsed.subsec,
1501                parsed.unmodified_hms,
1502            ))),
1503            None => Ok(SqliteValue::Null),
1504        }
1505    }
1506
1507    fn num_args(&self) -> i32 {
1508        -1
1509    }
1510
1511    fn is_deterministic(&self) -> bool {
1512        false
1513    }
1514
1515    fn name(&self) -> &str {
1516        "time"
1517    }
1518}
1519
1520// ── datetime() ────────────────────────────────────────────────────────────
1521
1522pub struct DateTimeFunc;
1523
1524impl ScalarFunction for DateTimeFunc {
1525    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1526        match parse_args(args) {
1527            Some(parsed) => Ok(SqliteValue::Text(format_datetime(
1528                parsed.jdn,
1529                parsed.subsec,
1530                parsed.unmodified_hms,
1531            ))),
1532            None => Ok(SqliteValue::Null),
1533        }
1534    }
1535
1536    fn num_args(&self) -> i32 {
1537        -1
1538    }
1539
1540    fn is_deterministic(&self) -> bool {
1541        false
1542    }
1543
1544    fn name(&self) -> &str {
1545        "datetime"
1546    }
1547}
1548
1549// ── julianday() ───────────────────────────────────────────────────────────
1550
1551pub struct JuliandayFunc;
1552
1553impl ScalarFunction for JuliandayFunc {
1554    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1555        match parse_args(args) {
1556            Some(parsed) => Ok(SqliteValue::Float(parsed.jdn)),
1557            None => Ok(SqliteValue::Null),
1558        }
1559    }
1560
1561    fn num_args(&self) -> i32 {
1562        -1
1563    }
1564
1565    fn is_deterministic(&self) -> bool {
1566        false
1567    }
1568
1569    fn name(&self) -> &str {
1570        "julianday"
1571    }
1572}
1573
1574// ── unixepoch() ───────────────────────────────────────────────────────────
1575
1576pub struct UnixepochFunc;
1577
1578impl ScalarFunction for UnixepochFunc {
1579    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1580        match parse_args(args) {
1581            // bd-855l7: the 'subsec'/'subsecond' modifier makes unixepoch return
1582            // a floating-point value carrying the fractional seconds.
1583            Some(parsed) if parsed.subsec => Ok(SqliteValue::Float(jdn_to_unix_subsec(parsed.jdn))),
1584            Some(parsed) => Ok(SqliteValue::Integer(jdn_to_unix(parsed.jdn))),
1585            None => Ok(SqliteValue::Null),
1586        }
1587    }
1588
1589    fn num_args(&self) -> i32 {
1590        -1
1591    }
1592
1593    fn is_deterministic(&self) -> bool {
1594        false
1595    }
1596
1597    fn name(&self) -> &str {
1598        "unixepoch"
1599    }
1600}
1601
1602// ── strftime() ────────────────────────────────────────────────────────────
1603
1604pub struct StrftimeFunc;
1605
1606impl ScalarFunction for StrftimeFunc {
1607    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1608        let Some(format_value) = args.first() else {
1609            return Ok(SqliteValue::Null);
1610        };
1611        let Some(fmt) = sqlite_value_datetime_text(format_value) else {
1612            return Ok(SqliteValue::Null);
1613        };
1614        let rest = &args[1..];
1615        match parse_args(rest) {
1616            Some(parsed) => Ok(SqliteValue::Text(
1617                format_strftime(
1618                    fmt.as_ref(),
1619                    parsed.jdn,
1620                    parsed.subsec,
1621                    parsed.unmodified_hms,
1622                )
1623                .into(),
1624            )),
1625            None => Ok(SqliteValue::Null),
1626        }
1627    }
1628
1629    fn num_args(&self) -> i32 {
1630        -1
1631    }
1632
1633    fn is_deterministic(&self) -> bool {
1634        false
1635    }
1636
1637    fn name(&self) -> &str {
1638        "strftime"
1639    }
1640}
1641
1642// ── timediff() ────────────────────────────────────────────────────────────
1643
1644pub struct TimediffFunc;
1645
1646impl ScalarFunction for TimediffFunc {
1647    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1648        if args.len() < 2 || args[0].is_null() || args[1].is_null() {
1649            return Ok(SqliteValue::Null);
1650        }
1651
1652        let jdn1 = parse_time_value(&args[0])
1653            .map(|parsed| parsed.jdn)
1654            .filter(|jdn| (0.0..=AUTO_JDN_MAX).contains(jdn));
1655        let jdn2 = parse_time_value(&args[1])
1656            .map(|parsed| parsed.jdn)
1657            .filter(|jdn| (0.0..=AUTO_JDN_MAX).contains(jdn));
1658
1659        match (jdn1, jdn2) {
1660            (Some(j1), Some(j2)) => Ok(SqliteValue::Text(timediff_impl(j1, j2).into())),
1661            _ => Ok(SqliteValue::Null),
1662        }
1663    }
1664
1665    fn num_args(&self) -> i32 {
1666        2
1667    }
1668
1669    fn is_deterministic(&self) -> bool {
1670        false
1671    }
1672
1673    fn name(&self) -> &str {
1674        "timediff"
1675    }
1676}
1677
1678// ── Registration ──────────────────────────────────────────────────────────
1679
1680/// Register all §13.3 date/time functions.
1681pub fn register_datetime_builtins(registry: &mut FunctionRegistry) {
1682    registry.register_conditionally_deterministic_scalar(DateFunc);
1683    registry.register_conditionally_deterministic_scalar(TimeFunc);
1684    registry.register_conditionally_deterministic_scalar(DateTimeFunc);
1685    registry.register_conditionally_deterministic_scalar(JuliandayFunc);
1686    registry.register_conditionally_deterministic_scalar(UnixepochFunc);
1687    registry.register_conditionally_deterministic_scalar(StrftimeFunc);
1688    registry.register_conditionally_deterministic_scalar(TimediffFunc);
1689}
1690
1691// ── Tests ─────────────────────────────────────────────────────────────────
1692
1693#[cfg(test)]
1694mod tests {
1695    use super::*;
1696
1697    fn text(s: &str) -> SqliteValue {
1698        SqliteValue::Text(s.into())
1699    }
1700
1701    fn int(v: i64) -> SqliteValue {
1702        SqliteValue::Integer(v)
1703    }
1704
1705    fn float(v: f64) -> SqliteValue {
1706        SqliteValue::Float(v)
1707    }
1708
1709    fn null() -> SqliteValue {
1710        SqliteValue::Null
1711    }
1712
1713    fn assert_text(result: &SqliteValue, expected: &str) {
1714        match result {
1715            SqliteValue::Text(s) => assert_eq!(s.as_ref(), expected, "text mismatch"),
1716            other => panic!("expected Text(\"{expected}\"), got {other:?}"),
1717        }
1718    }
1719
1720    // ── Schema-expression safety ─────────────────────────────────────────
1721
1722    fn blob(s: &str) -> SqliteValue {
1723        SqliteValue::Blob(s.as_bytes().into())
1724    }
1725
1726    #[test]
1727    fn test_schema_safety_common_datetime_argument_layout() {
1728        for name in ["date", "time", "datetime", "julianday", "unixepoch"] {
1729            assert!(
1730                !is_datetime_invocation_safe_for_schema(name, &[]),
1731                "{name}() implicitly reads the current time"
1732            );
1733            assert!(is_datetime_invocation_safe_for_schema(
1734                name,
1735                &[text("2024-03-15 12:34:56")]
1736            ));
1737            assert!(is_datetime_invocation_safe_for_schema(name, &[int(0)]));
1738            assert!(is_datetime_invocation_safe_for_schema(
1739                name,
1740                &[float(2_460_384.5)]
1741            ));
1742            assert!(is_datetime_invocation_safe_for_schema(name, &[null()]));
1743
1744            for current_time in ["now", "NOW", "subsec", "SUBSECOND"] {
1745                assert!(
1746                    !is_datetime_invocation_safe_for_schema(name, &[text(current_time)]),
1747                    "{name}({current_time:?}) must be conditional"
1748                );
1749            }
1750            assert!(!is_datetime_invocation_safe_for_schema(
1751                name,
1752                &[blob("NOW")]
1753            ));
1754            assert!(!is_datetime_invocation_safe_for_schema(
1755                name,
1756                &[text("now\0ignored")]
1757            ));
1758            assert!(!is_datetime_invocation_safe_for_schema(
1759                name,
1760                &[SqliteValue::Blob(b"subsec\0\xff".as_slice().into())]
1761            ));
1762            for invalid_padded in [" now ", "subsec ", " subsecond"] {
1763                assert!(is_datetime_invocation_safe_for_schema(
1764                    name,
1765                    &[text(invalid_padded)]
1766                ));
1767            }
1768            assert!(is_datetime_invocation_safe_for_schema(
1769                name,
1770                &[blob(" NoW ")]
1771            ));
1772            assert!(is_datetime_invocation_safe_for_schema(
1773                name,
1774                &[text("nowhere")]
1775            ));
1776
1777            // These words only have conditional meaning in modifier position.
1778            assert!(is_datetime_invocation_safe_for_schema(
1779                name,
1780                &[text("localtime")]
1781            ));
1782            assert!(is_datetime_invocation_safe_for_schema(name, &[text("utc")]));
1783            assert!(is_datetime_invocation_safe_for_schema(
1784                name,
1785                &[text("2024-03-15"), text("subsec")]
1786            ));
1787            assert!(is_datetime_invocation_safe_for_schema(
1788                name,
1789                &[text("2024-03-15"), text("subsecond")]
1790            ));
1791
1792            for modifier in ["localtime", "LOCALTIME", "utc"] {
1793                assert!(
1794                    !is_datetime_invocation_safe_for_schema(
1795                        name,
1796                        &[text("2024-03-15"), text(modifier)]
1797                    ),
1798                    "{name} modifier {modifier:?} depends on host-local state"
1799                );
1800            }
1801            assert!(!is_datetime_invocation_safe_for_schema(
1802                name,
1803                &[text("2024-03-15"), blob("UTC")]
1804            ));
1805            assert!(!is_datetime_invocation_safe_for_schema(
1806                name,
1807                &[text("2024-03-15"), text("localtime\0ignored")]
1808            ));
1809            assert!(is_datetime_invocation_safe_for_schema(
1810                name,
1811                &[text("2024-03-15"), text(" utc ")]
1812            ));
1813
1814            // A prior NULL returns NULL without reaching later modifiers.
1815            assert!(is_datetime_invocation_safe_for_schema(
1816                name,
1817                &[text("2024-03-15"), null(), text("localtime")]
1818            ));
1819            assert!(!is_datetime_invocation_safe_for_schema(
1820                name,
1821                &[text("2024-03-15"), text("localtime"), null()]
1822            ));
1823
1824            // Invalid input or an invalid modifier stops evaluation before a
1825            // later conditional token is reached.
1826            assert!(is_datetime_invocation_safe_for_schema(
1827                name,
1828                &[text("bogus"), text("localtime")]
1829            ));
1830            assert!(is_datetime_invocation_safe_for_schema(
1831                name,
1832                &[text("2000-01-01"), text("bogus"), text("localtime")]
1833            ));
1834        }
1835    }
1836
1837    #[test]
1838    fn test_schema_safety_strftime_uses_shifted_time_arguments() {
1839        assert!(is_datetime_invocation_safe_for_schema("strftime", &[]));
1840        assert!(is_datetime_invocation_safe_for_schema(
1841            "strftime",
1842            &[null()]
1843        ));
1844        assert!(is_datetime_invocation_safe_for_schema(
1845            "strftime",
1846            &[null(), text("now")]
1847        ));
1848
1849        // A format without a time value implicitly formats the current time.
1850        assert!(!is_datetime_invocation_safe_for_schema(
1851            "strftime",
1852            &[text("%Y")]
1853        ));
1854        assert!(!is_datetime_invocation_safe_for_schema(
1855            "STRFTIME",
1856            &[text("%Y"), text("now")]
1857        ));
1858        assert!(!is_datetime_invocation_safe_for_schema(
1859            "strftime",
1860            &[text("%s"), text("subsecond")]
1861        ));
1862
1863        // Keywords in the format slot are ordinary fixed format strings.
1864        assert!(is_datetime_invocation_safe_for_schema(
1865            "strftime",
1866            &[text("now localtime utc"), text("2024-03-15")]
1867        ));
1868        assert!(is_datetime_invocation_safe_for_schema(
1869            "strftime",
1870            &[text("%Y"), int(0), text("unixepoch")]
1871        ));
1872        assert!(is_datetime_invocation_safe_for_schema(
1873            "strftime",
1874            &[text("%f"), text("2024-03-15"), text("subsec")]
1875        ));
1876        assert!(!is_datetime_invocation_safe_for_schema(
1877            "strftime",
1878            &[text("%Y"), text("2024-03-15"), text("localtime")]
1879        ));
1880        assert!(is_datetime_invocation_safe_for_schema(
1881            "strftime",
1882            &[text("%Y"), text("2024-03-15"), null(), text("localtime")]
1883        ));
1884    }
1885
1886    #[test]
1887    fn test_schema_safety_timediff_treats_both_inputs_as_time_values() {
1888        assert!(is_datetime_invocation_safe_for_schema("timediff", &[]));
1889        assert!(is_datetime_invocation_safe_for_schema(
1890            "timediff",
1891            &[text("2024-03-15")]
1892        ));
1893        assert!(is_datetime_invocation_safe_for_schema(
1894            "timediff",
1895            &[text("2024-03-15"), text("2024-03-14")]
1896        ));
1897        assert!(is_datetime_invocation_safe_for_schema(
1898            "timediff",
1899            &[int(2_460_384), float(2_460_383.5)]
1900        ));
1901
1902        for current_time in ["now", "subsec", "subsecond"] {
1903            assert!(!is_datetime_invocation_safe_for_schema(
1904                "timediff",
1905                &[text(current_time), text("2024-03-14")]
1906            ));
1907            assert!(!is_datetime_invocation_safe_for_schema(
1908                "timediff",
1909                &[text("2024-03-15"), text(current_time)]
1910            ));
1911        }
1912
1913        // timediff has no modifier positions, so these are fixed (invalid)
1914        // time strings rather than requests for host-local conversion.
1915        assert!(is_datetime_invocation_safe_for_schema(
1916            "timediff",
1917            &[text("localtime"), text("utc")]
1918        ));
1919        assert!(is_datetime_invocation_safe_for_schema(
1920            "timediff",
1921            &[null(), text("now")]
1922        ));
1923        assert!(is_datetime_invocation_safe_for_schema(
1924            "timediff",
1925            &[text("bogus"), text("now")]
1926        ));
1927        assert!(!is_datetime_invocation_safe_for_schema(
1928            "timediff",
1929            &[blob("NOW"), null()]
1930        ));
1931    }
1932
1933    #[test]
1934    fn test_schema_safety_ignores_non_datetime_function_names() {
1935        for name in ["", "my_date", "current_date", "date ", "random"] {
1936            assert!(is_datetime_invocation_safe_for_schema(
1937                name,
1938                &[text("now"), text("localtime")]
1939            ));
1940        }
1941    }
1942
1943    // ── Basic functions ───────────────────────────────────────────────
1944
1945    #[test]
1946    fn test_omitted_time_value_uses_current_time() {
1947        let date = DateFunc.invoke(&[]).unwrap();
1948        let time = TimeFunc.invoke(&[]).unwrap();
1949        let datetime = DateTimeFunc.invoke(&[]).unwrap();
1950        let julianday = JuliandayFunc.invoke(&[]).unwrap();
1951        let unixepoch = UnixepochFunc.invoke(&[]).unwrap();
1952        let year = StrftimeFunc.invoke(&[text("%Y")]).unwrap();
1953
1954        assert!(matches!(&date, SqliteValue::Text(value) if value.len() == 10));
1955        assert!(matches!(&time, SqliteValue::Text(value) if value.len() == 8));
1956        assert!(matches!(&datetime, SqliteValue::Text(value) if value.len() == 19));
1957        assert!(matches!(julianday, SqliteValue::Float(_)));
1958        assert!(matches!(unixepoch, SqliteValue::Integer(_)));
1959        assert!(matches!(&year, SqliteValue::Text(value)
1960            if value.len() == 4 && value.as_bytes().iter().all(u8::is_ascii_digit)));
1961
1962        assert_eq!(StrftimeFunc.invoke(&[]).unwrap(), SqliteValue::Null);
1963        assert_eq!(StrftimeFunc.invoke(&[null()]).unwrap(), SqliteValue::Null);
1964    }
1965
1966    #[test]
1967    fn test_first_position_subsec_aliases_use_current_time() {
1968        for alias in ["subsec", "subsecond"] {
1969            assert!(matches!(
1970                DateFunc.invoke(&[text(alias)]).unwrap(),
1971                SqliteValue::Text(value) if value.len() == 10
1972            ));
1973            assert!(matches!(
1974                TimeFunc.invoke(&[text(alias)]).unwrap(),
1975                SqliteValue::Text(value)
1976                    if value.len() == 12 && value.as_bytes_direct()[8] == b'.'
1977            ));
1978            assert!(matches!(
1979                DateTimeFunc.invoke(&[text(alias)]).unwrap(),
1980                SqliteValue::Text(value)
1981                    if value.len() == 23 && value.as_bytes_direct()[19] == b'.'
1982            ));
1983            assert!(matches!(
1984                JuliandayFunc.invoke(&[text(alias)]).unwrap(),
1985                SqliteValue::Float(_)
1986            ));
1987            assert!(matches!(
1988                UnixepochFunc.invoke(&[text(alias)]).unwrap(),
1989                SqliteValue::Float(_)
1990            ));
1991            assert!(matches!(
1992                StrftimeFunc.invoke(&[text("%s"), text(alias)]).unwrap(),
1993                SqliteValue::Text(value)
1994                    if value.rsplit_once('.').is_some_and(|(_, fraction)| fraction.len() == 3)
1995            ));
1996        }
1997    }
1998
1999    #[test]
2000    fn test_padded_and_nul_terminated_special_values() {
2001        for invalid in [" now ", "subsec ", " subsecond"] {
2002            assert_eq!(
2003                DateFunc.invoke(&[text(invalid)]).unwrap(),
2004                SqliteValue::Null
2005            );
2006        }
2007        assert_eq!(
2008            DateFunc
2009                .invoke(&[text("2000-01-01"), text(" localtime ")])
2010                .unwrap(),
2011            SqliteValue::Null
2012        );
2013        assert!(matches!(
2014            DateFunc.invoke(&[text("now\0ignored")]).unwrap(),
2015            SqliteValue::Text(value) if value.len() == 10
2016        ));
2017        assert!(matches!(
2018            TimeFunc
2019                .invoke(&[SqliteValue::Blob(b"subsec\0\xff".as_slice().into())])
2020                .unwrap(),
2021            SqliteValue::Text(value) if value.len() == 12
2022        ));
2023    }
2024
2025    #[test]
2026    fn test_date_basic() {
2027        let r = DateFunc.invoke(&[text("2024-03-15 14:30:00")]).unwrap();
2028        assert_text(&r, "2024-03-15");
2029    }
2030
2031    #[test]
2032    fn test_time_basic() {
2033        let r = TimeFunc.invoke(&[text("2024-03-15 14:30:45")]).unwrap();
2034        assert_text(&r, "14:30:45");
2035    }
2036
2037    #[test]
2038    fn test_datetime_basic() {
2039        let r = DateTimeFunc.invoke(&[text("2024-03-15 14:30:00")]).unwrap();
2040        assert_text(&r, "2024-03-15 14:30:00");
2041    }
2042
2043    #[test]
2044    fn test_julianday_basic() {
2045        let r = JuliandayFunc.invoke(&[text("2024-03-15")]).unwrap();
2046        match r {
2047            SqliteValue::Float(jdn) => {
2048                // JDN for 2024-03-15 should be approximately 2460384.5
2049                assert!((jdn - 2_460_384.5).abs() < 0.01, "unexpected JDN: {jdn}");
2050            }
2051            other => panic!("expected Float, got {other:?}"),
2052        }
2053    }
2054
2055    // ── RFC3339 / ISO-8601 timezone suffix parsing ────────────────────
2056    //
2057    // Regression coverage for issue #64: julianday() must accept
2058    // Z / ±HH:MM / ±HHMM / ±HH timezone-bearing timestamps and convert
2059    // them to UTC before computing the Julian day.  The expected JDN
2060    // values below match the C SQLite reference implementation.
2061
2062    fn julianday_float(input: &str) -> f64 {
2063        match JuliandayFunc.invoke(&[text(input)]).unwrap() {
2064            SqliteValue::Float(v) => v,
2065            other => panic!("expected Float, got {other:?} for input {input:?}"),
2066        }
2067    }
2068
2069    fn assert_jdn_close(actual: f64, expected: f64, ctx: &str) {
2070        // 1 µs precision (86400e6 µs / day) is well within float epsilon.
2071        assert!(
2072            (actual - expected).abs() < 1e-6,
2073            "JDN mismatch for {ctx}: got {actual}, expected {expected}"
2074        );
2075    }
2076
2077    #[test]
2078    fn test_julianday_rfc3339_z_suffix() {
2079        // Zulu (UTC) — should match the equivalent naive form exactly.
2080        let naive = julianday_float("2026-04-07 16:00:00");
2081        assert_jdn_close(julianday_float("2026-04-07T16:00:00Z"), naive, "T...Z");
2082        assert_jdn_close(
2083            julianday_float("2026-04-07T16:00:00z"),
2084            naive,
2085            "lowercase z",
2086        );
2087    }
2088
2089    #[test]
2090    fn test_julianday_rfc3339_zero_offset() {
2091        let naive = julianday_float("2026-04-07 16:00:00");
2092        assert_jdn_close(
2093            julianday_float("2026-04-07T16:00:00+00:00"),
2094            naive,
2095            "+00:00",
2096        );
2097        assert_jdn_close(
2098            julianday_float("2026-04-07T16:00:00-00:00"),
2099            naive,
2100            "-00:00",
2101        );
2102    }
2103
2104    #[test]
2105    fn test_julianday_rfc3339_positive_offset() {
2106        // 16:00 +01:00 = 15:00 UTC → JDN is 1 hour (1/24) earlier.
2107        let base = julianday_float("2026-04-07 16:00:00");
2108        let expected = base - 1.0 / 24.0;
2109        assert_jdn_close(
2110            julianday_float("2026-04-07T16:00:00+01:00"),
2111            expected,
2112            "+01:00",
2113        );
2114    }
2115
2116    #[test]
2117    fn test_julianday_rfc3339_negative_offset() {
2118        // 16:00 -05:00 = 21:00 UTC → JDN is 5 hours later.
2119        let base = julianday_float("2026-04-07 16:00:00");
2120        let expected = base + 5.0 / 24.0;
2121        assert_jdn_close(
2122            julianday_float("2026-04-07T16:00:00-05:00"),
2123            expected,
2124            "-05:00",
2125        );
2126    }
2127
2128    #[test]
2129    fn test_julianday_rfc3339_half_hour_offset() {
2130        // India Standard Time is UTC+05:30.
2131        let base = julianday_float("2026-04-07 16:00:00");
2132        let expected = base - 5.5 / 24.0;
2133        assert_jdn_close(
2134            julianday_float("2026-04-07T16:00:00+05:30"),
2135            expected,
2136            "+05:30",
2137        );
2138    }
2139
2140    #[test]
2141    fn test_julianday_rfc3339_compact_offsets() {
2142        // Compact ISO-8601 offsets: ±HHMM and ±HH.
2143        let base = julianday_float("2026-04-07 16:00:00");
2144        assert_jdn_close(
2145            julianday_float("2026-04-07T16:00:00+0100"),
2146            base - 1.0 / 24.0,
2147            "+0100",
2148        );
2149        assert_jdn_close(
2150            julianday_float("2026-04-07T16:00:00-0530"),
2151            base + 5.5 / 24.0,
2152            "-0530",
2153        );
2154        assert_jdn_close(
2155            julianday_float("2026-04-07T16:00:00+09"),
2156            base - 9.0 / 24.0,
2157            "+09",
2158        );
2159    }
2160
2161    #[test]
2162    fn test_julianday_rfc3339_fractional_seconds_with_tz() {
2163        // Fractional seconds must play nicely with the TZ suffix split.
2164        let base = julianday_float("2026-04-07 16:00:00.500");
2165        assert_jdn_close(
2166            julianday_float("2026-04-07T16:00:00.500Z"),
2167            base,
2168            "fractional + Z",
2169        );
2170        assert_jdn_close(
2171            julianday_float("2026-04-07T16:00:00.500+01:00"),
2172            base - 1.0 / 24.0,
2173            "fractional + +01:00",
2174        );
2175    }
2176
2177    #[test]
2178    fn test_date_and_time_rfc3339_round_trip() {
2179        // date()/time()/datetime() all flow through parse_timestring, so
2180        // they should agree with the timezone conversion above.
2181        assert_text(
2182            &DateFunc
2183                .invoke(&[text("2026-04-07T16:00:00+05:00")])
2184                .unwrap(),
2185            // 16:00 +05:00 = 11:00 UTC on the same date.
2186            "2026-04-07",
2187        );
2188        assert_text(
2189            &TimeFunc
2190                .invoke(&[text("2026-04-07T16:00:00+05:00")])
2191                .unwrap(),
2192            "11:00:00",
2193        );
2194        assert_text(
2195            &DateTimeFunc
2196                .invoke(&[text("2026-04-07T16:00:00+05:00")])
2197                .unwrap(),
2198            "2026-04-07 11:00:00",
2199        );
2200    }
2201
2202    #[test]
2203    fn test_julianday_rfc3339_invalid_offsets_return_null() {
2204        // Malformed offsets fall through and return NULL (invalid input).
2205        for bad in &[
2206            "2026-04-07T16:00:00+25:00", // hour out of range
2207            "2026-04-07T16:00:00+01:99", // minute out of range
2208            "2026-04-07T16:00:00+1",     // too short
2209            "2026-04-07T16:00:00+123",   // wrong width
2210        ] {
2211            let result = JuliandayFunc.invoke(&[text(bad)]).unwrap();
2212            assert_eq!(
2213                result,
2214                SqliteValue::Null,
2215                "expected NULL for malformed offset {bad:?}, got {result:?}"
2216            );
2217        }
2218    }
2219
2220    #[test]
2221    fn test_julianday_rejects_malformed_time_fields() {
2222        // C SQLite's computeHMS requires exactly 2 bare decimal digits
2223        // for each HH, MM, SS field.  Inputs with wrong digit counts,
2224        // leading signs, or non-digit characters must return NULL.
2225        for bad in &[
2226            "+01:00",        // leading + on hour
2227            "-05:30",        // leading - on hour
2228            "+12:30:00",     // leading + on hour (with seconds)
2229            "12:+30:00",     // leading + on minute
2230            "12:30:+45",     // leading + on seconds (integer)
2231            "12:30:+45.123", // leading + on seconds (fractional)
2232            "0:00:00",       // 1-digit hour
2233            "12:0:00",       // 1-digit minute
2234            "12:30:0",       // 1-digit second
2235            "123:00:00",     // 3-digit hour
2236            "12:345:00",     // 3-digit minute
2237        ] {
2238            let result = JuliandayFunc.invoke(&[text(bad)]).unwrap();
2239            assert_eq!(
2240                result,
2241                SqliteValue::Null,
2242                "expected NULL for signed time field {bad:?}, got {result:?}"
2243            );
2244        }
2245    }
2246
2247    #[test]
2248    fn test_unixepoch_basic() {
2249        let r = UnixepochFunc
2250            .invoke(&[text("1970-01-01 00:00:00")])
2251            .unwrap();
2252        assert_eq!(r, int(0));
2253    }
2254
2255    #[test]
2256    fn test_unixepoch_known_date() {
2257        let r = UnixepochFunc
2258            .invoke(&[text("2024-01-01 00:00:00")])
2259            .unwrap();
2260        // 2024-01-01 00:00:00 UTC = 1704067200
2261        assert_eq!(r, int(1_704_067_200));
2262    }
2263
2264    // ── Modifiers ─────────────────────────────────────────────────────
2265
2266    #[test]
2267    fn test_modifier_days() {
2268        let r = DateFunc
2269            .invoke(&[text("2024-01-15"), text("+10 days")])
2270            .unwrap();
2271        assert_text(&r, "2024-01-25");
2272    }
2273
2274    #[test]
2275    fn test_modifier_months() {
2276        // 2024-01-31 + 1 month: C SQLite lets day=31 overflow via JDN
2277        // arithmetic → Feb 31 wraps to Mar 2 (2024 is a leap year).
2278        let r = DateFunc
2279            .invoke(&[text("2024-01-31"), text("+1 months")])
2280            .unwrap();
2281        assert_text(&r, "2024-03-02");
2282    }
2283
2284    #[test]
2285    fn test_modifier_years() {
2286        // 2024-02-29 + 1 year: 2025 is not a leap year, day=29 overflows
2287        // via JDN arithmetic → Mar 1.
2288        let r = DateFunc
2289            .invoke(&[text("2024-02-29"), text("+1 years")])
2290            .unwrap();
2291        assert_text(&r, "2025-03-01");
2292    }
2293
2294    #[test]
2295    fn test_modifier_hours() {
2296        let r = DateTimeFunc
2297            .invoke(&[text("2024-01-01 23:00:00"), text("+2 hours")])
2298            .unwrap();
2299        assert_text(&r, "2024-01-02 01:00:00");
2300    }
2301
2302    #[test]
2303    fn test_modifier_start_of_month() {
2304        let r = DateFunc
2305            .invoke(&[text("2024-03-15"), text("start of month")])
2306            .unwrap();
2307        assert_text(&r, "2024-03-01");
2308    }
2309
2310    #[test]
2311    fn test_modifier_start_of_year() {
2312        let r = DateFunc
2313            .invoke(&[text("2024-06-15"), text("start of year")])
2314            .unwrap();
2315        assert_text(&r, "2024-01-01");
2316    }
2317
2318    #[test]
2319    fn test_modifier_start_of_day() {
2320        let r = DateTimeFunc
2321            .invoke(&[text("2024-03-15 14:30:00"), text("start of day")])
2322            .unwrap();
2323        assert_text(&r, "2024-03-15 00:00:00");
2324    }
2325
2326    #[test]
2327    fn test_modifier_unixepoch() {
2328        let r = DateTimeFunc.invoke(&[int(0), text("unixepoch")]).unwrap();
2329        assert_text(&r, "1970-01-01 00:00:00");
2330    }
2331
2332    #[test]
2333    fn test_modifier_weekday() {
2334        // 2024-03-15 is Friday. `weekday 0` advances to the next Sunday.
2335        let r = DateFunc
2336            .invoke(&[text("2024-03-15"), text("weekday 0")])
2337            .unwrap();
2338        assert_text(&r, "2024-03-17");
2339    }
2340
2341    #[test]
2342    fn test_modifier_auto_unixepoch() {
2343        let ts = int(1_710_531_045);
2344        let r = DateTimeFunc.invoke(&[ts.clone(), text("auto")]).unwrap();
2345        let expected = DateTimeFunc.invoke(&[ts, text("unixepoch")]).unwrap();
2346        assert_eq!(
2347            r, expected,
2348            "auto and unixepoch should agree for unix-like values"
2349        );
2350    }
2351
2352    #[test]
2353    fn test_modifier_auto_julian_day() {
2354        let r = DateFunc
2355            .invoke(&[float(2_460_384.5), text("auto")])
2356            .unwrap();
2357        assert_text(&r, "2024-03-15");
2358    }
2359
2360    #[test]
2361    fn test_modifier_localtime_utc_roundtrip() {
2362        // localtime→utc should roundtrip back to the original value.
2363        let r = DateTimeFunc
2364            .invoke(&[text("2024-03-15 14:30:45"), text("localtime"), text("utc")])
2365            .unwrap();
2366        assert_text(&r, "2024-03-15 14:30:45");
2367    }
2368
2369    #[test]
2370    fn test_modifier_localtime_shifts_value() {
2371        // When system offset != 0, 'localtime' should actually shift the value.
2372        let offset = utc_offset_for_utc_jdn(ymdhms_to_jdn(2024, 3, 15, 12, 0, 0, 0.0));
2373        if offset != 0 {
2374            let r = DateTimeFunc
2375                .invoke(&[text("2024-03-15 12:00:00"), text("localtime")])
2376                .unwrap();
2377            // The shifted value should differ from the input.
2378            let shifted = match &r {
2379                SqliteValue::Text(s) => s.clone(),
2380                _ => panic!("expected text"),
2381            };
2382            assert_ne!(&*shifted, "2024-03-15 12:00:00");
2383        }
2384    }
2385
2386    #[test]
2387    fn test_modifier_auto_out_of_range_returns_null() {
2388        let r = DateTimeFunc.invoke(&[float(1.0e20), text("auto")]).unwrap();
2389        assert_eq!(r, SqliteValue::Null);
2390    }
2391
2392    #[test]
2393    fn test_modifier_order_matters() {
2394        // 'start of month' then '+1 day' = March 2nd.
2395        let r1 = DateFunc
2396            .invoke(&[text("2024-03-15"), text("start of month"), text("+1 days")])
2397            .unwrap();
2398        assert_text(&r1, "2024-03-02");
2399
2400        // '+1 day' then 'start of month' = March 1st.
2401        let r2 = DateFunc
2402            .invoke(&[text("2024-03-15"), text("+1 days"), text("start of month")])
2403            .unwrap();
2404        assert_text(&r2, "2024-03-01");
2405    }
2406
2407    #[test]
2408    fn test_modifier_weekday_same_day_is_noop() {
2409        // 2024-03-17 is Sunday; SQLite semantics: already on target weekday, no-op.
2410        let r = DateFunc
2411            .invoke(&[text("2024-03-17"), text("weekday 0")])
2412            .unwrap();
2413        assert_text(&r, "2024-03-17");
2414    }
2415
2416    // ── Input formats ─────────────────────────────────────────────────
2417
2418    #[test]
2419    fn test_bare_time_defaults() {
2420        let r = DateFunc.invoke(&[text("12:30:00")]).unwrap();
2421        assert_text(&r, "2000-01-01");
2422    }
2423
2424    #[test]
2425    fn test_t_separator() {
2426        let r = DateTimeFunc.invoke(&[text("2024-03-15T14:30:00")]).unwrap();
2427        assert_text(&r, "2024-03-15 14:30:00");
2428    }
2429
2430    #[test]
2431    fn test_julian_day_input() {
2432        // 2460384.5 is 2024-03-15.
2433        let r = DateFunc.invoke(&[float(2_460_384.5)]).unwrap();
2434        assert_text(&r, "2024-03-15");
2435    }
2436
2437    #[test]
2438    fn test_null_input() {
2439        assert_eq!(DateFunc.invoke(&[null()]).unwrap(), SqliteValue::Null);
2440    }
2441
2442    #[test]
2443    fn test_invalid_input() {
2444        assert_eq!(
2445            DateFunc.invoke(&[text("not-a-date")]).unwrap(),
2446            SqliteValue::Null
2447        );
2448    }
2449
2450    #[test]
2451    fn test_negative_time_component_invalid() {
2452        let r = TimeFunc.invoke(&[text("-01:00")]).unwrap();
2453        assert_eq!(r, SqliteValue::Null);
2454    }
2455
2456    // ── Leap year ─────────────────────────────────────────────────────
2457
2458    #[test]
2459    fn test_leap_year() {
2460        let r = DateFunc
2461            .invoke(&[text("2024-02-28"), text("+1 days")])
2462            .unwrap();
2463        assert_text(&r, "2024-02-29");
2464    }
2465
2466    #[test]
2467    fn test_non_leap_year() {
2468        let r = DateFunc
2469            .invoke(&[text("2023-02-28"), text("+1 days")])
2470            .unwrap();
2471        assert_text(&r, "2023-03-01");
2472    }
2473
2474    // ── strftime ──────────────────────────────────────────────────────
2475
2476    #[test]
2477    fn test_strftime_basic() {
2478        let r = StrftimeFunc
2479            .invoke(&[text("%Y-%m-%d"), text("2024-03-15")])
2480            .unwrap();
2481        assert_text(&r, "2024-03-15");
2482    }
2483
2484    #[test]
2485    fn test_strftime_time_specifiers() {
2486        let r = StrftimeFunc
2487            .invoke(&[text("%H:%M:%S"), text("2024-03-15 14:30:45")])
2488            .unwrap();
2489        assert_text(&r, "14:30:45");
2490    }
2491
2492    #[test]
2493    fn test_strftime_unix_seconds() {
2494        let r = StrftimeFunc
2495            .invoke(&[text("%s"), text("1970-01-01 00:00:00")])
2496            .unwrap();
2497        assert_text(&r, "0");
2498    }
2499
2500    #[test]
2501    fn test_strftime_day_of_year() {
2502        let r = StrftimeFunc
2503            .invoke(&[text("%j"), text("2024-03-15")])
2504            .unwrap();
2505        // 2024-03-15: Jan(31) + Feb(29) + 15 = 75
2506        assert_text(&r, "075");
2507    }
2508
2509    #[test]
2510    fn test_strftime_day_of_week() {
2511        // 2024-03-15 is a Friday → w=5 (0=Sunday), u=5 (1=Monday)
2512        let r = StrftimeFunc
2513            .invoke(&[text("%w"), text("2024-03-15")])
2514            .unwrap();
2515        assert_text(&r, "5");
2516
2517        let r = StrftimeFunc
2518            .invoke(&[text("%u"), text("2024-03-15")])
2519            .unwrap();
2520        assert_text(&r, "5");
2521    }
2522
2523    #[test]
2524    fn test_strftime_12hour() {
2525        let r = StrftimeFunc
2526            .invoke(&[text("%I %p"), text("2024-03-15 14:30:00")])
2527            .unwrap();
2528        assert_text(&r, "02 PM");
2529
2530        let r = StrftimeFunc
2531            .invoke(&[text("%I %P"), text("2024-03-15 09:30:00")])
2532            .unwrap();
2533        assert_text(&r, "09 am");
2534    }
2535
2536    #[test]
2537    fn test_strftime_all_specifiers_presence() {
2538        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|%%";
2539        let r = StrftimeFunc
2540            .invoke(&[text(fmt), text("2024-03-15 14:30:45.123")])
2541            .unwrap();
2542
2543        let s = match r {
2544            SqliteValue::Text(v) => v,
2545            other => panic!("expected Text, got {other:?}"),
2546        };
2547        let parts: Vec<&str> = s.split('|').collect();
2548        assert_eq!(parts.len(), 25, "unexpected specifier output: {s}");
2549        assert_eq!(parts[0], "15"); // %d
2550        assert_eq!(parts[1], "15"); // %e
2551        assert_eq!(parts[2], "45.123"); // %f
2552        assert_eq!(parts[3], "14"); // %H
2553        assert_eq!(parts[4], "02"); // %I
2554        assert_eq!(parts[5], "075"); // %j
2555        assert!(
2556            parts[6].parse::<f64>().is_ok(),
2557            "expected numeric %J output, got {}",
2558            parts[6]
2559        );
2560        assert_eq!(parts[7], "14"); // %k
2561        assert_eq!(parts[8], " 2"); // %l
2562        assert_eq!(parts[9], "03"); // %m
2563        assert_eq!(parts[10], "30"); // %M
2564        assert_eq!(parts[11], "PM"); // %p
2565        assert_eq!(parts[12], "pm"); // %P
2566        assert_eq!(parts[13], "14:30"); // %R
2567        assert!(
2568            parts[14].parse::<i64>().is_ok(),
2569            "expected numeric %s output, got {}",
2570            parts[14]
2571        );
2572        assert_eq!(parts[15], "45"); // %S
2573        assert_eq!(parts[16], "14:30:45"); // %T
2574        assert_eq!(parts[17], "5"); // %u
2575        assert_eq!(parts[18], "5"); // %w
2576        assert_eq!(parts[19], "11"); // %W
2577        assert_eq!(parts[20], "2024"); // %G
2578        assert_eq!(parts[21], "24"); // %g
2579        assert_eq!(parts[22], "11"); // %V
2580        assert_eq!(parts[23], "2024"); // %Y
2581        assert_eq!(parts[24], "%"); // %%
2582    }
2583
2584    #[test]
2585    fn test_strftime_null() {
2586        assert_eq!(
2587            StrftimeFunc.invoke(&[null(), text("2024-01-01")]).unwrap(),
2588            SqliteValue::Null
2589        );
2590        assert_eq!(
2591            StrftimeFunc.invoke(&[text("%Y"), null()]).unwrap(),
2592            SqliteValue::Null
2593        );
2594    }
2595
2596    #[test]
2597    #[ignore = "perf-only benchmark"]
2598    fn perf_strftime_timestamp_rows() {
2599        use std::hint::black_box;
2600        use std::time::Instant;
2601
2602        const ROWS: usize = 200_000;
2603        const REPEATS: usize = 5;
2604        const FORMAT: &str = "%Y-%m-%d %H:%M:%S";
2605        const INPUT: &str = "2024-03-15 14:30:45";
2606
2607        let func = StrftimeFunc;
2608        let fmt = text(FORMAT);
2609        let input = text(INPUT);
2610        let mut best_ns = u128::MAX;
2611        let mut output_len = 0usize;
2612
2613        for _ in 0..REPEATS {
2614            let started = Instant::now();
2615            for _ in 0..ROWS {
2616                let result = black_box(
2617                    func.invoke(black_box(&[fmt.clone(), input.clone()]))
2618                        .expect("strftime benchmark invocation must succeed"),
2619                );
2620                output_len = match result {
2621                    SqliteValue::Text(text) => text.len(),
2622                    SqliteValue::Null
2623                    | SqliteValue::Integer(_)
2624                    | SqliteValue::Float(_)
2625                    | SqliteValue::Blob(_) => 0,
2626                };
2627            }
2628            let elapsed_ns = started.elapsed().as_nanos();
2629            if elapsed_ns < best_ns {
2630                best_ns = elapsed_ns;
2631            }
2632        }
2633
2634        println!(
2635            "strftime_timestamp_rows rows={ROWS} repeats={REPEATS} best_ns={best_ns} output_len={output_len}"
2636        );
2637    }
2638
2639    // ── timediff ──────────────────────────────────────────────────────
2640
2641    #[test]
2642    fn test_timediff_basic() {
2643        let r = TimediffFunc
2644            .invoke(&[text("2024-03-15"), text("2024-03-10")])
2645            .unwrap();
2646        assert_text(&r, "+0000-00-05 00:00:00.000");
2647    }
2648
2649    #[test]
2650    fn test_timediff_negative() {
2651        let r = TimediffFunc
2652            .invoke(&[text("2024-03-10"), text("2024-03-15")])
2653            .unwrap();
2654        assert_text(&r, "-0000-00-05 00:00:00.000");
2655    }
2656
2657    #[test]
2658    fn test_timediff_year_boundary() {
2659        let r = TimediffFunc
2660            .invoke(&[text("2024-01-01 01:00:00"), text("2023-12-31 23:00:00")])
2661            .unwrap();
2662        assert_text(&r, "+0000-00-00 02:00:00.000");
2663    }
2664
2665    // ── Subsec modifier ───────────────────────────────────────────────
2666
2667    #[test]
2668    fn test_modifier_subsec() {
2669        assert_text(
2670            &TimeFunc
2671                .invoke(&[text("2024-01-01 12:00:00"), text("subsec")])
2672                .unwrap(),
2673            "12:00:00.000",
2674        );
2675        assert_text(
2676            &DateTimeFunc
2677                .invoke(&[text("2024-01-01 12:00:00"), text("subsecond")])
2678                .unwrap(),
2679            "2024-01-01 12:00:00.000",
2680        );
2681        assert_text(
2682            &TimeFunc
2683                .invoke(&[text("2024-01-01 12:00:00.123"), text("subsec")])
2684                .unwrap(),
2685            "12:00:00.123",
2686        );
2687    }
2688
2689    #[test]
2690    fn test_subsec_unix_seconds_and_integer_flooring() {
2691        assert_text(
2692            &StrftimeFunc
2693                .invoke(&[text("%s"), text("1970-01-01 00:00:00.125"), text("subsec")])
2694                .unwrap(),
2695            "0.125",
2696        );
2697        assert_eq!(
2698            UnixepochFunc
2699                .invoke(&[text("1970-01-01 00:00:00.125"), text("subsec")])
2700                .unwrap(),
2701            float(0.125)
2702        );
2703
2704        for input in ["1970-01-01 00:00:00.500", "1970-01-01 00:00:00.999"] {
2705            assert_eq!(UnixepochFunc.invoke(&[text(input)]).unwrap(), int(0));
2706            assert_text(
2707                &StrftimeFunc.invoke(&[text("%s"), text(input)]).unwrap(),
2708                "0",
2709            );
2710        }
2711        assert_eq!(
2712            UnixepochFunc
2713                .invoke(&[text("1969-12-31 23:59:59.999")])
2714                .unwrap(),
2715            int(-1)
2716        );
2717        assert_text(
2718            &StrftimeFunc
2719                .invoke(&[text("%s"), text("1969-12-31 23:59:59.999")])
2720                .unwrap(),
2721            "-1",
2722        );
2723    }
2724
2725    #[test]
2726    fn test_subsec_rounding_preserves_sqlite_second_60() {
2727        assert_text(
2728            &TimeFunc
2729                .invoke(&[text("12:34:59.9995"), text("subsec")])
2730                .unwrap(),
2731            "12:34:60.000",
2732        );
2733        assert_text(
2734            &DateTimeFunc
2735                .invoke(&[text("1970-01-01 23:59:59.9995"), text("subsec")])
2736                .unwrap(),
2737            "1970-01-01 23:59:60.000",
2738        );
2739        assert_text(
2740            &StrftimeFunc
2741                .invoke(&[
2742                    text("%H:%M:%f|%s"),
2743                    text("1970-01-01 23:59:59.9995"),
2744                    text("subsec"),
2745                ])
2746                .unwrap(),
2747            "23:59:59.999|86400.000",
2748        );
2749    }
2750
2751    // ── Registration ──────────────────────────────────────────────────
2752
2753    #[test]
2754    fn test_register_datetime_builtins_all_present() {
2755        let mut reg = FunctionRegistry::new();
2756        register_datetime_builtins(&mut reg);
2757
2758        let expected = [
2759            "date",
2760            "time",
2761            "datetime",
2762            "julianday",
2763            "unixepoch",
2764            "strftime",
2765            "timediff",
2766        ];
2767
2768        for name in expected {
2769            assert!(
2770                reg.find_scalar(name, 1).is_some() || reg.find_scalar(name, 2).is_some(),
2771                "datetime function '{name}' not registered"
2772            );
2773        }
2774    }
2775
2776    #[test]
2777    fn test_public_datetime_function_metadata_fails_closed_for_direct_registration() {
2778        assert!(!DateFunc.is_deterministic());
2779        assert!(!TimeFunc.is_deterministic());
2780        assert!(!DateTimeFunc.is_deterministic());
2781        assert!(!JuliandayFunc.is_deterministic());
2782        assert!(!UnixepochFunc.is_deterministic());
2783        assert!(!StrftimeFunc.is_deterministic());
2784        assert!(!TimediffFunc.is_deterministic());
2785
2786        let mut registry = FunctionRegistry::new();
2787        registry.register_scalar(DateFunc);
2788        registry.register_scalar(TimeFunc);
2789        registry.register_scalar(DateTimeFunc);
2790        registry.register_scalar(JuliandayFunc);
2791        registry.register_scalar(UnixepochFunc);
2792        registry.register_scalar(StrftimeFunc);
2793        registry.register_scalar(TimediffFunc);
2794        for (name, num_args) in [
2795            ("date", 0),
2796            ("time", 0),
2797            ("datetime", 0),
2798            ("julianday", 0),
2799            ("unixepoch", 0),
2800            ("strftime", 1),
2801            ("timediff", 2),
2802        ] {
2803            assert_eq!(
2804                registry.scalar_schema_safety(name, num_args),
2805                Some(crate::ScalarSchemaSafety::Never),
2806                "generic registration of {name}/{num_args} must fail closed"
2807            );
2808        }
2809    }
2810
2811    // ── JDN roundtrip ─────────────────────────────────────────────────
2812
2813    #[test]
2814    fn test_modifier_year_overflow() {
2815        // "+9223372036854775807 years" causes i64 overflow in year calculation.
2816        // Should return NULL, not panic.
2817        let huge = i64::MAX;
2818        let modifier = format!("+{huge} years");
2819        let r = DateFunc.invoke(&[text("2000-01-01"), text(&modifier)]);
2820        // The implementation should catch overflow and return Ok(Null), or at least not panic.
2821        // If it panics, the test harness catches it (but we want to prevent panics).
2822        assert_eq!(r.unwrap(), SqliteValue::Null);
2823    }
2824
2825    #[test]
2826    fn test_jdn_roundtrip() {
2827        // Test that ymd → jdn → ymd roundtrips correctly.
2828        let dates = [
2829            (2024, 3, 15),
2830            (2000, 1, 1),
2831            (1970, 1, 1),
2832            (2024, 2, 29),
2833            (1900, 1, 1),
2834            (2099, 12, 31),
2835        ];
2836        for (y, m, d) in dates {
2837            let jdn = ymd_to_jdn(y, m, d);
2838            let (y2, m2, d2) = jdn_to_ymd(jdn);
2839            assert_eq!(
2840                (y, m, d),
2841                (y2, m2, d2),
2842                "roundtrip failed for {y}-{m}-{d} (JDN={jdn})"
2843            );
2844        }
2845    }
2846
2847    #[test]
2848    fn test_unix_epoch_roundtrip() {
2849        let jdn = ymd_to_jdn(1970, 1, 1);
2850        let unix = jdn_to_unix(jdn);
2851        assert_eq!(unix, 0, "Unix epoch should be 0");
2852
2853        let jdn2 = unix_to_jdn(0.0);
2854        assert!((jdn2 - UNIX_EPOCH_JDN).abs() < 1e-10, "roundtrip failed");
2855    }
2856}