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