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