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(jdn: f64) -> i64 {
196    ((jdn - UNIX_EPOCH_JDN) * 86400.0).round() as i64
197}
198
199fn unix_to_jdn(ts: f64) -> f64 {
200    ts / 86400.0 + UNIX_EPOCH_JDN
201}
202
203fn is_leap_year(y: i64) -> bool {
204    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
205}
206
207fn days_in_month(y: i64, m: i64) -> i64 {
208    match m {
209        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
210        4 | 6 | 9 | 11 => 30,
211        2 => {
212            if is_leap_year(y) {
213                29
214            } else {
215                28
216            }
217        }
218        _ => 30,
219    }
220}
221
222fn day_of_year(y: i64, m: i64, d: i64) -> i64 {
223    let mut doy = d;
224    for mo in 1..m {
225        doy = doy.saturating_add(days_in_month(y, mo));
226    }
227    doy
228}
229
230// ── Time String Parsing ───────────────────────────────────────────────────
231
232/// Parse a SQLite time string into a JDN.
233fn parse_timestring(s: &str) -> Option<f64> {
234    let s = s.trim();
235
236    // Special value: 'now' — return the current UTC wall-clock time as JDN.
237    // C SQLite (date.c:451) captures time once per sqlite3_step() via the VFS
238    // and caches it; we use SystemTime::now() which gives per-call resolution.
239    // A future refinement (Track: Cx time source) could freeze time at
240    // statement start for full C SQLite compatibility.
241    if s.eq_ignore_ascii_case("now") {
242        use std::time::{SystemTime, UNIX_EPOCH};
243        let secs = SystemTime::now()
244            .duration_since(UNIX_EPOCH)
245            .unwrap_or_default()
246            .as_secs_f64();
247        // Unix epoch (1970-01-01 00:00:00) = JDN 2_440_587.5
248        return Some(2_440_587.5 + secs / 86_400.0);
249    }
250
251    // Try as a Julian Day Number (bare float).
252    // Reject non-finite values (NaN/Inf) — sqlite3AtoF doesn't recognize them.
253    if let Ok(jdn) = s.parse::<f64>() {
254        if jdn >= 0.0 && jdn.is_finite() {
255            return Some(jdn);
256        }
257    }
258
259    // ISO-8601 variants.
260    parse_iso8601(s)
261}
262
263fn parse_iso8601(s: &str) -> Option<f64> {
264    // YYYY-MM-DD HH:MM:SS.SSS[Z|±HH:MM]  or  YYYY-MM-DDTHH:MM:SS.SSS[Z|±HH:MM]
265    // YYYY-MM-DD HH:MM:SS[Z|±HH:MM]  or  YYYY-MM-DD HH:MM[Z|±HH:MM]
266    // YYYY-MM-DD
267    // HH:MM:SS.SSS[Z|±HH:MM]  (bare time → 2000-01-01)
268    // HH:MM:SS[Z|±HH:MM]
269    // HH:MM[Z|±HH:MM]
270
271    let bytes = s.as_bytes();
272    let len = bytes.len();
273
274    // Try date-only or date+time.
275    if len >= 10 && bytes[4] == b'-' && bytes[7] == b'-' {
276        let y = s[0..4].parse::<i64>().ok()?;
277        let m = s[5..7].parse::<i64>().ok()?;
278        let d = s[8..10].parse::<i64>().ok()?;
279
280        if m < 1 || m > 12 || d < 1 || d > 31 {
281            return None;
282        }
283
284        if len == 10 {
285            return Some(ymd_to_jdn(y, m, d));
286        }
287
288        // Separator: space or 'T'.
289        if len > 10 && (bytes[10] == b' ' || bytes[10] == b'T') {
290            let time_part = &s[11..];
291            let (h, mi, sec, frac, tz_offset_min) = parse_time_part_with_tz(time_part)?;
292            let jdn = ymdhms_to_jdn(y, m, d, h, mi, sec, frac);
293            // Apply TZ offset: subtract the offset to convert local → UTC.
294            // For "+01:00", local = UTC + 1h, so UTC = local - 1h.
295            return Some(jdn - (tz_offset_min as f64) / 1440.0);
296        }
297        return None;
298    }
299
300    // Bare time: HH:MM:SS or HH:MM:SS.SSS or HH:MM, optionally with TZ suffix.
301    if len >= 5 && bytes[2] == b':' {
302        let (h, mi, sec, frac, tz_offset_min) = parse_time_part_with_tz(s)?;
303        let jdn = ymdhms_to_jdn(2000, 1, 1, h, mi, sec, frac);
304        return Some(jdn - (tz_offset_min as f64) / 1440.0);
305    }
306
307    None
308}
309
310/// Split an optional trailing ISO-8601 timezone suffix (`Z`, `+HH:MM`, `-HH:MM`,
311/// or the compact `±HHMM` / `±HH` forms) from a time string.
312///
313/// Returns the time string without the suffix and the offset in minutes east
314/// of UTC.  `+01:00` returns `60`, `-05:30` returns `-330`, `Z` returns `0`.
315///
316/// If no recognised suffix is present, returns `(s, 0)`.
317fn split_tz_suffix(s: &str) -> Option<(&str, i64)> {
318    // Trailing 'Z' or 'z' → UTC.
319    if let Some(stripped) = s.strip_suffix('Z').or_else(|| s.strip_suffix('z')) {
320        return Some((stripped, 0));
321    }
322
323    // Find the last '+' or '-' that could plausibly start a tz offset.
324    // The sign must appear AFTER the seconds (or minutes) portion — i.e.
325    // after the last ':' or after a '.' fractional seconds block — so we
326    // never confuse a negative fractional value with a tz sign.
327    //
328    // Scan from the right: the first '+' or '-' we hit *before* any
329    // alphanumeric mismatch is the tz sign, provided the remainder is a
330    // well-formed HH[:MM] / HHMM offset.
331    let bytes = s.as_bytes();
332    // Look at the trailing 6 chars ("+HH:MM"), 5 chars ("+HHMM"), or 3 chars ("+HH").
333    for width in [6usize, 5, 3] {
334        if bytes.len() < width + 1 {
335            continue;
336        }
337        let split_at = bytes.len() - width;
338        let sign_byte = bytes[split_at];
339        if sign_byte != b'+' && sign_byte != b'-' {
340            continue;
341        }
342        let tz_part = &s[split_at..];
343        if let Some(offset) = parse_tz_offset(tz_part) {
344            return Some((&s[..split_at], offset));
345        }
346    }
347
348    // No recognised TZ suffix.
349    Some((s, 0))
350}
351
352/// Parse an ISO-8601 timezone offset like `+01:00`, `-05:30`, `+0100`, or `+05`.
353/// Returns the offset in minutes east of UTC, or None if not a valid offset.
354fn parse_tz_offset(tz: &str) -> Option<i64> {
355    let bytes = tz.as_bytes();
356    if bytes.is_empty() {
357        return None;
358    }
359    let sign: i64 = match bytes[0] {
360        b'+' => 1,
361        b'-' => -1,
362        _ => return None,
363    };
364    let rest = &tz[1..];
365    let (hours, minutes) = match rest.len() {
366        // ±HH:MM
367        5 if rest.as_bytes()[2] == b':' => (
368            rest[0..2].parse::<i64>().ok()?,
369            rest[3..5].parse::<i64>().ok()?,
370        ),
371        // ±HHMM
372        4 => (
373            rest[0..2].parse::<i64>().ok()?,
374            rest[2..4].parse::<i64>().ok()?,
375        ),
376        // ±HH
377        2 => (rest.parse::<i64>().ok()?, 0),
378        _ => return None,
379    };
380    if !(0..=23).contains(&hours) || !(0..=59).contains(&minutes) {
381        return None;
382    }
383    Some(sign * (hours * 60 + minutes))
384}
385
386/// Parse "HH:MM:SS.SSS" or "HH:MM:SS" or "HH:MM", optionally followed by a
387/// timezone suffix.  Returns `(h, mi, sec, frac, tz_offset_minutes)`.
388fn parse_time_part_with_tz(s: &str) -> Option<(i64, i64, i64, f64, i64)> {
389    let (time_only, tz_offset_min) = split_tz_suffix(s)?;
390    let (h, mi, sec, frac) = parse_time_part(time_only)?;
391    Some((h, mi, sec, frac, tz_offset_min))
392}
393
394/// Parse "HH:MM:SS.SSS" or "HH:MM:SS" or "HH:MM".
395fn parse_time_part(s: &str) -> Option<(i64, i64, i64, f64)> {
396    let [h_tens, h_ones, b':', mi_tens, mi_ones, rest @ ..] = s.as_bytes() else {
397        return None;
398    };
399    // C SQLite's computeHMS uses getDigits with a "20" field width,
400    // meaning exactly 2 bare decimal digits per time component.  Reject
401    // fields that don't match that shape: wrong length, leading signs
402    // (`+01`), or non-digit characters.
403    let h = parse_two_ascii_digits(*h_tens, *h_ones)?;
404    let mi = parse_two_ascii_digits(*mi_tens, *mi_ones)?;
405    if !(0..=23).contains(&h) || !(0..=59).contains(&mi) {
406        return None;
407    }
408
409    // Third part may have fractional seconds: "SS" or "SS.SSS".
410    // Apply the same 2-digit bare-digits constraint as hours/minutes:
411    // C SQLite's computeHMS requires the seconds integer to be exactly
412    // 2 decimal digits.
413    match rest {
414        [] => Some((h, mi, 0, 0.0)),
415        [b':', sec_tens, sec_ones] => {
416            let sec = parse_two_ascii_digits(*sec_tens, *sec_ones)?;
417            if !(0..=59).contains(&sec) {
418                return None;
419            }
420            Some((h, mi, sec, 0.0))
421        }
422        [b':', sec_tens, sec_ones, b'.', ..] => {
423            let sec = parse_two_ascii_digits(*sec_tens, *sec_ones)?;
424            if !(0..=59).contains(&sec) {
425                return None;
426            }
427            let frac = s.get(8..)?.parse::<f64>().ok()?;
428            Some((h, mi, sec, frac))
429        }
430        _ => None,
431    }
432}
433
434#[inline]
435fn parse_two_ascii_digits(tens: u8, ones: u8) -> Option<i64> {
436    if tens.is_ascii_digit() && ones.is_ascii_digit() {
437        Some(i64::from((tens - b'0') * 10 + (ones - b'0')))
438    } else {
439        None
440    }
441}
442
443// ── Modifier Pipeline ─────────────────────────────────────────────────────
444
445/// Apply a single modifier string to a JDN.  Returns None if invalid.
446fn apply_modifier(jdn: f64, modifier: &str) -> Option<f64> {
447    let m = modifier.trim().to_ascii_lowercase();
448
449    // 'start of month' / 'start of year' / 'start of day'
450    if m == "start of month" {
451        let (y, mo, _d) = jdn_to_ymd(jdn);
452        return Some(ymd_to_jdn(y, mo, 1));
453    }
454    if m == "start of year" {
455        let (y, _mo, _d) = jdn_to_ymd(jdn);
456        return Some(ymd_to_jdn(y, 1, 1));
457    }
458    if m == "start of day" {
459        let (y, mo, d) = jdn_to_ymd(jdn);
460        return Some(ymd_to_jdn(y, mo, d));
461    }
462
463    // 'unixepoch' — reinterpret input as Unix timestamp.
464    if m == "unixepoch" {
465        return Some(unix_to_jdn(jdn));
466    }
467
468    // 'julianday' — input is already a JDN (no-op here, but the spec says
469    // it forces interpretation as JDN).
470    if m == "julianday" {
471        return Some(jdn);
472    }
473
474    // 'auto' — apply SQLite numeric auto-detection:
475    //   0.0..=5373484.499999          => Julian day number
476    //   -210866760000..=253402300799  => Unix timestamp
477    //   otherwise                      => NULL
478    if m == "auto" {
479        if (0.0..=AUTO_JDN_MAX).contains(&jdn) {
480            return Some(jdn);
481        }
482        if (AUTO_UNIX_MIN..=AUTO_UNIX_MAX).contains(&jdn) {
483            return Some(unix_to_jdn(jdn));
484        }
485        return None;
486    }
487
488    // 'localtime' — convert UTC to local time.  The input JDN is UTC, so we
489    // must compute the offset by interpreting the datetime as UTC (not local).
490    if m == "localtime" {
491        let offset = utc_offset_for_utc_jdn(jdn);
492        return Some(jdn + offset as f64 / 86400.0);
493    }
494    // 'utc' — convert local time to UTC.  The input JDN is local time, so
495    // we compute the offset by interpreting the datetime as local.
496    if m == "utc" {
497        let offset = utc_offset_for_local_jdn(jdn);
498        return Some(jdn - offset as f64 / 86400.0);
499    }
500
501    // 'subsec' / 'subsecond' — this is a flag that affects output formatting,
502    // not the JDN.  We pass it through unchanged.
503    if m == "subsec" || m == "subsecond" {
504        return Some(jdn);
505    }
506
507    // 'weekday N' — advance to the next day that is weekday N (0=Sunday).
508    if let Some(rest) = m.strip_prefix("weekday ") {
509        let wd = rest.trim().parse::<i64>().ok()?;
510        if !(0..=6).contains(&wd) {
511            return None;
512        }
513        // Current day of week: 0=Monday in JDN, but SQLite uses 0=Sunday.
514        let current_jdn_int = (jdn + 0.5).floor() as i64;
515        let current_wd = (current_jdn_int + 1) % 7; // 0=Sunday
516        let mut diff = wd - current_wd;
517        if diff < 0 {
518            diff += 7;
519        }
520        // If already the target weekday, this is a no-op (SQLite behavior).
521        return Some(jdn + diff as f64);
522    }
523
524    // Arithmetic: '+NNN days', '-NNN hours', etc.
525    parse_arithmetic_modifier(&m).map(|delta| jdn + delta)
526}
527
528/// Parse "+NNN unit" / "-NNN unit" and return the JDN delta.
529fn parse_arithmetic_modifier(m: &str) -> Option<f64> {
530    let (sign, rest) = if let Some(r) = m.strip_prefix('+') {
531        (1.0, r.trim())
532    } else {
533        let r = m.strip_prefix('-')?;
534        (-1.0, r.trim())
535    };
536
537    let mut parts = rest.splitn(2, ' ');
538    let num_str = parts.next()?;
539    let unit = parts.next()?.trim();
540
541    // Reject non-finite (NaN/Inf) — sqlite3AtoF doesn't recognize them.
542    let num = num_str.parse::<f64>().ok().filter(|f| f.is_finite())?;
543    let delta = num * sign;
544
545    match unit.trim_end_matches('s') {
546        "day" => Some(delta),
547        "hour" => Some(delta / 24.0),
548        "minute" => Some(delta / 1440.0),
549        "second" => Some(delta / 86400.0),
550        "month" => Some(apply_month_delta(delta)),
551        "year" => Some(apply_month_delta(delta * 12.0)),
552        _ => None,
553    }
554}
555
556/// For month/year arithmetic, we can't simply add a JDN delta because months
557/// have variable lengths.  This returns a JDN delta that is *approximately*
558/// correct.  A fully correct implementation requires decomposing and
559/// recomposing, which we handle in `apply_modifier_full` for month/year cases.
560fn apply_month_delta(months: f64) -> f64 {
561    // Average month ≈ 30.436875 days.
562    months * 30.436875
563}
564
565/// SQLite's `computeFloor` (date.c): given a (possibly day-of-month-overflowing)
566/// Y-M-D, return how many days must be subtracted to bring the date back to the
567/// last valid day of month `m`.  Consumed by the `'floor'` datetime modifier.
568fn compute_floor(y: i64, m: i64, d: i64) -> i64 {
569    if d <= 28 {
570        0
571    } else if ((1_i64 << m) & 0x15aa) != 0 {
572        // 31-day month (Jan, Mar, May, Jul, Aug, Oct, Dec): days 29-31 all valid.
573        0
574    } else if m != 2 {
575        // 30-day month (Apr, Jun, Sep, Nov): only day 31 overflows, by 1.
576        i64::from(d == 31)
577    } else if y % 4 != 0 || (y % 100 == 0 && y % 400 != 0) {
578        d - 28 // non-leap February
579    } else {
580        d - 29 // leap February
581    }
582}
583
584/// Apply a sequence of modifiers, also tracking the 'subsec' flag.
585fn apply_modifiers(jdn: f64, modifiers: &[String]) -> Option<(f64, bool)> {
586    let mut j = jdn;
587    let mut subsec = false;
588    // Pending day-of-month overflow from the most recent +N month/year shift,
589    // consumed by a later 'floor' modifier (mirrors SQLite's DateTime.nFloor).
590    let mut n_floor: i64 = 0;
591    for m in modifiers {
592        let m_lower = m.trim().to_ascii_lowercase();
593        if m_lower == "subsec" || m_lower == "subsecond" {
594            subsec = true;
595            continue;
596        }
597        // 'ceiling' is the default day-of-month-overflow behavior (roll forward
598        // into the next month); it merely clears any pending floor adjustment.
599        // 'floor' rolls the date back to the last day of the prior month by
600        // subtracting the overflow days that the preceding month/year shift left.
601        if m_lower == "ceiling" {
602            n_floor = 0;
603            continue;
604        }
605        if m_lower == "floor" {
606            j -= n_floor as f64;
607            n_floor = 0;
608            continue;
609        }
610        // Month/year modifiers need special handling for exact date math.
611        // If exact arithmetic overflows (returns None), the modifier is
612        // out of representable range — return NULL rather than falling
613        // through to the approximate path which would produce overflow
614        // panics in jdn_to_ymd.
615        if is_month_year_modifier(&m_lower) {
616            match apply_month_year_exact(j, &m_lower) {
617                Ok(Some((new_jdn, nf))) => {
618                    j = new_jdn;
619                    n_floor = nf;
620                    continue;
621                }
622                Ok(None) => return None,
623                Err(()) => {
624                    // Fall through to `apply_modifier` for fractional values
625                }
626            }
627        }
628        // Any other date-changing modifier clears the pending floor.
629        n_floor = 0;
630        j = apply_modifier(j, m)?;
631    }
632    Some((j, subsec))
633}
634
635fn is_month_year_modifier(m: &str) -> bool {
636    (m.contains("month") || m.contains("year")) && (m.starts_with('+') || m.starts_with('-'))
637}
638
639/// Exact month/year arithmetic by decomposing to YMD.
640/// Returns Ok(Some((jdn, n_floor))) for exact application, where `n_floor` is
641/// the day-of-month overflow (days to subtract for a later `'floor'` modifier).
642/// Returns Ok(None) for overflow.
643/// Returns Err(()) if the modifier is not an integer, so it should fall back.
644fn apply_month_year_exact(jdn: f64, m: &str) -> std::result::Result<Option<(f64, i64)>, ()> {
645    let (sign, rest) = if let Some(r) = m.strip_prefix('+') {
646        (1_i64, r.trim())
647    } else if let Some(r) = m.strip_prefix('-') {
648        (-1_i64, r.trim())
649    } else {
650        return Err(());
651    };
652
653    let mut parts = rest.splitn(2, ' ');
654    let num_str = parts.next().ok_or(())?;
655    let unit = parts.next().ok_or(())?.trim();
656
657    // SQLite uses exact math only if the value is an integer.
658    let num = if let Ok(n) = num_str.parse::<i64>() {
659        n
660    } else if let Ok(f) = num_str.parse::<f64>() {
661        if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
662            f as i64
663        } else {
664            return Err(());
665        }
666    } else {
667        return Err(());
668    };
669
670    let (y, mo, d) = jdn_to_ymd(jdn);
671    let (h, mi, s, frac) = jdn_to_hms(jdn);
672
673    let total_months = match unit.trim_end_matches('s') {
674        "month" => {
675            if let Some(val) = num.checked_mul(sign) {
676                val
677            } else {
678                return Ok(None);
679            }
680        }
681        "year" => {
682            if let Some(val) = num.checked_mul(sign).and_then(|v| v.checked_mul(12)) {
683                val
684            } else {
685                return Ok(None);
686            }
687        }
688        _ => return Err(()),
689    };
690
691    // (y * 12 + (mo - 1)) + total_months
692    let current_months = if let Some(val) = y.checked_mul(12).and_then(|v| v.checked_add(mo - 1)) {
693        val
694    } else {
695        return Ok(None);
696    };
697    let new_total = if let Some(val) = current_months.checked_add(total_months) {
698        val
699    } else {
700        return Ok(None);
701    };
702
703    let new_y = new_total.div_euclid(12);
704    let new_mo = new_total.rem_euclid(12) + 1;
705    // Do NOT clamp `d` to the target month's day count.  C SQLite lets
706    // out-of-range days overflow via JDN arithmetic (e.g. Feb 31 → Mar 3) for
707    // the default/`'ceiling'` behavior; the `'floor'` modifier later subtracts
708    // the reported `n_floor` overflow days to clamp back to end-of-month.
709    let n_floor = compute_floor(new_y, new_mo, d);
710    Ok(Some((
711        ymdhms_to_jdn(new_y, new_mo, d, h, mi, s, frac),
712        n_floor,
713    )))
714}
715
716// ── Output Formatters ─────────────────────────────────────────────────────
717
718/// Fixed-capacity stack `fmt::Write` target. Any date/time output is at most ~26 bytes
719/// (a multi-digit year plus `-MM-DD HH:MM:SS.SSS`), so 48 bytes never overflows for a
720/// valid date; `write_str` returns `Err` on overflow so `build_small_text` can fall back.
721struct StackStr {
722    buf: [u8; 48],
723    len: usize,
724}
725
726impl StackStr {
727    fn new() -> Self {
728        Self {
729            buf: [0; 48],
730            len: 0,
731        }
732    }
733
734    fn as_str(&self) -> &str {
735        // Only ASCII digits/separators are ever written, so this is always valid UTF-8.
736        core::str::from_utf8(&self.buf[..self.len]).unwrap_or("")
737    }
738}
739
740impl core::fmt::Write for StackStr {
741    fn write_str(&mut self, s: &str) -> core::fmt::Result {
742        let end = self.len + s.len();
743        if end > self.buf.len() {
744            return Err(core::fmt::Error);
745        }
746        self.buf[self.len..end].copy_from_slice(s.as_bytes());
747        self.len = end;
748        Ok(())
749    }
750}
751
752/// Render `write` into a stack buffer and build an inline `SmallText` (no heap) when it
753/// fits the 23-byte inline capacity — the common case for every real date/time. Falls
754/// back to a heap `String` only when the stack buffer overflows (an absurdly large year),
755/// re-running `write` (hence `Fn`, not `FnOnce`). Mirrors the stack-backed Soundex result
756/// (bd-t2sf9.1): the transient `format!` heap allocation that `SmallText` immediately
757/// inlined is eliminated for the hot path.
758fn build_small_text(write: impl Fn(&mut dyn core::fmt::Write) -> core::fmt::Result) -> SmallText {
759    let mut buf = StackStr::new();
760    if write(&mut buf).is_ok() {
761        SmallText::new(buf.as_str())
762    } else {
763        let mut heap = String::new();
764        let _ = write(&mut heap);
765        SmallText::from_string(heap)
766    }
767}
768
769fn format_date(jdn: f64) -> SmallText {
770    let (y, m, d) = jdn_to_ymd(jdn);
771    build_small_text(move |w| write!(w, "{y:04}-{m:02}-{d:02}"))
772}
773
774fn format_time(jdn: f64, subsec: bool) -> SmallText {
775    let (h, m, s, frac) = jdn_to_hms(jdn);
776    if subsec && frac > 1e-9 {
777        let ms = (frac * 1000.0).round() as i64;
778        build_small_text(move |w| write!(w, "{h:02}:{m:02}:{s:02}.{ms:03}"))
779    } else {
780        build_small_text(move |w| write!(w, "{h:02}:{m:02}:{s:02}"))
781    }
782}
783
784fn format_datetime(jdn: f64, subsec: bool) -> SmallText {
785    let (y, mo, d) = jdn_to_ymd(jdn);
786    let (h, mi, s, frac) = jdn_to_hms(jdn);
787    if subsec && frac > 1e-9 {
788        let ms = (frac * 1000.0).round() as i64;
789        build_small_text(move |w| write!(w, "{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}.{ms:03}"))
790    } else {
791        build_small_text(move |w| write!(w, "{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}"))
792    }
793}
794
795#[inline]
796fn push_format(result: &mut String, args: Arguments<'_>) {
797    let _ = result.write_fmt(args);
798}
799
800#[inline]
801fn push_zero_padded_2(result: &mut String, value: i64) {
802    if (0..=99).contains(&value) {
803        let value = value as u8;
804        result.push(char::from(b'0' + value / 10));
805        result.push(char::from(b'0' + value % 10));
806    } else {
807        push_format(result, format_args!("{value:02}"));
808    }
809}
810
811#[inline]
812fn push_space_padded_2(result: &mut String, value: i64) {
813    if (0..=99).contains(&value) {
814        let value = value as u8;
815        if value >= 10 {
816            result.push(char::from(b'0' + value / 10));
817        } else {
818            result.push(' ');
819        }
820        result.push(char::from(b'0' + value % 10));
821    } else {
822        push_format(result, format_args!("{value:>2}"));
823    }
824}
825
826#[inline]
827fn push_zero_padded_3(result: &mut String, value: i64) {
828    if (0..=999).contains(&value) {
829        let value = value as u16;
830        result.push(char::from(b'0' + (value / 100) as u8));
831        result.push(char::from(b'0' + ((value / 10) % 10) as u8));
832        result.push(char::from(b'0' + (value % 10) as u8));
833    } else {
834        push_format(result, format_args!("{value:03}"));
835    }
836}
837
838#[inline]
839fn push_zero_padded_4(result: &mut String, value: i64) {
840    if (0..=9999).contains(&value) {
841        let value = value as u16;
842        result.push(char::from(b'0' + (value / 1000) as u8));
843        result.push(char::from(b'0' + ((value / 100) % 10) as u8));
844        result.push(char::from(b'0' + ((value / 10) % 10) as u8));
845        result.push(char::from(b'0' + (value % 10) as u8));
846    } else {
847        push_format(result, format_args!("{value:04}"));
848    }
849}
850
851/// strftime format engine.
852fn format_strftime(fmt: &str, jdn: f64) -> String {
853    let (y, mo, d) = jdn_to_ymd(jdn);
854    let (h, mi, s, frac) = jdn_to_hms(jdn);
855    let doy = day_of_year(y, mo, d);
856    // Day of week: 0=Sunday.
857    let jdn_int = (jdn + 0.5).floor() as i64;
858    let dow = (jdn_int + 1) % 7; // 0=Sunday, 6=Saturday
859
860    let mut result = String::with_capacity(fmt.len().saturating_add(8));
861    let bytes = fmt.as_bytes();
862    let mut i = 0;
863    let mut literal_start = 0;
864
865    while i < bytes.len() {
866        if bytes[i] != b'%' || i + 1 >= bytes.len() {
867            i += 1;
868            continue;
869        }
870
871        result.push_str(&fmt[literal_start..i]);
872
873        let spec_suffix = &fmt[i + 1..];
874        let Some(spec) = spec_suffix.chars().next() else {
875            break;
876        };
877        i += 1 + spec.len_utf8();
878        literal_start = i;
879
880        match spec {
881            'd' => push_zero_padded_2(&mut result, d),
882            'e' => push_space_padded_2(&mut result, d),
883            'F' => {
884                // ISO 8601 date: %Y-%m-%d (bd-luvv8).
885                push_zero_padded_4(&mut result, y);
886                result.push('-');
887                push_zero_padded_2(&mut result, mo);
888                result.push('-');
889                push_zero_padded_2(&mut result, d);
890            }
891            'f' => {
892                // Seconds with fractional part.
893                let total = s as f64 + frac;
894                push_format(&mut result, format_args!("{total:06.3}"));
895            }
896            'H' => push_zero_padded_2(&mut result, h),
897            'I' => {
898                // 12-hour clock.
899                let h12 = if h == 0 {
900                    12
901                } else if h > 12 {
902                    h - 12
903                } else {
904                    h
905                };
906                push_zero_padded_2(&mut result, h12);
907            }
908            'j' => push_zero_padded_3(&mut result, doy),
909            'J' => {
910                // C SQLite uses %.15g which strips trailing zeros.
911                push_format(&mut result, format_args!("{jdn:.15}"));
912                while result.as_bytes().last() == Some(&b'0') {
913                    result.pop();
914                }
915                if result.as_bytes().last() == Some(&b'.') {
916                    result.pop();
917                }
918            }
919            'k' => {
920                // Space-padded 24-hour.
921                push_space_padded_2(&mut result, h);
922            }
923            'l' => {
924                // Space-padded 12-hour.
925                let h12 = if h == 0 {
926                    12
927                } else if h > 12 {
928                    h - 12
929                } else {
930                    h
931                };
932                push_space_padded_2(&mut result, h12);
933            }
934            'm' => push_zero_padded_2(&mut result, mo),
935            'M' => push_zero_padded_2(&mut result, mi),
936            'p' => {
937                result.push_str(if h < 12 { "AM" } else { "PM" });
938            }
939            'P' => {
940                result.push_str(if h < 12 { "am" } else { "pm" });
941            }
942            'R' => {
943                push_zero_padded_2(&mut result, h);
944                result.push(':');
945                push_zero_padded_2(&mut result, mi);
946            }
947            's' => {
948                let unix = jdn_to_unix(jdn);
949                push_format(&mut result, format_args!("{unix}"));
950            }
951            'S' => push_zero_padded_2(&mut result, s),
952            'T' => {
953                push_zero_padded_2(&mut result, h);
954                result.push(':');
955                push_zero_padded_2(&mut result, mi);
956                result.push(':');
957                push_zero_padded_2(&mut result, s);
958            }
959            'u' => {
960                // ISO 8601 day of week: 1=Monday, 7=Sunday.
961                let u = if dow == 0 { 7 } else { dow };
962                push_format(&mut result, format_args!("{u}"));
963            }
964            'w' => push_format(&mut result, format_args!("{dow}")),
965            'W' => {
966                // Week of year (Monday as first day of week, 00-53).
967                let w = (doy + 6 - ((dow + 6) % 7)) / 7;
968                push_zero_padded_2(&mut result, w);
969            }
970            'Y' => push_zero_padded_4(&mut result, y),
971            'G' | 'g' | 'V' => {
972                // ISO 8601 week-based year/week.
973                let (iso_y, iso_w) = iso_week(y, mo, d);
974                match spec {
975                    'G' => push_zero_padded_4(&mut result, iso_y),
976                    'g' => push_zero_padded_2(&mut result, iso_y % 100),
977                    'V' => push_zero_padded_2(&mut result, iso_w),
978                    _ => unreachable!(),
979                }
980            }
981            '%' => result.push('%'),
982            other => {
983                result.push('%');
984                result.push(other);
985            }
986        }
987    }
988
989    if literal_start < fmt.len() {
990        result.push_str(&fmt[literal_start..]);
991    }
992
993    result
994}
995
996/// ISO 8601 week number and year.
997fn iso_week(y: i64, m: i64, d: i64) -> (i64, i64) {
998    let jdn = ymd_to_jdn(y, m, d);
999    let jdn_int = (jdn + 0.5).floor() as i64;
1000    // ISO day of week: 1=Monday, 7=Sunday.
1001    let dow = (jdn_int + 1) % 7;
1002    let iso_dow = if dow == 0 { 7 } else { dow };
1003
1004    // Thursday of the same week determines the year.
1005    let thu_jdn = jdn_int + (4 - iso_dow);
1006    let (thu_y, _, _) = jdn_to_ymd(thu_jdn as f64);
1007
1008    // Jan 4 is always in week 1 (ISO 8601).
1009    let jan4_jdn = (ymd_to_jdn(thu_y, 1, 4) + 0.5).floor() as i64;
1010    let jan4_dow = (jan4_jdn + 1) % 7;
1011    let jan4_iso_dow = if jan4_dow == 0 { 7 } else { jan4_dow };
1012    let week1_start = jan4_jdn - (jan4_iso_dow - 1);
1013
1014    let week = (thu_jdn - week1_start) / 7 + 1;
1015    (thu_y, week)
1016}
1017
1018// ── timediff ──────────────────────────────────────────────────────────────
1019
1020fn timediff_impl(jdn1: f64, jdn2: f64) -> String {
1021    let (sign, start_jdn, end_jdn) = if jdn1 >= jdn2 {
1022        ('+', jdn2, jdn1)
1023    } else {
1024        ('-', jdn1, jdn2)
1025    };
1026
1027    let (start_y, start_mo, start_d) = jdn_to_ymd(start_jdn);
1028    let (start_h, start_mi, mut start_s, start_frac) = jdn_to_hms(start_jdn);
1029    let mut start_ms = (start_frac * 1000.0).round() as i64;
1030    if start_ms >= 1000 {
1031        start_ms = 0;
1032        start_s += 1;
1033    }
1034
1035    let (end_y, end_mo, end_d) = jdn_to_ymd(end_jdn);
1036    let (end_h, end_mi, mut end_s, end_frac) = jdn_to_hms(end_jdn);
1037    let mut end_ms = (end_frac * 1000.0).round() as i64;
1038    if end_ms >= 1000 {
1039        end_ms = 0;
1040        end_s += 1;
1041    }
1042
1043    let mut years = end_y - start_y;
1044    let mut months = end_mo - start_mo;
1045    let mut days = end_d - start_d;
1046    let mut hours = end_h - start_h;
1047    let mut minutes = end_mi - start_mi;
1048    let mut seconds = end_s - start_s;
1049    let mut millis = end_ms - start_ms;
1050
1051    if millis < 0 {
1052        millis += 1000;
1053        seconds -= 1;
1054    }
1055    if seconds < 0 {
1056        seconds += 60;
1057        minutes -= 1;
1058    }
1059    if minutes < 0 {
1060        minutes += 60;
1061        hours -= 1;
1062    }
1063    if hours < 0 {
1064        hours += 24;
1065        days -= 1;
1066    }
1067    if days < 0 {
1068        months -= 1;
1069        let (borrow_y, borrow_mo) = if end_mo == 1 {
1070            (end_y - 1, 12)
1071        } else {
1072            (end_y, end_mo - 1)
1073        };
1074        days += days_in_month(borrow_y, borrow_mo);
1075    }
1076    if months < 0 {
1077        months += 12;
1078        years -= 1;
1079    }
1080
1081    format!(
1082        "{sign}{years:04}-{months:02}-{days:02} {hours:02}:{minutes:02}:{seconds:02}.{millis:03}"
1083    )
1084}
1085
1086// ── Scalar Function Implementations ───────────────────────────────────────
1087
1088/// Parse args: first arg is time string, rest are modifiers.
1089fn parse_args(args: &[SqliteValue]) -> Option<(f64, bool)> {
1090    if args.is_empty() || args[0].is_null() {
1091        return None;
1092    }
1093
1094    let numeric_input = matches!(&args[0], SqliteValue::Integer(_) | SqliteValue::Float(_));
1095    let input = match &args[0] {
1096        SqliteValue::Text(s) => parse_timestring(s)?,
1097        SqliteValue::Integer(i) => *i as f64,
1098        SqliteValue::Float(f) => *f,
1099        _ => return None,
1100    };
1101
1102    // C SQLite: a NULL modifier causes the entire function to return NULL
1103    // (date.c:1127). Previously, NULL modifiers were silently skipped.
1104    if args[1..].iter().any(SqliteValue::is_null) {
1105        return None;
1106    }
1107    let modifiers: Vec<String> = args[1..].iter().map(SqliteValue::to_text).collect();
1108
1109    // C SQLite rejects a numeric argument outside the representable
1110    // Julian-day range (date.c validJulianDay), returning NULL rather than
1111    // formatting a saturated garbage date — unless the first modifier
1112    // reinterprets the raw number ('unixepoch', 'julianday', 'auto').
1113    if numeric_input {
1114        let first = modifiers
1115            .first()
1116            .map(|modifier| modifier.trim().to_ascii_lowercase());
1117        let reinterprets_raw = matches!(first.as_deref(), Some("unixepoch" | "julianday" | "auto"));
1118        if !reinterprets_raw && !(0.0..=AUTO_JDN_MAX).contains(&input) {
1119            return None;
1120        }
1121    }
1122
1123    apply_modifiers(input, &modifiers)
1124}
1125
1126// ── date() ────────────────────────────────────────────────────────────────
1127
1128pub struct DateFunc;
1129
1130impl ScalarFunction for DateFunc {
1131    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1132        match parse_args(args) {
1133            Some((jdn, _)) => Ok(SqliteValue::Text(format_date(jdn))),
1134            None => Ok(SqliteValue::Null),
1135        }
1136    }
1137
1138    fn num_args(&self) -> i32 {
1139        -1
1140    }
1141
1142    fn name(&self) -> &str {
1143        "date"
1144    }
1145}
1146
1147// ── time() ────────────────────────────────────────────────────────────────
1148
1149pub struct TimeFunc;
1150
1151impl ScalarFunction for TimeFunc {
1152    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1153        match parse_args(args) {
1154            Some((jdn, subsec)) => Ok(SqliteValue::Text(format_time(jdn, subsec))),
1155            None => Ok(SqliteValue::Null),
1156        }
1157    }
1158
1159    fn num_args(&self) -> i32 {
1160        -1
1161    }
1162
1163    fn name(&self) -> &str {
1164        "time"
1165    }
1166}
1167
1168// ── datetime() ────────────────────────────────────────────────────────────
1169
1170pub struct DateTimeFunc;
1171
1172impl ScalarFunction for DateTimeFunc {
1173    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1174        match parse_args(args) {
1175            Some((jdn, subsec)) => Ok(SqliteValue::Text(format_datetime(jdn, subsec))),
1176            None => Ok(SqliteValue::Null),
1177        }
1178    }
1179
1180    fn num_args(&self) -> i32 {
1181        -1
1182    }
1183
1184    fn name(&self) -> &str {
1185        "datetime"
1186    }
1187}
1188
1189// ── julianday() ───────────────────────────────────────────────────────────
1190
1191pub struct JuliandayFunc;
1192
1193impl ScalarFunction for JuliandayFunc {
1194    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1195        match parse_args(args) {
1196            Some((jdn, _)) => Ok(SqliteValue::Float(jdn)),
1197            None => Ok(SqliteValue::Null),
1198        }
1199    }
1200
1201    fn num_args(&self) -> i32 {
1202        -1
1203    }
1204
1205    fn name(&self) -> &str {
1206        "julianday"
1207    }
1208}
1209
1210// ── unixepoch() ───────────────────────────────────────────────────────────
1211
1212pub struct UnixepochFunc;
1213
1214impl ScalarFunction for UnixepochFunc {
1215    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1216        match parse_args(args) {
1217            // bd-855l7: the 'subsec'/'subsecond' modifier makes unixepoch return
1218            // a floating-point value carrying the fractional seconds.
1219            Some((jdn, true)) => {
1220                let secs = (jdn - UNIX_EPOCH_JDN) * 86400.0;
1221                let rounded = (secs * 1000.0).round() / 1000.0;
1222                Ok(SqliteValue::Float(rounded))
1223            }
1224            Some((jdn, false)) => Ok(SqliteValue::Integer(jdn_to_unix(jdn))),
1225            None => Ok(SqliteValue::Null),
1226        }
1227    }
1228
1229    fn num_args(&self) -> i32 {
1230        -1
1231    }
1232
1233    fn name(&self) -> &str {
1234        "unixepoch"
1235    }
1236}
1237
1238// ── strftime() ────────────────────────────────────────────────────────────
1239
1240pub struct StrftimeFunc;
1241
1242impl ScalarFunction for StrftimeFunc {
1243    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1244        if args.len() < 2 || args[0].is_null() || args[1].is_null() {
1245            return Ok(SqliteValue::Null);
1246        }
1247        let rest = &args[1..];
1248        match parse_args(rest) {
1249            Some((jdn, _)) => {
1250                let fmt = match args[0].as_text_str() {
1251                    Some(text) => Cow::Borrowed(text),
1252                    None => Cow::Owned(args[0].to_text()),
1253                };
1254                Ok(SqliteValue::Text(format_strftime(fmt.as_ref(), jdn).into()))
1255            }
1256            None => Ok(SqliteValue::Null),
1257        }
1258    }
1259
1260    fn num_args(&self) -> i32 {
1261        -1
1262    }
1263
1264    fn name(&self) -> &str {
1265        "strftime"
1266    }
1267}
1268
1269// ── timediff() ────────────────────────────────────────────────────────────
1270
1271pub struct TimediffFunc;
1272
1273impl ScalarFunction for TimediffFunc {
1274    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
1275        if args.len() < 2 || args[0].is_null() || args[1].is_null() {
1276            return Ok(SqliteValue::Null);
1277        }
1278
1279        let jdn1 = match &args[0] {
1280            SqliteValue::Text(s) => parse_timestring(s),
1281            SqliteValue::Integer(i) => Some(*i as f64),
1282            SqliteValue::Float(f) => Some(*f),
1283            _ => None,
1284        };
1285        let jdn2 = match &args[1] {
1286            SqliteValue::Text(s) => parse_timestring(s),
1287            SqliteValue::Integer(i) => Some(*i as f64),
1288            SqliteValue::Float(f) => Some(*f),
1289            _ => None,
1290        };
1291
1292        match (jdn1, jdn2) {
1293            (Some(j1), Some(j2)) => Ok(SqliteValue::Text(timediff_impl(j1, j2).into())),
1294            _ => Ok(SqliteValue::Null),
1295        }
1296    }
1297
1298    fn num_args(&self) -> i32 {
1299        2
1300    }
1301
1302    fn name(&self) -> &str {
1303        "timediff"
1304    }
1305}
1306
1307// ── Registration ──────────────────────────────────────────────────────────
1308
1309/// Register all §13.3 date/time functions.
1310pub fn register_datetime_builtins(registry: &mut FunctionRegistry) {
1311    registry.register_scalar(DateFunc);
1312    registry.register_scalar(TimeFunc);
1313    registry.register_scalar(DateTimeFunc);
1314    registry.register_scalar(JuliandayFunc);
1315    registry.register_scalar(UnixepochFunc);
1316    registry.register_scalar(StrftimeFunc);
1317    registry.register_scalar(TimediffFunc);
1318}
1319
1320// ── Tests ─────────────────────────────────────────────────────────────────
1321
1322#[cfg(test)]
1323mod tests {
1324    use super::*;
1325
1326    fn text(s: &str) -> SqliteValue {
1327        SqliteValue::Text(s.into())
1328    }
1329
1330    fn int(v: i64) -> SqliteValue {
1331        SqliteValue::Integer(v)
1332    }
1333
1334    fn float(v: f64) -> SqliteValue {
1335        SqliteValue::Float(v)
1336    }
1337
1338    fn null() -> SqliteValue {
1339        SqliteValue::Null
1340    }
1341
1342    fn assert_text(result: &SqliteValue, expected: &str) {
1343        match result {
1344            SqliteValue::Text(s) => assert_eq!(s.as_ref(), expected, "text mismatch"),
1345            other => panic!("expected Text(\"{expected}\"), got {other:?}"),
1346        }
1347    }
1348
1349    // ── Basic functions ───────────────────────────────────────────────
1350
1351    #[test]
1352    fn test_date_basic() {
1353        let r = DateFunc.invoke(&[text("2024-03-15 14:30:00")]).unwrap();
1354        assert_text(&r, "2024-03-15");
1355    }
1356
1357    #[test]
1358    fn test_time_basic() {
1359        let r = TimeFunc.invoke(&[text("2024-03-15 14:30:45")]).unwrap();
1360        assert_text(&r, "14:30:45");
1361    }
1362
1363    #[test]
1364    fn test_datetime_basic() {
1365        let r = DateTimeFunc.invoke(&[text("2024-03-15 14:30:00")]).unwrap();
1366        assert_text(&r, "2024-03-15 14:30:00");
1367    }
1368
1369    #[test]
1370    fn test_julianday_basic() {
1371        let r = JuliandayFunc.invoke(&[text("2024-03-15")]).unwrap();
1372        match r {
1373            SqliteValue::Float(jdn) => {
1374                // JDN for 2024-03-15 should be approximately 2460384.5
1375                assert!((jdn - 2_460_384.5).abs() < 0.01, "unexpected JDN: {jdn}");
1376            }
1377            other => panic!("expected Float, got {other:?}"),
1378        }
1379    }
1380
1381    // ── RFC3339 / ISO-8601 timezone suffix parsing ────────────────────
1382    //
1383    // Regression coverage for issue #64: julianday() must accept
1384    // Z / ±HH:MM / ±HHMM / ±HH timezone-bearing timestamps and convert
1385    // them to UTC before computing the Julian day.  The expected JDN
1386    // values below match the C SQLite reference implementation.
1387
1388    fn julianday_float(input: &str) -> f64 {
1389        match JuliandayFunc.invoke(&[text(input)]).unwrap() {
1390            SqliteValue::Float(v) => v,
1391            other => panic!("expected Float, got {other:?} for input {input:?}"),
1392        }
1393    }
1394
1395    fn assert_jdn_close(actual: f64, expected: f64, ctx: &str) {
1396        // 1 µs precision (86400e6 µs / day) is well within float epsilon.
1397        assert!(
1398            (actual - expected).abs() < 1e-6,
1399            "JDN mismatch for {ctx}: got {actual}, expected {expected}"
1400        );
1401    }
1402
1403    #[test]
1404    fn test_julianday_rfc3339_z_suffix() {
1405        // Zulu (UTC) — should match the equivalent naive form exactly.
1406        let naive = julianday_float("2026-04-07 16:00:00");
1407        assert_jdn_close(julianday_float("2026-04-07T16:00:00Z"), naive, "T...Z");
1408        assert_jdn_close(
1409            julianday_float("2026-04-07T16:00:00z"),
1410            naive,
1411            "lowercase z",
1412        );
1413    }
1414
1415    #[test]
1416    fn test_julianday_rfc3339_zero_offset() {
1417        let naive = julianday_float("2026-04-07 16:00:00");
1418        assert_jdn_close(
1419            julianday_float("2026-04-07T16:00:00+00:00"),
1420            naive,
1421            "+00:00",
1422        );
1423        assert_jdn_close(
1424            julianday_float("2026-04-07T16:00:00-00:00"),
1425            naive,
1426            "-00:00",
1427        );
1428    }
1429
1430    #[test]
1431    fn test_julianday_rfc3339_positive_offset() {
1432        // 16:00 +01:00 = 15:00 UTC → JDN is 1 hour (1/24) earlier.
1433        let base = julianday_float("2026-04-07 16:00:00");
1434        let expected = base - 1.0 / 24.0;
1435        assert_jdn_close(
1436            julianday_float("2026-04-07T16:00:00+01:00"),
1437            expected,
1438            "+01:00",
1439        );
1440    }
1441
1442    #[test]
1443    fn test_julianday_rfc3339_negative_offset() {
1444        // 16:00 -05:00 = 21:00 UTC → JDN is 5 hours later.
1445        let base = julianday_float("2026-04-07 16:00:00");
1446        let expected = base + 5.0 / 24.0;
1447        assert_jdn_close(
1448            julianday_float("2026-04-07T16:00:00-05:00"),
1449            expected,
1450            "-05:00",
1451        );
1452    }
1453
1454    #[test]
1455    fn test_julianday_rfc3339_half_hour_offset() {
1456        // India Standard Time is UTC+05:30.
1457        let base = julianday_float("2026-04-07 16:00:00");
1458        let expected = base - 5.5 / 24.0;
1459        assert_jdn_close(
1460            julianday_float("2026-04-07T16:00:00+05:30"),
1461            expected,
1462            "+05:30",
1463        );
1464    }
1465
1466    #[test]
1467    fn test_julianday_rfc3339_compact_offsets() {
1468        // Compact ISO-8601 offsets: ±HHMM and ±HH.
1469        let base = julianday_float("2026-04-07 16:00:00");
1470        assert_jdn_close(
1471            julianday_float("2026-04-07T16:00:00+0100"),
1472            base - 1.0 / 24.0,
1473            "+0100",
1474        );
1475        assert_jdn_close(
1476            julianday_float("2026-04-07T16:00:00-0530"),
1477            base + 5.5 / 24.0,
1478            "-0530",
1479        );
1480        assert_jdn_close(
1481            julianday_float("2026-04-07T16:00:00+09"),
1482            base - 9.0 / 24.0,
1483            "+09",
1484        );
1485    }
1486
1487    #[test]
1488    fn test_julianday_rfc3339_fractional_seconds_with_tz() {
1489        // Fractional seconds must play nicely with the TZ suffix split.
1490        let base = julianday_float("2026-04-07 16:00:00.500");
1491        assert_jdn_close(
1492            julianday_float("2026-04-07T16:00:00.500Z"),
1493            base,
1494            "fractional + Z",
1495        );
1496        assert_jdn_close(
1497            julianday_float("2026-04-07T16:00:00.500+01:00"),
1498            base - 1.0 / 24.0,
1499            "fractional + +01:00",
1500        );
1501    }
1502
1503    #[test]
1504    fn test_date_and_time_rfc3339_round_trip() {
1505        // date()/time()/datetime() all flow through parse_timestring, so
1506        // they should agree with the timezone conversion above.
1507        assert_text(
1508            &DateFunc
1509                .invoke(&[text("2026-04-07T16:00:00+05:00")])
1510                .unwrap(),
1511            // 16:00 +05:00 = 11:00 UTC on the same date.
1512            "2026-04-07",
1513        );
1514        assert_text(
1515            &TimeFunc
1516                .invoke(&[text("2026-04-07T16:00:00+05:00")])
1517                .unwrap(),
1518            "11:00:00",
1519        );
1520        assert_text(
1521            &DateTimeFunc
1522                .invoke(&[text("2026-04-07T16:00:00+05:00")])
1523                .unwrap(),
1524            "2026-04-07 11:00:00",
1525        );
1526    }
1527
1528    #[test]
1529    fn test_julianday_rfc3339_invalid_offsets_return_null() {
1530        // Malformed offsets fall through and return NULL (invalid input).
1531        for bad in &[
1532            "2026-04-07T16:00:00+25:00", // hour out of range
1533            "2026-04-07T16:00:00+01:99", // minute out of range
1534            "2026-04-07T16:00:00+1",     // too short
1535            "2026-04-07T16:00:00+123",   // wrong width
1536        ] {
1537            let result = JuliandayFunc.invoke(&[text(bad)]).unwrap();
1538            assert_eq!(
1539                result,
1540                SqliteValue::Null,
1541                "expected NULL for malformed offset {bad:?}, got {result:?}"
1542            );
1543        }
1544    }
1545
1546    #[test]
1547    fn test_julianday_rejects_malformed_time_fields() {
1548        // C SQLite's computeHMS requires exactly 2 bare decimal digits
1549        // for each HH, MM, SS field.  Inputs with wrong digit counts,
1550        // leading signs, or non-digit characters must return NULL.
1551        for bad in &[
1552            "+01:00",        // leading + on hour
1553            "-05:30",        // leading - on hour
1554            "+12:30:00",     // leading + on hour (with seconds)
1555            "12:+30:00",     // leading + on minute
1556            "12:30:+45",     // leading + on seconds (integer)
1557            "12:30:+45.123", // leading + on seconds (fractional)
1558            "0:00:00",       // 1-digit hour
1559            "12:0:00",       // 1-digit minute
1560            "12:30:0",       // 1-digit second
1561            "123:00:00",     // 3-digit hour
1562            "12:345:00",     // 3-digit minute
1563        ] {
1564            let result = JuliandayFunc.invoke(&[text(bad)]).unwrap();
1565            assert_eq!(
1566                result,
1567                SqliteValue::Null,
1568                "expected NULL for signed time field {bad:?}, got {result:?}"
1569            );
1570        }
1571    }
1572
1573    #[test]
1574    fn test_unixepoch_basic() {
1575        let r = UnixepochFunc
1576            .invoke(&[text("1970-01-01 00:00:00")])
1577            .unwrap();
1578        assert_eq!(r, int(0));
1579    }
1580
1581    #[test]
1582    fn test_unixepoch_known_date() {
1583        let r = UnixepochFunc
1584            .invoke(&[text("2024-01-01 00:00:00")])
1585            .unwrap();
1586        // 2024-01-01 00:00:00 UTC = 1704067200
1587        assert_eq!(r, int(1_704_067_200));
1588    }
1589
1590    // ── Modifiers ─────────────────────────────────────────────────────
1591
1592    #[test]
1593    fn test_modifier_days() {
1594        let r = DateFunc
1595            .invoke(&[text("2024-01-15"), text("+10 days")])
1596            .unwrap();
1597        assert_text(&r, "2024-01-25");
1598    }
1599
1600    #[test]
1601    fn test_modifier_months() {
1602        // 2024-01-31 + 1 month: C SQLite lets day=31 overflow via JDN
1603        // arithmetic → Feb 31 wraps to Mar 2 (2024 is a leap year).
1604        let r = DateFunc
1605            .invoke(&[text("2024-01-31"), text("+1 months")])
1606            .unwrap();
1607        assert_text(&r, "2024-03-02");
1608    }
1609
1610    #[test]
1611    fn test_modifier_years() {
1612        // 2024-02-29 + 1 year: 2025 is not a leap year, day=29 overflows
1613        // via JDN arithmetic → Mar 1.
1614        let r = DateFunc
1615            .invoke(&[text("2024-02-29"), text("+1 years")])
1616            .unwrap();
1617        assert_text(&r, "2025-03-01");
1618    }
1619
1620    #[test]
1621    fn test_modifier_hours() {
1622        let r = DateTimeFunc
1623            .invoke(&[text("2024-01-01 23:00:00"), text("+2 hours")])
1624            .unwrap();
1625        assert_text(&r, "2024-01-02 01:00:00");
1626    }
1627
1628    #[test]
1629    fn test_modifier_start_of_month() {
1630        let r = DateFunc
1631            .invoke(&[text("2024-03-15"), text("start of month")])
1632            .unwrap();
1633        assert_text(&r, "2024-03-01");
1634    }
1635
1636    #[test]
1637    fn test_modifier_start_of_year() {
1638        let r = DateFunc
1639            .invoke(&[text("2024-06-15"), text("start of year")])
1640            .unwrap();
1641        assert_text(&r, "2024-01-01");
1642    }
1643
1644    #[test]
1645    fn test_modifier_start_of_day() {
1646        let r = DateTimeFunc
1647            .invoke(&[text("2024-03-15 14:30:00"), text("start of day")])
1648            .unwrap();
1649        assert_text(&r, "2024-03-15 00:00:00");
1650    }
1651
1652    #[test]
1653    fn test_modifier_unixepoch() {
1654        let r = DateTimeFunc.invoke(&[int(0), text("unixepoch")]).unwrap();
1655        assert_text(&r, "1970-01-01 00:00:00");
1656    }
1657
1658    #[test]
1659    fn test_modifier_weekday() {
1660        // 2024-03-15 is Friday. `weekday 0` advances to the next Sunday.
1661        let r = DateFunc
1662            .invoke(&[text("2024-03-15"), text("weekday 0")])
1663            .unwrap();
1664        assert_text(&r, "2024-03-17");
1665    }
1666
1667    #[test]
1668    fn test_modifier_auto_unixepoch() {
1669        let ts = int(1_710_531_045);
1670        let r = DateTimeFunc.invoke(&[ts.clone(), text("auto")]).unwrap();
1671        let expected = DateTimeFunc.invoke(&[ts, text("unixepoch")]).unwrap();
1672        assert_eq!(
1673            r, expected,
1674            "auto and unixepoch should agree for unix-like values"
1675        );
1676    }
1677
1678    #[test]
1679    fn test_modifier_auto_julian_day() {
1680        let r = DateFunc
1681            .invoke(&[float(2_460_384.5), text("auto")])
1682            .unwrap();
1683        assert_text(&r, "2024-03-15");
1684    }
1685
1686    #[test]
1687    fn test_modifier_localtime_utc_roundtrip() {
1688        // localtime→utc should roundtrip back to the original value.
1689        let r = DateTimeFunc
1690            .invoke(&[text("2024-03-15 14:30:45"), text("localtime"), text("utc")])
1691            .unwrap();
1692        assert_text(&r, "2024-03-15 14:30:45");
1693    }
1694
1695    #[test]
1696    fn test_modifier_localtime_shifts_value() {
1697        // When system offset != 0, 'localtime' should actually shift the value.
1698        let offset = utc_offset_for_utc_jdn(ymdhms_to_jdn(2024, 3, 15, 12, 0, 0, 0.0));
1699        if offset != 0 {
1700            let r = DateTimeFunc
1701                .invoke(&[text("2024-03-15 12:00:00"), text("localtime")])
1702                .unwrap();
1703            // The shifted value should differ from the input.
1704            let shifted = match &r {
1705                SqliteValue::Text(s) => s.clone(),
1706                _ => panic!("expected text"),
1707            };
1708            assert_ne!(&*shifted, "2024-03-15 12:00:00");
1709        }
1710    }
1711
1712    #[test]
1713    fn test_modifier_auto_out_of_range_returns_null() {
1714        let r = DateTimeFunc.invoke(&[float(1.0e20), text("auto")]).unwrap();
1715        assert_eq!(r, SqliteValue::Null);
1716    }
1717
1718    #[test]
1719    fn test_modifier_order_matters() {
1720        // 'start of month' then '+1 day' = March 2nd.
1721        let r1 = DateFunc
1722            .invoke(&[text("2024-03-15"), text("start of month"), text("+1 days")])
1723            .unwrap();
1724        assert_text(&r1, "2024-03-02");
1725
1726        // '+1 day' then 'start of month' = March 1st.
1727        let r2 = DateFunc
1728            .invoke(&[text("2024-03-15"), text("+1 days"), text("start of month")])
1729            .unwrap();
1730        assert_text(&r2, "2024-03-01");
1731    }
1732
1733    #[test]
1734    fn test_modifier_weekday_same_day_is_noop() {
1735        // 2024-03-17 is Sunday; SQLite semantics: already on target weekday, no-op.
1736        let r = DateFunc
1737            .invoke(&[text("2024-03-17"), text("weekday 0")])
1738            .unwrap();
1739        assert_text(&r, "2024-03-17");
1740    }
1741
1742    // ── Input formats ─────────────────────────────────────────────────
1743
1744    #[test]
1745    fn test_bare_time_defaults() {
1746        let r = DateFunc.invoke(&[text("12:30:00")]).unwrap();
1747        assert_text(&r, "2000-01-01");
1748    }
1749
1750    #[test]
1751    fn test_t_separator() {
1752        let r = DateTimeFunc.invoke(&[text("2024-03-15T14:30:00")]).unwrap();
1753        assert_text(&r, "2024-03-15 14:30:00");
1754    }
1755
1756    #[test]
1757    fn test_julian_day_input() {
1758        // 2460384.5 is 2024-03-15.
1759        let r = DateFunc.invoke(&[float(2_460_384.5)]).unwrap();
1760        assert_text(&r, "2024-03-15");
1761    }
1762
1763    #[test]
1764    fn test_null_input() {
1765        assert_eq!(DateFunc.invoke(&[null()]).unwrap(), SqliteValue::Null);
1766    }
1767
1768    #[test]
1769    fn test_invalid_input() {
1770        assert_eq!(
1771            DateFunc.invoke(&[text("not-a-date")]).unwrap(),
1772            SqliteValue::Null
1773        );
1774    }
1775
1776    #[test]
1777    fn test_negative_time_component_invalid() {
1778        let r = TimeFunc.invoke(&[text("-01:00")]).unwrap();
1779        assert_eq!(r, SqliteValue::Null);
1780    }
1781
1782    // ── Leap year ─────────────────────────────────────────────────────
1783
1784    #[test]
1785    fn test_leap_year() {
1786        let r = DateFunc
1787            .invoke(&[text("2024-02-28"), text("+1 days")])
1788            .unwrap();
1789        assert_text(&r, "2024-02-29");
1790    }
1791
1792    #[test]
1793    fn test_non_leap_year() {
1794        let r = DateFunc
1795            .invoke(&[text("2023-02-28"), text("+1 days")])
1796            .unwrap();
1797        assert_text(&r, "2023-03-01");
1798    }
1799
1800    // ── strftime ──────────────────────────────────────────────────────
1801
1802    #[test]
1803    fn test_strftime_basic() {
1804        let r = StrftimeFunc
1805            .invoke(&[text("%Y-%m-%d"), text("2024-03-15")])
1806            .unwrap();
1807        assert_text(&r, "2024-03-15");
1808    }
1809
1810    #[test]
1811    fn test_strftime_time_specifiers() {
1812        let r = StrftimeFunc
1813            .invoke(&[text("%H:%M:%S"), text("2024-03-15 14:30:45")])
1814            .unwrap();
1815        assert_text(&r, "14:30:45");
1816    }
1817
1818    #[test]
1819    fn test_strftime_unix_seconds() {
1820        let r = StrftimeFunc
1821            .invoke(&[text("%s"), text("1970-01-01 00:00:00")])
1822            .unwrap();
1823        assert_text(&r, "0");
1824    }
1825
1826    #[test]
1827    fn test_strftime_day_of_year() {
1828        let r = StrftimeFunc
1829            .invoke(&[text("%j"), text("2024-03-15")])
1830            .unwrap();
1831        // 2024-03-15: Jan(31) + Feb(29) + 15 = 75
1832        assert_text(&r, "075");
1833    }
1834
1835    #[test]
1836    fn test_strftime_day_of_week() {
1837        // 2024-03-15 is a Friday → w=5 (0=Sunday), u=5 (1=Monday)
1838        let r = StrftimeFunc
1839            .invoke(&[text("%w"), text("2024-03-15")])
1840            .unwrap();
1841        assert_text(&r, "5");
1842
1843        let r = StrftimeFunc
1844            .invoke(&[text("%u"), text("2024-03-15")])
1845            .unwrap();
1846        assert_text(&r, "5");
1847    }
1848
1849    #[test]
1850    fn test_strftime_12hour() {
1851        let r = StrftimeFunc
1852            .invoke(&[text("%I %p"), text("2024-03-15 14:30:00")])
1853            .unwrap();
1854        assert_text(&r, "02 PM");
1855
1856        let r = StrftimeFunc
1857            .invoke(&[text("%I %P"), text("2024-03-15 09:30:00")])
1858            .unwrap();
1859        assert_text(&r, "09 am");
1860    }
1861
1862    #[test]
1863    fn test_strftime_all_specifiers_presence() {
1864        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|%%";
1865        let r = StrftimeFunc
1866            .invoke(&[text(fmt), text("2024-03-15 14:30:45.123")])
1867            .unwrap();
1868
1869        let s = match r {
1870            SqliteValue::Text(v) => v,
1871            other => panic!("expected Text, got {other:?}"),
1872        };
1873        let parts: Vec<&str> = s.split('|').collect();
1874        assert_eq!(parts.len(), 25, "unexpected specifier output: {s}");
1875        assert_eq!(parts[0], "15"); // %d
1876        assert_eq!(parts[1], "15"); // %e
1877        assert_eq!(parts[2], "45.123"); // %f
1878        assert_eq!(parts[3], "14"); // %H
1879        assert_eq!(parts[4], "02"); // %I
1880        assert_eq!(parts[5], "075"); // %j
1881        assert!(
1882            parts[6].parse::<f64>().is_ok(),
1883            "expected numeric %J output, got {}",
1884            parts[6]
1885        );
1886        assert_eq!(parts[7], "14"); // %k
1887        assert_eq!(parts[8], " 2"); // %l
1888        assert_eq!(parts[9], "03"); // %m
1889        assert_eq!(parts[10], "30"); // %M
1890        assert_eq!(parts[11], "PM"); // %p
1891        assert_eq!(parts[12], "pm"); // %P
1892        assert_eq!(parts[13], "14:30"); // %R
1893        assert!(
1894            parts[14].parse::<i64>().is_ok(),
1895            "expected numeric %s output, got {}",
1896            parts[14]
1897        );
1898        assert_eq!(parts[15], "45"); // %S
1899        assert_eq!(parts[16], "14:30:45"); // %T
1900        assert_eq!(parts[17], "5"); // %u
1901        assert_eq!(parts[18], "5"); // %w
1902        assert_eq!(parts[19], "11"); // %W
1903        assert_eq!(parts[20], "2024"); // %G
1904        assert_eq!(parts[21], "24"); // %g
1905        assert_eq!(parts[22], "11"); // %V
1906        assert_eq!(parts[23], "2024"); // %Y
1907        assert_eq!(parts[24], "%"); // %%
1908    }
1909
1910    #[test]
1911    fn test_strftime_null() {
1912        assert_eq!(
1913            StrftimeFunc.invoke(&[null(), text("2024-01-01")]).unwrap(),
1914            SqliteValue::Null
1915        );
1916        assert_eq!(
1917            StrftimeFunc.invoke(&[text("%Y"), null()]).unwrap(),
1918            SqliteValue::Null
1919        );
1920    }
1921
1922    #[test]
1923    #[ignore = "perf-only benchmark"]
1924    fn perf_strftime_timestamp_rows() {
1925        use std::hint::black_box;
1926        use std::time::Instant;
1927
1928        const ROWS: usize = 200_000;
1929        const REPEATS: usize = 5;
1930        const FORMAT: &str = "%Y-%m-%d %H:%M:%S";
1931        const INPUT: &str = "2024-03-15 14:30:45";
1932
1933        let func = StrftimeFunc;
1934        let fmt = text(FORMAT);
1935        let input = text(INPUT);
1936        let mut best_ns = u128::MAX;
1937        let mut output_len = 0usize;
1938
1939        for _ in 0..REPEATS {
1940            let started = Instant::now();
1941            for _ in 0..ROWS {
1942                let result = black_box(
1943                    func.invoke(black_box(&[fmt.clone(), input.clone()]))
1944                        .expect("strftime benchmark invocation must succeed"),
1945                );
1946                output_len = match result {
1947                    SqliteValue::Text(text) => text.len(),
1948                    SqliteValue::Null
1949                    | SqliteValue::Integer(_)
1950                    | SqliteValue::Float(_)
1951                    | SqliteValue::Blob(_) => 0,
1952                };
1953            }
1954            let elapsed_ns = started.elapsed().as_nanos();
1955            if elapsed_ns < best_ns {
1956                best_ns = elapsed_ns;
1957            }
1958        }
1959
1960        println!(
1961            "strftime_timestamp_rows rows={ROWS} repeats={REPEATS} best_ns={best_ns} output_len={output_len}"
1962        );
1963    }
1964
1965    // ── timediff ──────────────────────────────────────────────────────
1966
1967    #[test]
1968    fn test_timediff_basic() {
1969        let r = TimediffFunc
1970            .invoke(&[text("2024-03-15"), text("2024-03-10")])
1971            .unwrap();
1972        assert_text(&r, "+0000-00-05 00:00:00.000");
1973    }
1974
1975    #[test]
1976    fn test_timediff_negative() {
1977        let r = TimediffFunc
1978            .invoke(&[text("2024-03-10"), text("2024-03-15")])
1979            .unwrap();
1980        assert_text(&r, "-0000-00-05 00:00:00.000");
1981    }
1982
1983    #[test]
1984    fn test_timediff_year_boundary() {
1985        let r = TimediffFunc
1986            .invoke(&[text("2024-01-01 01:00:00"), text("2023-12-31 23:00:00")])
1987            .unwrap();
1988        assert_text(&r, "+0000-00-00 02:00:00.000");
1989    }
1990
1991    // ── Subsec modifier ───────────────────────────────────────────────
1992
1993    #[test]
1994    fn test_modifier_subsec() {
1995        let r = TimeFunc
1996            .invoke(&[text("2024-01-01 12:00:00.123"), text("subsec")])
1997            .unwrap();
1998        match &r {
1999            SqliteValue::Text(s) => assert!(
2000                s.contains('.'),
2001                "expected fractional seconds with subsec: {s}"
2002            ),
2003            other => panic!("expected Text, got {other:?}"),
2004        }
2005    }
2006
2007    // ── Registration ──────────────────────────────────────────────────
2008
2009    #[test]
2010    fn test_register_datetime_builtins_all_present() {
2011        let mut reg = FunctionRegistry::new();
2012        register_datetime_builtins(&mut reg);
2013
2014        let expected = [
2015            "date",
2016            "time",
2017            "datetime",
2018            "julianday",
2019            "unixepoch",
2020            "strftime",
2021            "timediff",
2022        ];
2023
2024        for name in expected {
2025            assert!(
2026                reg.find_scalar(name, 1).is_some() || reg.find_scalar(name, 2).is_some(),
2027                "datetime function '{name}' not registered"
2028            );
2029        }
2030    }
2031
2032    // ── JDN roundtrip ─────────────────────────────────────────────────
2033
2034    #[test]
2035    fn test_modifier_year_overflow() {
2036        // "+9223372036854775807 years" causes i64 overflow in year calculation.
2037        // Should return NULL, not panic.
2038        let huge = i64::MAX;
2039        let modifier = format!("+{huge} years");
2040        let r = DateFunc.invoke(&[text("2000-01-01"), text(&modifier)]);
2041        // The implementation should catch overflow and return Ok(Null), or at least not panic.
2042        // If it panics, the test harness catches it (but we want to prevent panics).
2043        assert_eq!(r.unwrap(), SqliteValue::Null);
2044    }
2045
2046    #[test]
2047    fn test_jdn_roundtrip() {
2048        // Test that ymd → jdn → ymd roundtrips correctly.
2049        let dates = [
2050            (2024, 3, 15),
2051            (2000, 1, 1),
2052            (1970, 1, 1),
2053            (2024, 2, 29),
2054            (1900, 1, 1),
2055            (2099, 12, 31),
2056        ];
2057        for (y, m, d) in dates {
2058            let jdn = ymd_to_jdn(y, m, d);
2059            let (y2, m2, d2) = jdn_to_ymd(jdn);
2060            assert_eq!(
2061                (y, m, d),
2062                (y2, m2, d2),
2063                "roundtrip failed for {y}-{m}-{d} (JDN={jdn})"
2064            );
2065        }
2066    }
2067
2068    #[test]
2069    fn test_unix_epoch_roundtrip() {
2070        let jdn = ymd_to_jdn(1970, 1, 1);
2071        let unix = jdn_to_unix(jdn);
2072        assert_eq!(unix, 0, "Unix epoch should be 0");
2073
2074        let jdn2 = unix_to_jdn(0.0);
2075        assert!((jdn2 - UNIX_EPOCH_JDN).abs() < 1e-10, "roundtrip failed");
2076    }
2077}