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