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 fsqlite_error::Result;
35use fsqlite_types::SqliteValue;
36
37use crate::{FunctionRegistry, ScalarFunction};
38
39// ── System Timezone Offset ────────────────────────────────────────────────
40//
41// Used by the 'localtime' and 'utc' modifiers.  Since we cannot use unsafe
42// code (libc::localtime_r) and no chrono/time crate is available, we shell
43// out to `date +%z` once and cache the result.
44
45/// Parse a `+HHMM` / `-HHMM` string into seconds.
46fn parse_tz_offset(s: &str) -> Option<i64> {
47    let s = s.trim();
48    if s.len() < 5 {
49        return None;
50    }
51    let sign: i64 = if s.starts_with('-') { -1 } else { 1 };
52    let digits = if s.starts_with('+') || s.starts_with('-') {
53        &s[1..]
54    } else {
55        s
56    };
57    if digits.len() < 4 {
58        return None;
59    }
60    let hours: i64 = digits[..2].parse().ok()?;
61    let minutes: i64 = digits[2..4].parse().ok()?;
62    Some(sign * (hours * 3600 + minutes * 60))
63}
64
65/// Compute the local UTC offset in seconds by running `date +%z`.
66fn compute_utc_offset() -> i64 {
67    // Check TZ env var for explicit UTC.
68    if let Ok(tz) = std::env::var("TZ") {
69        let tz_upper = tz.trim().to_uppercase();
70        if tz_upper == "UTC"
71            || tz_upper == "GMT"
72            || tz_upper == "UTC0"
73            || tz_upper.starts_with("UTC-0")
74            || tz_upper.starts_with("UTC+0")
75        {
76            return 0;
77        }
78    }
79    let output = std::process::Command::new("date").arg("+%z").output();
80    match output {
81        Ok(out) if out.status.success() => {
82            let s = String::from_utf8_lossy(&out.stdout);
83            parse_tz_offset(s.trim()).unwrap_or(0)
84        }
85        _ => 0,
86    }
87}
88
89/// Return the cached system UTC offset in seconds.
90fn utc_offset_seconds() -> i64 {
91    use std::sync::OnceLock;
92    static OFFSET: OnceLock<i64> = OnceLock::new();
93    *OFFSET.get_or_init(compute_utc_offset)
94}
95
96// ── Julian Day Number Conversions ─────────────────────────────────────────
97//
98// Algorithms from Meeus, "Astronomical Algorithms" (1991).
99
100/// Gregorian (y, m, d, h, min, sec, frac_sec) → Julian Day Number.
101fn ymd_to_jdn(y: i64, m: i64, d: i64) -> f64 {
102    let (y, m) = if m <= 2 {
103        (y.saturating_sub(1), m.saturating_add(12))
104    } else {
105        (y, m)
106    };
107    let a = y / 100;
108    let b = 2_i64.saturating_sub(a).saturating_add(a / 4);
109    (365.25 * y.saturating_add(4716) as f64).floor()
110        + (30.6001 * m.saturating_add(1) as f64).floor()
111        + d as f64
112        + b as f64
113        - 1524.5
114}
115
116/// Julian Day Number → Gregorian (year, month, day).
117///
118/// Uses saturating/wrapping-safe arithmetic so that extreme JDN values
119/// (from overflowed modifier chains) produce deterministic garbage rather
120/// than panicking.  Callers that care about validity should bounds-check
121/// the JDN before calling.
122fn jdn_to_ymd(jdn: f64) -> (i64, i64, i64) {
123    let z = (jdn + 0.5).floor() as i64;
124    let a = if z < 2_299_161 {
125        z
126    } else {
127        let alpha = ((z as f64 - 1_867_216.25) / 36524.25).floor() as i64;
128        z.saturating_add(1)
129            .saturating_add(alpha)
130            .saturating_sub(alpha / 4)
131    };
132    let b = a.saturating_add(1524);
133    let c = ((b as f64 - 122.1) / 365.25).floor() as i64;
134    let d = (365.25 * c as f64).floor() as i64;
135    let e = ((b.saturating_sub(d)) as f64 / 30.6001).floor() as i64;
136
137    let day = b
138        .saturating_sub(d)
139        .saturating_sub((30.6001 * e as f64).floor() as i64);
140    let month = if e < 14 {
141        e.saturating_sub(1)
142    } else {
143        e.saturating_sub(13)
144    };
145    let year = if month > 2 {
146        c.saturating_sub(4716)
147    } else {
148        c.saturating_sub(4715)
149    };
150    (year, month, day)
151}
152
153/// Julian Day Number → (hour, minute, second, fractional_sec).
154fn jdn_to_hms(jdn: f64) -> (i64, i64, i64, f64) {
155    let frac = jdn + 0.5 - (jdn + 0.5).floor();
156    // Round to nearest millisecond to avoid floating-point drift.
157    let total_ms = (frac * 86_400_000.0).round() as i64;
158    let h = total_ms / 3_600_000;
159    let rem = total_ms % 3_600_000;
160    let m = rem / 60_000;
161    let rem = rem % 60_000;
162    let s = rem / 1000;
163    let ms_frac = (rem % 1000) as f64 / 1000.0;
164    (h, m, s, ms_frac)
165}
166
167/// Build a JDN from date + time components.
168fn ymdhms_to_jdn(y: i64, mo: i64, d: i64, h: i64, mi: i64, s: i64, frac: f64) -> f64 {
169    ymd_to_jdn(y, mo, d) + (h as f64 * 3600.0 + mi as f64 * 60.0 + s as f64 + frac) / 86400.0
170}
171
172/// Unix epoch as JDN.
173const UNIX_EPOCH_JDN: f64 = 2_440_587.5;
174/// Upper bound for values interpreted as Julian day by the `auto` modifier.
175const AUTO_JDN_MAX: f64 = 5_373_484.499_999;
176/// Unix timestamp bounds used by SQLite's `auto` modifier.
177const AUTO_UNIX_MIN: f64 = -210_866_760_000.0;
178const AUTO_UNIX_MAX: f64 = 253_402_300_799.0;
179
180fn jdn_to_unix(jdn: f64) -> i64 {
181    ((jdn - UNIX_EPOCH_JDN) * 86400.0).round() as i64
182}
183
184fn unix_to_jdn(ts: f64) -> f64 {
185    ts / 86400.0 + UNIX_EPOCH_JDN
186}
187
188fn is_leap_year(y: i64) -> bool {
189    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
190}
191
192fn days_in_month(y: i64, m: i64) -> i64 {
193    match m {
194        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
195        4 | 6 | 9 | 11 => 30,
196        2 => {
197            if is_leap_year(y) {
198                29
199            } else {
200                28
201            }
202        }
203        _ => 30,
204    }
205}
206
207fn day_of_year(y: i64, m: i64, d: i64) -> i64 {
208    let mut doy = d;
209    for mo in 1..m {
210        doy = doy.saturating_add(days_in_month(y, mo));
211    }
212    doy
213}
214
215// ── Time String Parsing ───────────────────────────────────────────────────
216
217/// Parse a SQLite time string into a JDN.
218fn parse_timestring(s: &str) -> Option<f64> {
219    let s = s.trim();
220
221    // Special value: 'now' — we use a fixed time for determinism in tests.
222    // Real 'now' will come from the Cx connection time source later.
223    if s.eq_ignore_ascii_case("now") {
224        // Return a placeholder JDN for 2000-01-01 00:00:00.
225        return Some(ymd_to_jdn(2000, 1, 1));
226    }
227
228    // Try as a Julian Day Number (bare float).
229    // Reject non-finite values (NaN/Inf) — sqlite3AtoF doesn't recognize them.
230    if let Ok(jdn) = s.parse::<f64>() {
231        if jdn >= 0.0 && jdn.is_finite() {
232            return Some(jdn);
233        }
234    }
235
236    // ISO-8601 variants.
237    parse_iso8601(s)
238}
239
240fn parse_iso8601(s: &str) -> Option<f64> {
241    // YYYY-MM-DD HH:MM:SS.SSS  or  YYYY-MM-DDTHH:MM:SS.SSS
242    // YYYY-MM-DD HH:MM:SS  or  YYYY-MM-DD HH:MM
243    // YYYY-MM-DD
244    // HH:MM:SS.SSS  (bare time → 2000-01-01)
245    // HH:MM:SS
246    // HH:MM
247
248    let bytes = s.as_bytes();
249    let len = bytes.len();
250
251    // Try date-only or date+time.
252    if len >= 10 && bytes[4] == b'-' && bytes[7] == b'-' {
253        let y = s[0..4].parse::<i64>().ok()?;
254        let m = s[5..7].parse::<i64>().ok()?;
255        let d = s[8..10].parse::<i64>().ok()?;
256
257        if m < 1 || m > 12 || d < 1 || d > 31 {
258            return None;
259        }
260
261        if len == 10 {
262            return Some(ymd_to_jdn(y, m, d));
263        }
264
265        // Separator: space or 'T'.
266        if len > 10 && (bytes[10] == b' ' || bytes[10] == b'T') {
267            let time_part = &s[11..];
268            let (h, mi, sec, frac) = parse_time_part(time_part)?;
269            return Some(ymdhms_to_jdn(y, m, d, h, mi, sec, frac));
270        }
271        return None;
272    }
273
274    // Bare time: HH:MM:SS or HH:MM:SS.SSS or HH:MM
275    if len >= 5 && bytes[2] == b':' {
276        let (h, mi, sec, frac) = parse_time_part(s)?;
277        return Some(ymdhms_to_jdn(2000, 1, 1, h, mi, sec, frac));
278    }
279
280    None
281}
282
283/// Parse "HH:MM:SS.SSS" or "HH:MM:SS" or "HH:MM".
284fn parse_time_part(s: &str) -> Option<(i64, i64, i64, f64)> {
285    let parts: Vec<&str> = s.splitn(3, ':').collect();
286    if parts.len() < 2 {
287        return None;
288    }
289    let h = parts[0].parse::<i64>().ok()?;
290    let mi = parts[1].parse::<i64>().ok()?;
291    if !(0..=23).contains(&h) || !(0..=59).contains(&mi) {
292        return None;
293    }
294
295    if parts.len() == 2 {
296        return Some((h, mi, 0, 0.0));
297    }
298
299    // Third part may have fractional seconds: "SS" or "SS.SSS".
300    let sec_str = parts[2];
301    if let Some(dot_pos) = sec_str.find('.') {
302        let sec = sec_str[..dot_pos].parse::<i64>().ok()?;
303        let frac_str = &sec_str[dot_pos..]; // ".SSS"
304        let frac = frac_str.parse::<f64>().ok()?;
305        if !(0..=59).contains(&sec) {
306            return None;
307        }
308        Some((h, mi, sec, frac))
309    } else {
310        let sec = sec_str.parse::<i64>().ok()?;
311        if !(0..=59).contains(&sec) {
312            return None;
313        }
314        Some((h, mi, sec, 0.0))
315    }
316}
317
318// ── Modifier Pipeline ─────────────────────────────────────────────────────
319
320/// Apply a single modifier string to a JDN.  Returns None if invalid.
321fn apply_modifier(jdn: f64, modifier: &str) -> Option<f64> {
322    let m = modifier.trim().to_ascii_lowercase();
323
324    // 'start of month' / 'start of year' / 'start of day'
325    if m == "start of month" {
326        let (y, mo, _d) = jdn_to_ymd(jdn);
327        return Some(ymd_to_jdn(y, mo, 1));
328    }
329    if m == "start of year" {
330        let (y, _mo, _d) = jdn_to_ymd(jdn);
331        return Some(ymd_to_jdn(y, 1, 1));
332    }
333    if m == "start of day" {
334        let (y, mo, d) = jdn_to_ymd(jdn);
335        return Some(ymd_to_jdn(y, mo, d));
336    }
337
338    // 'unixepoch' — reinterpret input as Unix timestamp.
339    if m == "unixepoch" {
340        return Some(unix_to_jdn(jdn));
341    }
342
343    // 'julianday' — input is already a JDN (no-op here, but the spec says
344    // it forces interpretation as JDN).
345    if m == "julianday" {
346        return Some(jdn);
347    }
348
349    // 'auto' — apply SQLite numeric auto-detection:
350    //   0.0..=5373484.499999          => Julian day number
351    //   -210866760000..=253402300799  => Unix timestamp
352    //   otherwise                      => NULL
353    if m == "auto" {
354        if (0.0..=AUTO_JDN_MAX).contains(&jdn) {
355            return Some(jdn);
356        }
357        if (AUTO_UNIX_MIN..=AUTO_UNIX_MAX).contains(&jdn) {
358            return Some(unix_to_jdn(jdn));
359        }
360        return None;
361    }
362
363    // 'localtime' — convert UTC to local time by adding the system UTC offset.
364    if m == "localtime" {
365        let offset = utc_offset_seconds();
366        return Some(jdn + offset as f64 / 86400.0);
367    }
368    // 'utc' — convert local time to UTC by subtracting the system UTC offset.
369    if m == "utc" {
370        let offset = utc_offset_seconds();
371        return Some(jdn - offset as f64 / 86400.0);
372    }
373
374    // 'subsec' / 'subsecond' — this is a flag that affects output formatting,
375    // not the JDN.  We pass it through unchanged.
376    if m == "subsec" || m == "subsecond" {
377        return Some(jdn);
378    }
379
380    // 'weekday N' — advance to the next day that is weekday N (0=Sunday).
381    if let Some(rest) = m.strip_prefix("weekday ") {
382        let wd = rest.trim().parse::<i64>().ok()?;
383        if !(0..=6).contains(&wd) {
384            return None;
385        }
386        // Current day of week: 0=Monday in JDN, but SQLite uses 0=Sunday.
387        let current_jdn_int = (jdn + 0.5).floor() as i64;
388        let current_wd = (current_jdn_int + 1) % 7; // 0=Sunday
389        let mut diff = wd - current_wd;
390        if diff < 0 {
391            diff += 7;
392        }
393        // If already the target weekday, this is a no-op (SQLite behavior).
394        return Some(jdn + diff as f64);
395    }
396
397    // Arithmetic: '+NNN days', '-NNN hours', etc.
398    parse_arithmetic_modifier(&m).map(|delta| jdn + delta)
399}
400
401/// Parse "+NNN unit" / "-NNN unit" and return the JDN delta.
402fn parse_arithmetic_modifier(m: &str) -> Option<f64> {
403    let (sign, rest) = if let Some(r) = m.strip_prefix('+') {
404        (1.0, r.trim())
405    } else if let Some(r) = m.strip_prefix('-') {
406        (-1.0, r.trim())
407    } else {
408        return None;
409    };
410
411    let mut parts = rest.splitn(2, ' ');
412    let num_str = parts.next()?;
413    let unit = parts.next()?.trim();
414
415    // Reject non-finite (NaN/Inf) — sqlite3AtoF doesn't recognize them.
416    let num = num_str.parse::<f64>().ok().filter(|f| f.is_finite())?;
417    let delta = num * sign;
418
419    match unit.trim_end_matches('s') {
420        "day" => Some(delta),
421        "hour" => Some(delta / 24.0),
422        "minute" => Some(delta / 1440.0),
423        "second" => Some(delta / 86400.0),
424        "month" => Some(apply_month_delta(delta)),
425        "year" => Some(apply_month_delta(delta * 12.0)),
426        _ => None,
427    }
428}
429
430/// For month/year arithmetic, we can't simply add a JDN delta because months
431/// have variable lengths.  This returns a JDN delta that is *approximately*
432/// correct.  A fully correct implementation requires decomposing and
433/// recomposing, which we handle in `apply_modifier_full` for month/year cases.
434fn apply_month_delta(months: f64) -> f64 {
435    // Average month ≈ 30.436875 days.
436    months * 30.436875
437}
438
439/// Apply a sequence of modifiers, also tracking the 'subsec' flag.
440fn apply_modifiers(jdn: f64, modifiers: &[String]) -> Option<(f64, bool)> {
441    let mut j = jdn;
442    let mut subsec = false;
443    for m in modifiers {
444        let m_lower = m.trim().to_ascii_lowercase();
445        if m_lower == "subsec" || m_lower == "subsecond" {
446            subsec = true;
447        }
448        // Month/year modifiers need special handling for exact date math.
449        // If exact arithmetic overflows (returns None), the modifier is
450        // out of representable range — return NULL rather than falling
451        // through to the approximate path which would produce overflow
452        // panics in jdn_to_ymd.
453        if is_month_year_modifier(&m_lower) {
454            match apply_month_year_exact(j, &m_lower) {
455                Ok(new_jdn) => {
456                    j = new_jdn?;
457                    continue;
458                }
459                Err(()) => {
460                    // Fall through to `apply_modifier` for fractional values
461                }
462            }
463        }
464        j = apply_modifier(j, m)?;
465    }
466    Some((j, subsec))
467}
468
469fn is_month_year_modifier(m: &str) -> bool {
470    (m.contains("month") || m.contains("year")) && (m.starts_with('+') || m.starts_with('-'))
471}
472
473/// Exact month/year arithmetic by decomposing to YMD.
474/// Returns Ok(Some(jdn)) for exact application.
475/// Returns Ok(None) for overflow.
476/// Returns Err(()) if the modifier is not an integer, so it should fall back.
477fn apply_month_year_exact(jdn: f64, m: &str) -> std::result::Result<Option<f64>, ()> {
478    let (sign, rest) = if let Some(r) = m.strip_prefix('+') {
479        (1_i64, r.trim())
480    } else if let Some(r) = m.strip_prefix('-') {
481        (-1_i64, r.trim())
482    } else {
483        return Err(());
484    };
485
486    let mut parts = rest.splitn(2, ' ');
487    let num_str = parts.next().ok_or(())?;
488    let unit = parts.next().ok_or(())?.trim();
489
490    // SQLite uses exact math only if the value is an integer.
491    let num = if let Ok(n) = num_str.parse::<i64>() {
492        n
493    } else if let Ok(f) = num_str.parse::<f64>() {
494        if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
495            f as i64
496        } else {
497            return Err(());
498        }
499    } else {
500        return Err(());
501    };
502
503    let (y, mo, d) = jdn_to_ymd(jdn);
504    let (h, mi, s, frac) = jdn_to_hms(jdn);
505
506    let total_months = match unit.trim_end_matches('s') {
507        "month" => {
508            if let Some(val) = num.checked_mul(sign) {
509                val
510            } else {
511                return Ok(None);
512            }
513        }
514        "year" => {
515            if let Some(val) = num.checked_mul(sign).and_then(|v| v.checked_mul(12)) {
516                val
517            } else {
518                return Ok(None);
519            }
520        }
521        _ => return Err(()),
522    };
523
524    // (y * 12 + (mo - 1)) + total_months
525    let current_months = if let Some(val) = y.checked_mul(12).and_then(|v| v.checked_add(mo - 1)) {
526        val
527    } else {
528        return Ok(None);
529    };
530    let new_total = if let Some(val) = current_months.checked_add(total_months) {
531        val
532    } else {
533        return Ok(None);
534    };
535
536    let new_y = new_total.div_euclid(12);
537    let new_mo = new_total.rem_euclid(12) + 1;
538    // Do NOT clamp `d` to the target month's day count.  C SQLite lets
539    // out-of-range days overflow via JDN arithmetic (e.g. Feb 31 → Mar 3).
540    Ok(Some(ymdhms_to_jdn(new_y, new_mo, d, h, mi, s, frac)))
541}
542
543// ── Output Formatters ─────────────────────────────────────────────────────
544
545fn format_date(jdn: f64) -> String {
546    let (y, m, d) = jdn_to_ymd(jdn);
547    format!("{y:04}-{m:02}-{d:02}")
548}
549
550fn format_time(jdn: f64, subsec: bool) -> String {
551    let (h, m, s, frac) = jdn_to_hms(jdn);
552    if subsec && frac > 1e-9 {
553        format!("{h:02}:{m:02}:{s:02}.{:03}", (frac * 1000.0).round() as i64)
554    } else {
555        format!("{h:02}:{m:02}:{s:02}")
556    }
557}
558
559fn format_datetime(jdn: f64, subsec: bool) -> String {
560    format!("{} {}", format_date(jdn), format_time(jdn, subsec))
561}
562
563/// strftime format engine.
564fn format_strftime(fmt: &str, jdn: f64) -> String {
565    let (y, mo, d) = jdn_to_ymd(jdn);
566    let (h, mi, s, frac) = jdn_to_hms(jdn);
567    let doy = day_of_year(y, mo, d);
568    // Day of week: 0=Sunday.
569    let jdn_int = (jdn + 0.5).floor() as i64;
570    let dow = (jdn_int + 1) % 7; // 0=Sunday, 6=Saturday
571
572    let mut result = String::new();
573    let chars: Vec<char> = fmt.chars().collect();
574    let mut i = 0;
575
576    while i < chars.len() {
577        if chars[i] == '%' && i + 1 < chars.len() {
578            i += 1;
579            match chars[i] {
580                'd' => result.push_str(&format!("{d:02}")),
581                'e' => result.push_str(&format!("{d:>2}")), // space-padded day
582                'f' => {
583                    // Seconds with fractional part.
584                    let total = s as f64 + frac;
585                    result.push_str(&format!("{total:06.3}"));
586                }
587                'H' => result.push_str(&format!("{h:02}")),
588                'I' => {
589                    // 12-hour clock.
590                    let h12 = if h == 0 {
591                        12
592                    } else if h > 12 {
593                        h - 12
594                    } else {
595                        h
596                    };
597                    result.push_str(&format!("{h12:02}"));
598                }
599                'j' => result.push_str(&format!("{doy:03}")),
600                'J' => {
601                    // C SQLite uses %.15g which strips trailing zeros.
602                    let s = format!("{jdn:.15}");
603                    let s = s.trim_end_matches('0');
604                    let s = s.strip_suffix('.').unwrap_or(s);
605                    result.push_str(s);
606                }
607                'k' => {
608                    // Space-padded 24-hour.
609                    result.push_str(&format!("{h:>2}"));
610                }
611                'l' => {
612                    // Space-padded 12-hour.
613                    let h12 = if h == 0 {
614                        12
615                    } else if h > 12 {
616                        h - 12
617                    } else {
618                        h
619                    };
620                    result.push_str(&format!("{h12:>2}"));
621                }
622                'm' => result.push_str(&format!("{mo:02}")),
623                'M' => result.push_str(&format!("{mi:02}")),
624                'p' => {
625                    result.push_str(if h < 12 { "AM" } else { "PM" });
626                }
627                'P' => {
628                    result.push_str(if h < 12 { "am" } else { "pm" });
629                }
630                'R' => result.push_str(&format!("{h:02}:{mi:02}")),
631                's' => {
632                    let unix = jdn_to_unix(jdn);
633                    result.push_str(&unix.to_string());
634                }
635                'S' => result.push_str(&format!("{s:02}")),
636                'T' => result.push_str(&format!("{h:02}:{mi:02}:{s:02}")),
637                'u' => {
638                    // ISO 8601 day of week: 1=Monday, 7=Sunday.
639                    let u = if dow == 0 { 7 } else { dow };
640                    result.push_str(&u.to_string());
641                }
642                'w' => result.push_str(&dow.to_string()),
643                'W' => {
644                    // Week of year (Monday as first day of week, 00-53).
645                    let w = (doy + 6 - ((dow + 6) % 7)) / 7;
646                    result.push_str(&format!("{w:02}"));
647                }
648                'Y' => result.push_str(&format!("{y:04}")),
649                'G' | 'g' | 'V' => {
650                    // ISO 8601 week-based year/week.
651                    let (iso_y, iso_w) = iso_week(y, mo, d);
652                    match chars[i] {
653                        'G' => result.push_str(&format!("{iso_y:04}")),
654                        'g' => result.push_str(&format!("{:02}", iso_y % 100)),
655                        'V' => result.push_str(&format!("{iso_w:02}")),
656                        _ => unreachable!(),
657                    }
658                }
659                '%' => result.push('%'),
660                other => {
661                    result.push('%');
662                    result.push(other);
663                }
664            }
665        } else {
666            result.push(chars[i]);
667        }
668        i += 1;
669    }
670
671    result
672}
673
674/// ISO 8601 week number and year.
675fn iso_week(y: i64, m: i64, d: i64) -> (i64, i64) {
676    let jdn = ymd_to_jdn(y, m, d);
677    let jdn_int = (jdn + 0.5).floor() as i64;
678    // ISO day of week: 1=Monday, 7=Sunday.
679    let dow = (jdn_int + 1) % 7;
680    let iso_dow = if dow == 0 { 7 } else { dow };
681
682    // Thursday of the same week determines the year.
683    let thu_jdn = jdn_int + (4 - iso_dow);
684    let (thu_y, _, _) = jdn_to_ymd(thu_jdn as f64);
685
686    // Jan 4 is always in week 1 (ISO 8601).
687    let jan4_jdn = (ymd_to_jdn(thu_y, 1, 4) + 0.5).floor() as i64;
688    let jan4_dow = (jan4_jdn + 1) % 7;
689    let jan4_iso_dow = if jan4_dow == 0 { 7 } else { jan4_dow };
690    let week1_start = jan4_jdn - (jan4_iso_dow - 1);
691
692    let week = (thu_jdn - week1_start) / 7 + 1;
693    (thu_y, week)
694}
695
696// ── timediff ──────────────────────────────────────────────────────────────
697
698fn timediff_impl(jdn1: f64, jdn2: f64) -> String {
699    let (sign, start_jdn, end_jdn) = if jdn1 >= jdn2 {
700        ('+', jdn2, jdn1)
701    } else {
702        ('-', jdn1, jdn2)
703    };
704
705    let (start_y, start_mo, start_d) = jdn_to_ymd(start_jdn);
706    let (start_h, start_mi, mut start_s, start_frac) = jdn_to_hms(start_jdn);
707    let mut start_ms = (start_frac * 1000.0).round() as i64;
708    if start_ms >= 1000 {
709        start_ms = 0;
710        start_s += 1;
711    }
712
713    let (end_y, end_mo, end_d) = jdn_to_ymd(end_jdn);
714    let (end_h, end_mi, mut end_s, end_frac) = jdn_to_hms(end_jdn);
715    let mut end_ms = (end_frac * 1000.0).round() as i64;
716    if end_ms >= 1000 {
717        end_ms = 0;
718        end_s += 1;
719    }
720
721    let mut years = end_y - start_y;
722    let mut months = end_mo - start_mo;
723    let mut days = end_d - start_d;
724    let mut hours = end_h - start_h;
725    let mut minutes = end_mi - start_mi;
726    let mut seconds = end_s - start_s;
727    let mut millis = end_ms - start_ms;
728
729    if millis < 0 {
730        millis += 1000;
731        seconds -= 1;
732    }
733    if seconds < 0 {
734        seconds += 60;
735        minutes -= 1;
736    }
737    if minutes < 0 {
738        minutes += 60;
739        hours -= 1;
740    }
741    if hours < 0 {
742        hours += 24;
743        days -= 1;
744    }
745    if days < 0 {
746        months -= 1;
747        let (borrow_y, borrow_mo) = if end_mo == 1 {
748            (end_y - 1, 12)
749        } else {
750            (end_y, end_mo - 1)
751        };
752        days += days_in_month(borrow_y, borrow_mo);
753    }
754    if months < 0 {
755        months += 12;
756        years -= 1;
757    }
758
759    format!(
760        "{sign}{years:04}-{months:02}-{days:02} {hours:02}:{minutes:02}:{seconds:02}.{millis:03}"
761    )
762}
763
764// ── Scalar Function Implementations ───────────────────────────────────────
765
766/// Parse args: first arg is time string, rest are modifiers.
767fn parse_args(args: &[SqliteValue]) -> Option<(f64, bool)> {
768    if args.is_empty() || args[0].is_null() {
769        return None;
770    }
771
772    let input = match &args[0] {
773        SqliteValue::Text(s) => parse_timestring(s)?,
774        SqliteValue::Integer(i) => *i as f64,
775        SqliteValue::Float(f) => *f,
776        _ => return None,
777    };
778
779    let modifiers: Vec<String> = args[1..]
780        .iter()
781        .filter_map(|a| if a.is_null() { None } else { Some(a.to_text()) })
782        .collect();
783
784    apply_modifiers(input, &modifiers)
785}
786
787// ── date() ────────────────────────────────────────────────────────────────
788
789pub struct DateFunc;
790
791impl ScalarFunction for DateFunc {
792    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
793        match parse_args(args) {
794            Some((jdn, _)) => Ok(SqliteValue::Text(format_date(jdn).into())),
795            None => Ok(SqliteValue::Null),
796        }
797    }
798
799    fn num_args(&self) -> i32 {
800        -1
801    }
802
803    fn name(&self) -> &str {
804        "date"
805    }
806}
807
808// ── time() ────────────────────────────────────────────────────────────────
809
810pub struct TimeFunc;
811
812impl ScalarFunction for TimeFunc {
813    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
814        match parse_args(args) {
815            Some((jdn, subsec)) => Ok(SqliteValue::Text(format_time(jdn, subsec).into())),
816            None => Ok(SqliteValue::Null),
817        }
818    }
819
820    fn num_args(&self) -> i32 {
821        -1
822    }
823
824    fn name(&self) -> &str {
825        "time"
826    }
827}
828
829// ── datetime() ────────────────────────────────────────────────────────────
830
831pub struct DateTimeFunc;
832
833impl ScalarFunction for DateTimeFunc {
834    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
835        match parse_args(args) {
836            Some((jdn, subsec)) => Ok(SqliteValue::Text(format_datetime(jdn, subsec).into())),
837            None => Ok(SqliteValue::Null),
838        }
839    }
840
841    fn num_args(&self) -> i32 {
842        -1
843    }
844
845    fn name(&self) -> &str {
846        "datetime"
847    }
848}
849
850// ── julianday() ───────────────────────────────────────────────────────────
851
852pub struct JuliandayFunc;
853
854impl ScalarFunction for JuliandayFunc {
855    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
856        match parse_args(args) {
857            Some((jdn, _)) => Ok(SqliteValue::Float(jdn)),
858            None => Ok(SqliteValue::Null),
859        }
860    }
861
862    fn num_args(&self) -> i32 {
863        -1
864    }
865
866    fn name(&self) -> &str {
867        "julianday"
868    }
869}
870
871// ── unixepoch() ───────────────────────────────────────────────────────────
872
873pub struct UnixepochFunc;
874
875impl ScalarFunction for UnixepochFunc {
876    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
877        match parse_args(args) {
878            Some((jdn, _)) => Ok(SqliteValue::Integer(jdn_to_unix(jdn))),
879            None => Ok(SqliteValue::Null),
880        }
881    }
882
883    fn num_args(&self) -> i32 {
884        -1
885    }
886
887    fn name(&self) -> &str {
888        "unixepoch"
889    }
890}
891
892// ── strftime() ────────────────────────────────────────────────────────────
893
894pub struct StrftimeFunc;
895
896impl ScalarFunction for StrftimeFunc {
897    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
898        if args.len() < 2 || args[0].is_null() || args[1].is_null() {
899            return Ok(SqliteValue::Null);
900        }
901        let fmt = args[0].to_text();
902        let rest = &args[1..];
903        match parse_args(rest) {
904            Some((jdn, _)) => Ok(SqliteValue::Text(format_strftime(&fmt, jdn).into())),
905            None => Ok(SqliteValue::Null),
906        }
907    }
908
909    fn num_args(&self) -> i32 {
910        -1
911    }
912
913    fn name(&self) -> &str {
914        "strftime"
915    }
916}
917
918// ── timediff() ────────────────────────────────────────────────────────────
919
920pub struct TimediffFunc;
921
922impl ScalarFunction for TimediffFunc {
923    fn invoke(&self, args: &[SqliteValue]) -> Result<SqliteValue> {
924        if args.len() < 2 || args[0].is_null() || args[1].is_null() {
925            return Ok(SqliteValue::Null);
926        }
927
928        let jdn1 = match &args[0] {
929            SqliteValue::Text(s) => parse_timestring(s),
930            SqliteValue::Integer(i) => Some(*i as f64),
931            SqliteValue::Float(f) => Some(*f),
932            _ => None,
933        };
934        let jdn2 = match &args[1] {
935            SqliteValue::Text(s) => parse_timestring(s),
936            SqliteValue::Integer(i) => Some(*i as f64),
937            SqliteValue::Float(f) => Some(*f),
938            _ => None,
939        };
940
941        match (jdn1, jdn2) {
942            (Some(j1), Some(j2)) => Ok(SqliteValue::Text(timediff_impl(j1, j2).into())),
943            _ => Ok(SqliteValue::Null),
944        }
945    }
946
947    fn num_args(&self) -> i32 {
948        2
949    }
950
951    fn name(&self) -> &str {
952        "timediff"
953    }
954}
955
956// ── Registration ──────────────────────────────────────────────────────────
957
958/// Register all §13.3 date/time functions.
959pub fn register_datetime_builtins(registry: &mut FunctionRegistry) {
960    registry.register_scalar(DateFunc);
961    registry.register_scalar(TimeFunc);
962    registry.register_scalar(DateTimeFunc);
963    registry.register_scalar(JuliandayFunc);
964    registry.register_scalar(UnixepochFunc);
965    registry.register_scalar(StrftimeFunc);
966    registry.register_scalar(TimediffFunc);
967}
968
969// ── Tests ─────────────────────────────────────────────────────────────────
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974
975    fn text(s: &str) -> SqliteValue {
976        SqliteValue::Text(s.into())
977    }
978
979    fn int(v: i64) -> SqliteValue {
980        SqliteValue::Integer(v)
981    }
982
983    fn float(v: f64) -> SqliteValue {
984        SqliteValue::Float(v)
985    }
986
987    fn null() -> SqliteValue {
988        SqliteValue::Null
989    }
990
991    fn assert_text(result: &SqliteValue, expected: &str) {
992        match result {
993            SqliteValue::Text(s) => assert_eq!(s.as_ref(), expected, "text mismatch"),
994            other => panic!("expected Text(\"{expected}\"), got {other:?}"),
995        }
996    }
997
998    // ── Basic functions ───────────────────────────────────────────────
999
1000    #[test]
1001    fn test_date_basic() {
1002        let r = DateFunc.invoke(&[text("2024-03-15 14:30:00")]).unwrap();
1003        assert_text(&r, "2024-03-15");
1004    }
1005
1006    #[test]
1007    fn test_time_basic() {
1008        let r = TimeFunc.invoke(&[text("2024-03-15 14:30:45")]).unwrap();
1009        assert_text(&r, "14:30:45");
1010    }
1011
1012    #[test]
1013    fn test_datetime_basic() {
1014        let r = DateTimeFunc.invoke(&[text("2024-03-15 14:30:00")]).unwrap();
1015        assert_text(&r, "2024-03-15 14:30:00");
1016    }
1017
1018    #[test]
1019    fn test_julianday_basic() {
1020        let r = JuliandayFunc.invoke(&[text("2024-03-15")]).unwrap();
1021        match r {
1022            SqliteValue::Float(jdn) => {
1023                // JDN for 2024-03-15 should be approximately 2460384.5
1024                assert!((jdn - 2_460_384.5).abs() < 0.01, "unexpected JDN: {jdn}");
1025            }
1026            other => panic!("expected Float, got {other:?}"),
1027        }
1028    }
1029
1030    #[test]
1031    fn test_unixepoch_basic() {
1032        let r = UnixepochFunc
1033            .invoke(&[text("1970-01-01 00:00:00")])
1034            .unwrap();
1035        assert_eq!(r, int(0));
1036    }
1037
1038    #[test]
1039    fn test_unixepoch_known_date() {
1040        let r = UnixepochFunc
1041            .invoke(&[text("2024-01-01 00:00:00")])
1042            .unwrap();
1043        // 2024-01-01 00:00:00 UTC = 1704067200
1044        assert_eq!(r, int(1_704_067_200));
1045    }
1046
1047    // ── Modifiers ─────────────────────────────────────────────────────
1048
1049    #[test]
1050    fn test_modifier_days() {
1051        let r = DateFunc
1052            .invoke(&[text("2024-01-15"), text("+10 days")])
1053            .unwrap();
1054        assert_text(&r, "2024-01-25");
1055    }
1056
1057    #[test]
1058    fn test_modifier_months() {
1059        // 2024-01-31 + 1 month: C SQLite lets day=31 overflow via JDN
1060        // arithmetic → Feb 31 wraps to Mar 2 (2024 is a leap year).
1061        let r = DateFunc
1062            .invoke(&[text("2024-01-31"), text("+1 months")])
1063            .unwrap();
1064        assert_text(&r, "2024-03-02");
1065    }
1066
1067    #[test]
1068    fn test_modifier_years() {
1069        // 2024-02-29 + 1 year: 2025 is not a leap year, day=29 overflows
1070        // via JDN arithmetic → Mar 1.
1071        let r = DateFunc
1072            .invoke(&[text("2024-02-29"), text("+1 years")])
1073            .unwrap();
1074        assert_text(&r, "2025-03-01");
1075    }
1076
1077    #[test]
1078    fn test_modifier_hours() {
1079        let r = DateTimeFunc
1080            .invoke(&[text("2024-01-01 23:00:00"), text("+2 hours")])
1081            .unwrap();
1082        assert_text(&r, "2024-01-02 01:00:00");
1083    }
1084
1085    #[test]
1086    fn test_modifier_start_of_month() {
1087        let r = DateFunc
1088            .invoke(&[text("2024-03-15"), text("start of month")])
1089            .unwrap();
1090        assert_text(&r, "2024-03-01");
1091    }
1092
1093    #[test]
1094    fn test_modifier_start_of_year() {
1095        let r = DateFunc
1096            .invoke(&[text("2024-06-15"), text("start of year")])
1097            .unwrap();
1098        assert_text(&r, "2024-01-01");
1099    }
1100
1101    #[test]
1102    fn test_modifier_start_of_day() {
1103        let r = DateTimeFunc
1104            .invoke(&[text("2024-03-15 14:30:00"), text("start of day")])
1105            .unwrap();
1106        assert_text(&r, "2024-03-15 00:00:00");
1107    }
1108
1109    #[test]
1110    fn test_modifier_unixepoch() {
1111        let r = DateTimeFunc.invoke(&[int(0), text("unixepoch")]).unwrap();
1112        assert_text(&r, "1970-01-01 00:00:00");
1113    }
1114
1115    #[test]
1116    fn test_modifier_weekday() {
1117        // 2024-03-15 is Friday. `weekday 0` advances to the next Sunday.
1118        let r = DateFunc
1119            .invoke(&[text("2024-03-15"), text("weekday 0")])
1120            .unwrap();
1121        assert_text(&r, "2024-03-17");
1122    }
1123
1124    #[test]
1125    fn test_modifier_auto_unixepoch() {
1126        let ts = int(1_710_531_045);
1127        let r = DateTimeFunc.invoke(&[ts.clone(), text("auto")]).unwrap();
1128        let expected = DateTimeFunc.invoke(&[ts, text("unixepoch")]).unwrap();
1129        assert_eq!(
1130            r, expected,
1131            "auto and unixepoch should agree for unix-like values"
1132        );
1133    }
1134
1135    #[test]
1136    fn test_modifier_auto_julian_day() {
1137        let r = DateFunc
1138            .invoke(&[float(2_460_384.5), text("auto")])
1139            .unwrap();
1140        assert_text(&r, "2024-03-15");
1141    }
1142
1143    #[test]
1144    fn test_modifier_localtime_utc_roundtrip() {
1145        // localtime→utc should roundtrip back to the original value.
1146        let r = DateTimeFunc
1147            .invoke(&[text("2024-03-15 14:30:45"), text("localtime"), text("utc")])
1148            .unwrap();
1149        assert_text(&r, "2024-03-15 14:30:45");
1150    }
1151
1152    #[test]
1153    fn test_modifier_localtime_shifts_value() {
1154        // When system offset != 0, 'localtime' should actually shift the value.
1155        let offset = utc_offset_seconds();
1156        if offset != 0 {
1157            let r = DateTimeFunc
1158                .invoke(&[text("2024-03-15 12:00:00"), text("localtime")])
1159                .unwrap();
1160            // The shifted value should differ from the input.
1161            let shifted = match &r {
1162                SqliteValue::Text(s) => s.clone(),
1163                _ => panic!("expected text"),
1164            };
1165            assert_ne!(&*shifted, "2024-03-15 12:00:00");
1166        }
1167    }
1168
1169    #[test]
1170    fn test_modifier_auto_out_of_range_returns_null() {
1171        let r = DateTimeFunc.invoke(&[float(1.0e20), text("auto")]).unwrap();
1172        assert_eq!(r, SqliteValue::Null);
1173    }
1174
1175    #[test]
1176    fn test_modifier_order_matters() {
1177        // 'start of month' then '+1 day' = March 2nd.
1178        let r1 = DateFunc
1179            .invoke(&[text("2024-03-15"), text("start of month"), text("+1 days")])
1180            .unwrap();
1181        assert_text(&r1, "2024-03-02");
1182
1183        // '+1 day' then 'start of month' = March 1st.
1184        let r2 = DateFunc
1185            .invoke(&[text("2024-03-15"), text("+1 days"), text("start of month")])
1186            .unwrap();
1187        assert_text(&r2, "2024-03-01");
1188    }
1189
1190    #[test]
1191    fn test_modifier_weekday_same_day_is_noop() {
1192        // 2024-03-17 is Sunday; SQLite semantics: already on target weekday, no-op.
1193        let r = DateFunc
1194            .invoke(&[text("2024-03-17"), text("weekday 0")])
1195            .unwrap();
1196        assert_text(&r, "2024-03-17");
1197    }
1198
1199    // ── Input formats ─────────────────────────────────────────────────
1200
1201    #[test]
1202    fn test_bare_time_defaults() {
1203        let r = DateFunc.invoke(&[text("12:30:00")]).unwrap();
1204        assert_text(&r, "2000-01-01");
1205    }
1206
1207    #[test]
1208    fn test_t_separator() {
1209        let r = DateTimeFunc.invoke(&[text("2024-03-15T14:30:00")]).unwrap();
1210        assert_text(&r, "2024-03-15 14:30:00");
1211    }
1212
1213    #[test]
1214    fn test_julian_day_input() {
1215        // 2460384.5 is 2024-03-15.
1216        let r = DateFunc.invoke(&[float(2_460_384.5)]).unwrap();
1217        assert_text(&r, "2024-03-15");
1218    }
1219
1220    #[test]
1221    fn test_null_input() {
1222        assert_eq!(DateFunc.invoke(&[null()]).unwrap(), SqliteValue::Null);
1223    }
1224
1225    #[test]
1226    fn test_invalid_input() {
1227        assert_eq!(
1228            DateFunc.invoke(&[text("not-a-date")]).unwrap(),
1229            SqliteValue::Null
1230        );
1231    }
1232
1233    #[test]
1234    fn test_negative_time_component_invalid() {
1235        let r = TimeFunc.invoke(&[text("-01:00")]).unwrap();
1236        assert_eq!(r, SqliteValue::Null);
1237    }
1238
1239    // ── Leap year ─────────────────────────────────────────────────────
1240
1241    #[test]
1242    fn test_leap_year() {
1243        let r = DateFunc
1244            .invoke(&[text("2024-02-28"), text("+1 days")])
1245            .unwrap();
1246        assert_text(&r, "2024-02-29");
1247    }
1248
1249    #[test]
1250    fn test_non_leap_year() {
1251        let r = DateFunc
1252            .invoke(&[text("2023-02-28"), text("+1 days")])
1253            .unwrap();
1254        assert_text(&r, "2023-03-01");
1255    }
1256
1257    // ── strftime ──────────────────────────────────────────────────────
1258
1259    #[test]
1260    fn test_strftime_basic() {
1261        let r = StrftimeFunc
1262            .invoke(&[text("%Y-%m-%d"), text("2024-03-15")])
1263            .unwrap();
1264        assert_text(&r, "2024-03-15");
1265    }
1266
1267    #[test]
1268    fn test_strftime_time_specifiers() {
1269        let r = StrftimeFunc
1270            .invoke(&[text("%H:%M:%S"), text("2024-03-15 14:30:45")])
1271            .unwrap();
1272        assert_text(&r, "14:30:45");
1273    }
1274
1275    #[test]
1276    fn test_strftime_unix_seconds() {
1277        let r = StrftimeFunc
1278            .invoke(&[text("%s"), text("1970-01-01 00:00:00")])
1279            .unwrap();
1280        assert_text(&r, "0");
1281    }
1282
1283    #[test]
1284    fn test_strftime_day_of_year() {
1285        let r = StrftimeFunc
1286            .invoke(&[text("%j"), text("2024-03-15")])
1287            .unwrap();
1288        // 2024-03-15: Jan(31) + Feb(29) + 15 = 75
1289        assert_text(&r, "075");
1290    }
1291
1292    #[test]
1293    fn test_strftime_day_of_week() {
1294        // 2024-03-15 is a Friday → w=5 (0=Sunday), u=5 (1=Monday)
1295        let r = StrftimeFunc
1296            .invoke(&[text("%w"), text("2024-03-15")])
1297            .unwrap();
1298        assert_text(&r, "5");
1299
1300        let r = StrftimeFunc
1301            .invoke(&[text("%u"), text("2024-03-15")])
1302            .unwrap();
1303        assert_text(&r, "5");
1304    }
1305
1306    #[test]
1307    fn test_strftime_12hour() {
1308        let r = StrftimeFunc
1309            .invoke(&[text("%I %p"), text("2024-03-15 14:30:00")])
1310            .unwrap();
1311        assert_text(&r, "02 PM");
1312
1313        let r = StrftimeFunc
1314            .invoke(&[text("%I %P"), text("2024-03-15 09:30:00")])
1315            .unwrap();
1316        assert_text(&r, "09 am");
1317    }
1318
1319    #[test]
1320    fn test_strftime_all_specifiers_presence() {
1321        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|%%";
1322        let r = StrftimeFunc
1323            .invoke(&[text(fmt), text("2024-03-15 14:30:45.123")])
1324            .unwrap();
1325
1326        let s = match r {
1327            SqliteValue::Text(v) => v,
1328            other => panic!("expected Text, got {other:?}"),
1329        };
1330        let parts: Vec<&str> = s.split('|').collect();
1331        assert_eq!(parts.len(), 25, "unexpected specifier output: {s}");
1332        assert_eq!(parts[0], "15"); // %d
1333        assert_eq!(parts[1], "15"); // %e
1334        assert_eq!(parts[2], "45.123"); // %f
1335        assert_eq!(parts[3], "14"); // %H
1336        assert_eq!(parts[4], "02"); // %I
1337        assert_eq!(parts[5], "075"); // %j
1338        assert!(
1339            parts[6].parse::<f64>().is_ok(),
1340            "expected numeric %J output, got {}",
1341            parts[6]
1342        );
1343        assert_eq!(parts[7], "14"); // %k
1344        assert_eq!(parts[8], " 2"); // %l
1345        assert_eq!(parts[9], "03"); // %m
1346        assert_eq!(parts[10], "30"); // %M
1347        assert_eq!(parts[11], "PM"); // %p
1348        assert_eq!(parts[12], "pm"); // %P
1349        assert_eq!(parts[13], "14:30"); // %R
1350        assert!(
1351            parts[14].parse::<i64>().is_ok(),
1352            "expected numeric %s output, got {}",
1353            parts[14]
1354        );
1355        assert_eq!(parts[15], "45"); // %S
1356        assert_eq!(parts[16], "14:30:45"); // %T
1357        assert_eq!(parts[17], "5"); // %u
1358        assert_eq!(parts[18], "5"); // %w
1359        assert_eq!(parts[19], "11"); // %W
1360        assert_eq!(parts[20], "2024"); // %G
1361        assert_eq!(parts[21], "24"); // %g
1362        assert_eq!(parts[22], "11"); // %V
1363        assert_eq!(parts[23], "2024"); // %Y
1364        assert_eq!(parts[24], "%"); // %%
1365    }
1366
1367    #[test]
1368    fn test_strftime_null() {
1369        assert_eq!(
1370            StrftimeFunc.invoke(&[null(), text("2024-01-01")]).unwrap(),
1371            SqliteValue::Null
1372        );
1373        assert_eq!(
1374            StrftimeFunc.invoke(&[text("%Y"), null()]).unwrap(),
1375            SqliteValue::Null
1376        );
1377    }
1378
1379    // ── timediff ──────────────────────────────────────────────────────
1380
1381    #[test]
1382    fn test_timediff_basic() {
1383        let r = TimediffFunc
1384            .invoke(&[text("2024-03-15"), text("2024-03-10")])
1385            .unwrap();
1386        assert_text(&r, "+0000-00-05 00:00:00.000");
1387    }
1388
1389    #[test]
1390    fn test_timediff_negative() {
1391        let r = TimediffFunc
1392            .invoke(&[text("2024-03-10"), text("2024-03-15")])
1393            .unwrap();
1394        assert_text(&r, "-0000-00-05 00:00:00.000");
1395    }
1396
1397    #[test]
1398    fn test_timediff_year_boundary() {
1399        let r = TimediffFunc
1400            .invoke(&[text("2024-01-01 01:00:00"), text("2023-12-31 23:00:00")])
1401            .unwrap();
1402        assert_text(&r, "+0000-00-00 02:00:00.000");
1403    }
1404
1405    // ── Subsec modifier ───────────────────────────────────────────────
1406
1407    #[test]
1408    fn test_modifier_subsec() {
1409        let r = TimeFunc
1410            .invoke(&[text("2024-01-01 12:00:00.123"), text("subsec")])
1411            .unwrap();
1412        match &r {
1413            SqliteValue::Text(s) => assert!(
1414                s.contains('.'),
1415                "expected fractional seconds with subsec: {s}"
1416            ),
1417            other => panic!("expected Text, got {other:?}"),
1418        }
1419    }
1420
1421    // ── Registration ──────────────────────────────────────────────────
1422
1423    #[test]
1424    fn test_register_datetime_builtins_all_present() {
1425        let mut reg = FunctionRegistry::new();
1426        register_datetime_builtins(&mut reg);
1427
1428        let expected = [
1429            "date",
1430            "time",
1431            "datetime",
1432            "julianday",
1433            "unixepoch",
1434            "strftime",
1435            "timediff",
1436        ];
1437
1438        for name in expected {
1439            assert!(
1440                reg.find_scalar(name, 1).is_some() || reg.find_scalar(name, 2).is_some(),
1441                "datetime function '{name}' not registered"
1442            );
1443        }
1444    }
1445
1446    // ── JDN roundtrip ─────────────────────────────────────────────────
1447
1448    #[test]
1449    fn test_modifier_year_overflow() {
1450        // "+9223372036854775807 years" causes i64 overflow in year calculation.
1451        // Should return NULL, not panic.
1452        let huge = i64::MAX;
1453        let modifier = format!("+{huge} years");
1454        let r = DateFunc.invoke(&[text("2000-01-01"), text(&modifier)]);
1455        // The implementation should catch overflow and return Ok(Null), or at least not panic.
1456        // If it panics, the test harness catches it (but we want to prevent panics).
1457        assert_eq!(r.unwrap(), SqliteValue::Null);
1458    }
1459
1460    #[test]
1461    fn test_jdn_roundtrip() {
1462        // Test that ymd → jdn → ymd roundtrips correctly.
1463        let dates = [
1464            (2024, 3, 15),
1465            (2000, 1, 1),
1466            (1970, 1, 1),
1467            (2024, 2, 29),
1468            (1900, 1, 1),
1469            (2099, 12, 31),
1470        ];
1471        for (y, m, d) in dates {
1472            let jdn = ymd_to_jdn(y, m, d);
1473            let (y2, m2, d2) = jdn_to_ymd(jdn);
1474            assert_eq!(
1475                (y, m, d),
1476                (y2, m2, d2),
1477                "roundtrip failed for {y}-{m}-{d} (JDN={jdn})"
1478            );
1479        }
1480    }
1481
1482    #[test]
1483    fn test_unix_epoch_roundtrip() {
1484        let jdn = ymd_to_jdn(1970, 1, 1);
1485        let unix = jdn_to_unix(jdn);
1486        assert_eq!(unix, 0, "Unix epoch should be 0");
1487
1488        let jdn2 = unix_to_jdn(0.0);
1489        assert!((jdn2 - UNIX_EPOCH_JDN).abs() < 1e-10, "roundtrip failed");
1490    }
1491}