Skip to main content

spg_engine/eval/
format.rs

1//! Canonical PG text representation of typed values + date/time literal
2//! parsing, split out of `eval.rs` (cut 31). The value→text formatters
3//! (`format_date` / `format_timestamp` / `format_timestamptz` /
4//! `format_time` / `format_timetz` / `format_money` / `format_interval`
5//! / `format_numeric` and the array formatters) plus the inverse
6//! `parse_date_literal` / `parse_timestamp_literal` text→value parsers
7//! with their TZ-suffix helpers. `civil_from_days` stays in `eval.rs`
8//! (shared with the date SQL functions there); the calendar-arithmetic
9//! helpers (`add_months_to_civil` / `days_in_month`) stay alongside
10//! `shift_date_by_months` in `eval.rs`.
11
12use alloc::format;
13use alloc::string::{String, ToString};
14use alloc::vec::Vec;
15
16use super::{MONTH_ABBR, MONTH_FULL, civil_from_days};
17
18// ---- v7.39 (GUC knife 3) — render styles ----
19//
20// PG's DateStyle / IntervalStyle / extra_float_digits GUCs change the
21// TEXT of date/timestamp/interval/float output everywhere the out-
22// functions run (wire cells, COPY, ::text casts). The engine caches a
23// parsed `RenderStyle` per session; formatters take it by reference.
24// Every shape below is verified against live PG18 (knife-3 probe).
25
26#[derive(Clone, Copy, PartialEq, Eq, Debug)]
27pub enum DateOrder {
28    Mdy,
29    Dmy,
30    Ymd,
31}
32
33#[derive(Clone, Copy, PartialEq, Eq, Debug)]
34pub enum DateStyleKind {
35    Iso,
36    German,
37    Sql,
38    Postgres,
39}
40
41#[derive(Clone, Copy, PartialEq, Eq, Debug)]
42pub enum IntervalStyleKind {
43    Postgres,
44    SqlStandard,
45    Iso8601,
46    PostgresVerbose,
47}
48
49#[derive(Clone, Copy, PartialEq, Eq, Debug)]
50pub struct RenderStyle {
51    pub date_style: DateStyleKind,
52    pub date_order: DateOrder,
53    pub interval_style: IntervalStyleKind,
54    /// PG default 1: >= 1 means shortest-round-trip output; 0 and
55    /// negative trim to 15+n (float8) / 6+n (float4) significant digits.
56    pub extra_float_digits: i32,
57    /// v7.39 (round 524) — the session asked for `bytea_output =
58    /// 'escape'`. PG's other bytea form: printable bytes as themselves, a
59    /// backslash doubled, everything else three-digit octal. SPG accepted
60    /// the SET and rendered hex regardless, so a client that asked for
61    /// escape got the form it had just said it did not want.
62    pub bytea_escape: bool,
63    /// v7.39 (round 368, M20 P3) — the session is on the MySQL dialect, so
64    /// a binary string (`Value::Bytes`, e.g. a `0x…` literal) renders as
65    /// its raw bytes read latin-1 (`0x41` → 'A', `CONCAT(0x41,'B')` →
66    /// 'AB'), not as PG's `\x…` hex form.
67    pub mysql: bool,
68}
69
70impl Default for RenderStyle {
71    fn default() -> Self {
72        Self {
73            date_style: DateStyleKind::Iso,
74            date_order: DateOrder::Mdy,
75            interval_style: IntervalStyleKind::Postgres,
76            extra_float_digits: 1,
77            bytea_escape: false,
78            mysql: false,
79        }
80    }
81}
82
83/// Day-of-week abbreviation. 1970-01-01 (day 0) was a Thursday.
84fn dow_abbr(days: i32) -> &'static str {
85    const DOW: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
86    DOW[((days.rem_euclid(7)) as usize + 3) % 7]
87}
88
89/// `HH:MM:SS[.frac]` with the 6-digit fraction's trailing zeros
90/// stripped — the intra-day part every timestamp style shares.
91fn hms_from_day_micros(day_micros: i64) -> String {
92    let secs = day_micros / 1_000_000;
93    let frac = day_micros % 1_000_000;
94    let hh = secs / 3600;
95    let mm = (secs / 60) % 60;
96    let ss = secs % 60;
97    if frac == 0 {
98        format!("{hh:02}:{mm:02}:{ss:02}")
99    } else {
100        let raw = format!("{frac:06}");
101        let trimmed = raw.trim_end_matches('0');
102        format!("{hh:02}:{mm:02}:{ss:02}.{trimmed}")
103    }
104}
105
106/// `format_date` under a DateStyle. PG18 shapes:
107/// ISO `2024-03-15` · German `15.03.2024` · SQL `03/15/2024` (DMY
108/// `15/03/2024`) · Postgres `03-15-2024` (DMY `15-03-2024`). YMD field
109/// order affects only ISO-irrelevant styles' INPUT; output falls back
110/// to the MDY arrangement (as PG does).
111pub fn format_date_styled(days: i32, style: &RenderStyle) -> String {
112    if days == i32::MAX {
113        return "infinity".into();
114    }
115    if days == i32::MIN {
116        return "-infinity".into();
117    }
118    let (y, m, d) = civil_from_days(days);
119    let (y, bc) = if y <= 0 { (1 - y, " BC") } else { (y, "") };
120    let dmy = style.date_order == DateOrder::Dmy;
121    match style.date_style {
122        DateStyleKind::Iso => format!("{y:04}-{m:02}-{d:02}{bc}"),
123        DateStyleKind::German => format!("{d:02}.{m:02}.{y:04}{bc}"),
124        DateStyleKind::Sql => {
125            if dmy {
126                format!("{d:02}/{m:02}/{y:04}{bc}")
127            } else {
128                format!("{m:02}/{d:02}/{y:04}{bc}")
129            }
130        }
131        DateStyleKind::Postgres => {
132            if dmy {
133                format!("{d:02}-{m:02}-{y:04}{bc}")
134            } else {
135                format!("{m:02}-{d:02}-{y:04}{bc}")
136            }
137        }
138    }
139}
140
141/// `format_timestamp` under a DateStyle. German/SQL prepend their date
142/// form; Postgres style is the asctime-like
143/// `Dow Mon DD HH:MM:SS[.f] YYYY` (DMY: `Dow DD Mon …`).
144pub fn format_timestamp_styled(micros: i64, style: &RenderStyle) -> String {
145    if micros == i64::MAX {
146        return "infinity".into();
147    }
148    if micros == i64::MIN {
149        return "-infinity".into();
150    }
151    if style.date_style == DateStyleKind::Iso {
152        return format_timestamp(micros);
153    }
154    const MICROS_PER_DAY: i64 = 86_400_000_000;
155    let days = micros.div_euclid(MICROS_PER_DAY);
156    let day_micros = micros.rem_euclid(MICROS_PER_DAY);
157    let day_i32 = i32::try_from(days).unwrap_or(i32::MAX);
158    let hms = hms_from_day_micros(day_micros);
159    match style.date_style {
160        DateStyleKind::Iso => unreachable!("handled above"),
161        DateStyleKind::German | DateStyleKind::Sql => {
162            // format_date_styled carries the BC suffix on the date part;
163            // move it after the time to match PG.
164            let d = format_date_styled(day_i32, style);
165            match d.strip_suffix(" BC") {
166                Some(base) => format!("{base} {hms} BC"),
167                None => format!("{d} {hms}"),
168            }
169        }
170        DateStyleKind::Postgres => {
171            let (y, m, d) = civil_from_days(day_i32);
172            let (y, bc) = if y <= 0 { (1 - y, " BC") } else { (y, "") };
173            let mon = MONTH_ABBR[(m as usize).saturating_sub(1).min(11)];
174            let dow = dow_abbr(day_i32);
175            if style.date_order == DateOrder::Dmy {
176                format!("{dow} {d} {mon} {hms} {y:04}{bc}")
177            } else {
178                format!("{dow} {mon} {d} {hms} {y:04}{bc}")
179            }
180        }
181    }
182}
183
184/// `format_timestamptz` under a DateStyle for the UTC session: ISO
185/// keeps the `+00` offset suffix; the other styles append the zone
186/// NAME (` UTC`), as PG does.
187pub fn format_timestamptz_styled(micros: i64, style: &RenderStyle) -> String {
188    format_timestamptz_tz(micros, style, 0, None)
189}
190
191/// v7.39 (tz epic) — the session-timezone-aware renderer: shift by the
192/// per-value offset; ISO appends the numeric offset (`+09`, `+05:45`),
193/// the other DateStyles append the zone designation — a named zone's
194/// abbreviation (`JST`, `EDT`), a fixed offset's numeric form, or
195/// `UTC` (all PG18-differential).
196pub fn format_timestamptz_tz(
197    micros: i64,
198    style: &RenderStyle,
199    offset_micros: i64,
200    abbr: Option<&str>,
201) -> String {
202    if style.date_style == DateStyleKind::Iso {
203        return format_timestamptz_at(micros, offset_micros);
204    }
205    if micros == i64::MAX || micros == i64::MIN {
206        return format_timestamp(micros);
207    }
208    let body = format_timestamp_styled(micros + offset_micros, style);
209    match abbr {
210        Some(a) => format!("{body} {a}"),
211        None if offset_micros == 0 => format!("{body} UTC"),
212        None => {
213            let total_min = (offset_micros / 60_000_000).abs();
214            let (h, m) = (total_min / 60, total_min % 60);
215            let sign = if offset_micros < 0 { '-' } else { '+' };
216            if m == 0 {
217                format!("{body} {sign}{h:02}")
218            } else {
219                format!("{body} {sign}{h:02}:{m:02}")
220            }
221        }
222    }
223}
224
225/// `H:MM:SS[.frac]` — the sql_standard time body (hour unpadded),
226/// over non-negative micros.
227fn sql_std_time(abs_us: i64) -> String {
228    let secs = abs_us / 1_000_000;
229    let frac = abs_us % 1_000_000;
230    let h = secs / 3600;
231    let mm = (secs / 60) % 60;
232    let ss = secs % 60;
233    if frac == 0 {
234        format!("{h}:{mm:02}:{ss:02}")
235    } else {
236        let raw = format!("{frac:06}");
237        let trimmed = raw.trim_end_matches('0');
238        format!("{h}:{mm:02}:{ss:02}.{trimmed}")
239    }
240}
241
242/// `SS[.frac]` seconds body for iso_8601 / verbose fields.
243fn secs_body(abs_us: i64) -> String {
244    let ss = abs_us / 1_000_000;
245    let frac = abs_us % 1_000_000;
246    if frac == 0 {
247        format!("{ss}")
248    } else {
249        let raw = format!("{frac:06}");
250        let trimmed = raw.trim_end_matches('0');
251        format!("{ss}.{trimmed}")
252    }
253}
254
255/// `format_interval` under an IntervalStyle (PG18 differential truth):
256///
257/// - sql_standard — pure year-month `1-2` / `-1-2`; pure day-time
258///   `1 0:00:00` (sign on the leading field, time fields absolute;
259///   time-only `2:00:00` / `-0:00:01`); a mix of year-month and
260///   day-time classes (or mixed signs) is non-conforming and prints
261///   every part explicitly signed: `+1-2 +3 +4:05:06`, `+0-1 -1
262///   +0:00:00`; zero → `0`.
263/// - iso_8601 — `P[nY][nM][nD][T[nH][nM][nS]]`, each field
264///   individually signed, zero → `PT0S`.
265/// - postgres_verbose — `@ ` + `years/mons/days/hours/mins/secs`
266///   fields; when the interval compares below zero every field is
267///   negated and ` ago` is appended; zero → `@ 0`.
268/// Render an interval that may be infinite.
269///
270/// v7.38.19 — PostgreSQL 18.4 renders the two infinities as the words
271/// `infinity` and `-infinity`, in every interval style, and reads them
272/// back (`'infinity'::text::interval` round-trips). `'inf'` is NOT
273/// accepted, which is the one place interval differs from float here.
274#[must_use]
275pub fn format_interval_kinded(
276    months: i32,
277    days: i32,
278    micros: i64,
279    kind: spg_storage::IntervalKind,
280) -> String {
281    match kind {
282        spg_storage::IntervalKind::PosInf => alloc::string::String::from("infinity"),
283        spg_storage::IntervalKind::NegInf => alloc::string::String::from("-infinity"),
284        spg_storage::IntervalKind::Finite => format_interval(months, days, micros),
285    }
286}
287
288pub fn format_interval_styled(months: i32, days: i32, micros: i64, style: &RenderStyle) -> String {
289    match style.interval_style {
290        IntervalStyleKind::Postgres => format_interval(months, days, micros),
291        IntervalStyleKind::SqlStandard => {
292            let has_ym = months != 0;
293            let has_dt = days != 0 || micros != 0;
294            if !has_ym && !has_dt {
295                return "0".into();
296            }
297            let y = months / 12;
298            let mo = (months % 12).abs();
299            // Sign coherence: every nonzero class must agree for the
300            // conforming shapes; a year-month + day-time mix is always
301            // the signed non-conforming shape.
302            let signs: Vec<i8> = [i64::from(months), i64::from(days), micros]
303                .iter()
304                .filter(|v| **v != 0)
305                .map(|v| if *v < 0 { -1i8 } else { 1 })
306                .collect();
307            let coherent = signs.windows(2).all(|w| w[0] == w[1]);
308            if has_ym && !has_dt && coherent {
309                return format!("{y}-{mo}");
310            }
311            if !has_ym && coherent {
312                let neg = days < 0 || micros < 0;
313                let time = sql_std_time(micros.abs());
314                if days == 0 {
315                    return format!("{}{time}", if neg { "-" } else { "" });
316                }
317                return format!("{days} {time}");
318            }
319            // Non-conforming: every part explicitly signed, absolute
320            // field bodies.
321            let sgn = |neg: bool| if neg { '-' } else { '+' };
322            format!(
323                "{}{}-{} {}{} {}{}",
324                sgn(months < 0),
325                y.abs(),
326                mo,
327                sgn(days < 0),
328                days.abs(),
329                sgn(micros < 0),
330                sql_std_time(micros.abs())
331            )
332        }
333        IntervalStyleKind::Iso8601 => {
334            if months == 0 && days == 0 && micros == 0 {
335                return "PT0S".into();
336            }
337            let y = months / 12;
338            let mo = months % 12;
339            let mut out = String::from("P");
340            if y != 0 {
341                out.push_str(&format!("{y}Y"));
342            }
343            if mo != 0 {
344                out.push_str(&format!("{mo}M"));
345            }
346            if days != 0 {
347                out.push_str(&format!("{days}D"));
348            }
349            if micros != 0 {
350                out.push('T');
351                let neg = micros < 0;
352                let abs = micros.abs();
353                let h = abs / 3_600_000_000;
354                let m = (abs / 60_000_000) % 60;
355                let s_us = abs % 60_000_000;
356                let sgn = if neg { "-" } else { "" };
357                if h != 0 {
358                    out.push_str(&format!("{sgn}{h}H"));
359                }
360                if m != 0 {
361                    out.push_str(&format!("{sgn}{m}M"));
362                }
363                if s_us != 0 {
364                    out.push_str(&format!("{sgn}{}S", secs_body(s_us)));
365                }
366            }
367            out
368        }
369        IntervalStyleKind::PostgresVerbose => {
370            if months == 0 && days == 0 && micros == 0 {
371                return "@ 0".into();
372            }
373            // PG's comparison convention (months at 30 days) decides
374            // the overall sign; a negative interval prints its fields
375            // negated with a trailing ` ago`.
376            let total = i128::from(months) * 30 * 86_400_000_000
377                + i128::from(days) * 86_400_000_000
378                + i128::from(micros);
379            let ago = total < 0;
380            let (months, days, micros) = if ago {
381                (-months, -days, -micros)
382            } else {
383                (months, days, micros)
384            };
385            let y = months / 12;
386            let mo = months % 12;
387            let neg_t = micros < 0;
388            let abs = micros.abs();
389            let h = abs / 3_600_000_000;
390            let m = (abs / 60_000_000) % 60;
391            let s_us = abs % 60_000_000;
392            let mut parts: Vec<String> = Vec::new();
393            let unit = |n: i64, singular: &'static str| -> String {
394                if n == 1 {
395                    singular.into()
396                } else {
397                    format!("{singular}s")
398                }
399            };
400            if y != 0 {
401                parts.push(format!("{y} {}", unit(i64::from(y), "year")));
402            }
403            if mo != 0 {
404                parts.push(format!("{mo} {}", unit(i64::from(mo), "mon")));
405            }
406            if days != 0 {
407                parts.push(format!("{days} {}", unit(i64::from(days), "day")));
408            }
409            let tsgn = if neg_t { "-" } else { "" };
410            if h != 0 {
411                parts.push(format!("{tsgn}{h} {}", unit(h, "hour")));
412            }
413            if m != 0 {
414                parts.push(format!("{tsgn}{m} {}", unit(m, "min")));
415            }
416            if s_us != 0 {
417                let body = secs_body(s_us);
418                let plural = body != "1";
419                parts.push(format!(
420                    "{tsgn}{body} {}",
421                    if plural { "secs" } else { "sec" }
422                ));
423            }
424            let mut out = String::from("@ ");
425            out.push_str(&parts.join(" "));
426            if ago {
427                out.push_str(" ago");
428            }
429            out
430        }
431    }
432}
433
434/// Styled array renderers — `{elem,…}` with per-element styled text.
435pub fn format_date_array_styled(items: &[Option<i32>], style: &RenderStyle) -> String {
436    array_styled(items, |d| format_date_styled(*d, style))
437}
438
439pub fn format_timestamp_array_styled(
440    items: &[Option<i64>],
441    with_tz: bool,
442    style: &RenderStyle,
443) -> String {
444    if with_tz {
445        array_styled(items, |t| format_timestamptz_styled(*t, style))
446    } else {
447        array_styled(items, |t| format_timestamp_styled(*t, style))
448    }
449}
450
451pub fn format_interval_array_styled(
452    items: &[Option<spg_storage::IntervalSpan>],
453    style: &RenderStyle,
454) -> String {
455    array_styled(items, |iv| {
456        if iv.kind.is_finite() {
457            format_interval_styled(iv.months, iv.days, iv.micros, style)
458        } else {
459            format_interval_kinded(0, 0, 0, iv.kind)
460        }
461    })
462}
463
464pub fn format_float_array_styled(items: &[Option<f64>], style: &RenderStyle) -> String {
465    array_styled(items, |f| format_float_styled(*f, style))
466}
467
468fn array_styled<T>(items: &[Option<T>], mut f: impl FnMut(&T) -> String) -> String {
469    let mut out = String::with_capacity(2 + items.len() * 12);
470    out.push('{');
471    for (i, item) in items.iter().enumerate() {
472        if i > 0 {
473            out.push(',');
474        }
475        match item {
476            None => out.push_str("NULL"),
477            Some(v) => push_array_element(&mut out, &f(v)),
478        }
479    }
480    out.push('}');
481    out
482}
483
484/// v7.39 (read01 round 73) — PG's array output QUOTES an element whose text
485/// contains a delimiter, a brace, a quote, a backslash or whitespace — so an
486/// interval array reads `{"1 day",02:00:00}`, not `{1 day,02:00:00}`. This lived
487/// only in the text-array renderer; every typed array shared `array_styled`,
488/// which never quoted, because none of the types it rendered had ever produced a
489/// space. `array_agg(interval)` does — and the differential caught it the moment
490/// round 73 gave that aggregate its real element type.
491fn push_array_element(out: &mut String, s: &str) {
492    let needs_quote = s.is_empty()
493        || s.eq_ignore_ascii_case("null")
494        || s.chars()
495            .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\') || c.is_whitespace());
496    if !needs_quote {
497        out.push_str(s);
498        return;
499    }
500    out.push('"');
501    for c in s.chars() {
502        if c == '"' || c == '\\' {
503            out.push('\\');
504        }
505        out.push(c);
506    }
507    out.push('"');
508}
509
510/// C `%.{prec}g` over an f64 — what PG's float8out/float4out emit when
511/// `extra_float_digits <= 0`: `prec` significant digits, fixed-point
512/// when the decimal exponent is in `-4..prec`, else scientific;
513/// trailing zeros trimmed in both shapes; exponent `e±NN`.
514fn format_g(x: f64, prec: usize) -> String {
515    let prec = prec.max(1);
516    // Round to `prec` significant digits via {:.*e} (exact decimal
517    // mantissa; handles the 9.99→10.0 exponent carry).
518    let sci = format!("{:.*e}", prec - 1, x);
519    let epos = sci.find('e').expect("{:e} always has an 'e'");
520    let exp_val: i32 = sci[epos + 1..].parse().unwrap_or(0);
521    let mant = &sci[..epos];
522    if exp_val >= -4 && (exp_val as i64) < prec as i64 {
523        // Fixed-point with prec-1-exp decimals, from the rounded value.
524        let decimals =
525            usize::try_from(i64::try_from(prec).unwrap_or(1) - 1 - i64::from(exp_val)).unwrap_or(0);
526        let rounded: f64 = sci.parse().unwrap_or(x);
527        let fixed = format!("{rounded:.decimals$}");
528        if fixed.contains('.') {
529            let t = fixed.trim_end_matches('0').trim_end_matches('.');
530            t.into()
531        } else {
532            fixed
533        }
534    } else {
535        let mant = if mant.contains('.') {
536            mant.trim_end_matches('0').trim_end_matches('.')
537        } else {
538            mant
539        };
540        let (sign, digits) = if exp_val < 0 {
541            ('-', format!("{}", -exp_val))
542        } else {
543            ('+', format!("{exp_val}"))
544        };
545        format!("{mant}e{sign}{digits:0>2}")
546    }
547}
548
549/// PG `float8out` under `extra_float_digits`: >= 1 → shortest
550/// round-trip (`format_float`); <= 0 → `%.{15+n}g`.
551pub fn format_float_styled(x: f64, style: &RenderStyle) -> String {
552    if style.mysql {
553        return format_float_mysql(x);
554    }
555    if style.extra_float_digits >= 1 {
556        return format_float(x);
557    }
558    if x.is_nan() {
559        return "NaN".into();
560    }
561    if x.is_infinite() {
562        return if x > 0.0 { "Infinity" } else { "-Infinity" }.into();
563    }
564    if x == 0.0 {
565        return if x.is_sign_negative() { "-0" } else { "0" }.into();
566    }
567    let prec = (15 + style.extra_float_digits).clamp(1, 17) as usize;
568    format_g(x, prec)
569}
570
571/// v7.39.2 — MySQL's float rendering, measured on 9.7.2.
572///
573/// Two rules differ from PostgreSQL's, and both were visible on every
574/// non-trivial value: MySQL prints a FLOAT to SIX significant digits
575/// (`3.14159265358979` reads back `3.14159`, where SPG printed the f32
576/// shortest round-trip `3.1415927`), and it stays in FIXED notation for
577/// a much wider band of exponents — `[-15, 14]` for both widths, so
578/// `123456789` reads `123457000` rather than `1.2345679e+08` and `1e15`
579/// reads `1e15` rather than `1000000000000000`.
580///
581/// A DOUBLE's digits already agreed (MySQL is shortest-round-trip there
582/// too: `0.1+0.2` prints all seventeen), so only the window moves.
583///
584/// The exponent form carries no `+` and no zero padding: `1e15`,
585/// `1.23457e15`, `1e-16`, `1.7976931348623157e308`.
586fn format_mysql_float(sci: &str, mant: &str, exp_val: i32) -> String {
587    if (-15..=14).contains(&exp_val) {
588        // Trailing zeros go, in both shapes: the six-digit rounding
589        // leaves them (`0.1` came out `0.100000`, `1` came out
590        // `1.00000`) and MySQL prints neither.
591        let fixed = fixed_from_sci(sci, exp_val);
592        if fixed.contains('.') {
593            return alloc::string::String::from(fixed.trim_end_matches('0').trim_end_matches('.'));
594        }
595        return fixed;
596    }
597    let mant = if mant.contains('.') {
598        mant.trim_end_matches('0').trim_end_matches('.')
599    } else {
600        mant
601    };
602    alloc::format!("{mant}e{exp_val}")
603}
604
605/// Split a `{:e}`-shaped string into its mantissa and exponent.
606fn split_sci(sci: &str) -> (&str, i32) {
607    let epos = sci.find('e').expect("{:e} always has an 'e'");
608    (&sci[..epos], sci[epos + 1..].parse().unwrap_or(0))
609}
610
611/// PG `float4out` under `extra_float_digits`: >= 1 → shortest
612/// round-trip (`format_real`); <= 0 → `%.{6+n}g`.
613///
614/// On a MySQL session neither applies — see `format_mysql_float`.
615pub fn format_real_mysql(x: f32) -> String {
616    if x.is_nan() {
617        return "NaN".into();
618    }
619    if x.is_infinite() {
620        return if x > 0.0 { "Infinity" } else { "-Infinity" }.into();
621    }
622    if x == 0.0 {
623        return if x.is_sign_negative() { "-0" } else { "0" }.into();
624    }
625    // Six significant digits, then MySQL's window.
626    let sci = alloc::format!("{:.*e}", 5, f64::from(x));
627    let (mant, exp_val) = split_sci(&sci);
628    format_mysql_float(&sci, mant, exp_val)
629}
630
631/// The f64 half of the same rule — see `format_real_mysql`.
632pub fn format_float_mysql(x: f64) -> String {
633    if x.is_nan() {
634        return "NaN".into();
635    }
636    if x.is_infinite() {
637        return if x > 0.0 { "Infinity" } else { "-Infinity" }.into();
638    }
639    if x == 0.0 {
640        return if x.is_sign_negative() { "-0" } else { "0" }.into();
641    }
642    // The digits are the shortest round-trip, as MySQL's are; only the
643    // fixed/scientific window differs from PostgreSQL's.
644    let sci = shortest_float_sci(x);
645    let (mant, exp_val) = split_sci(&sci);
646    format_mysql_float(&sci, mant, exp_val)
647}
648
649pub fn format_real_styled(x: f32, style: &RenderStyle) -> String {
650    if style.mysql {
651        return format_real_mysql(x);
652    }
653    if style.extra_float_digits >= 1 {
654        return format_real(x);
655    }
656    if x.is_nan() {
657        return "NaN".into();
658    }
659    if x.is_infinite() {
660        return if x > 0.0 { "Infinity" } else { "-Infinity" }.into();
661    }
662    if x == 0.0 {
663        return if x.is_sign_negative() { "-0" } else { "0" }.into();
664    }
665    let prec = (6 + style.extra_float_digits).clamp(1, 9) as usize;
666    format_g(f64::from(x), prec)
667}
668
669/// Render a `Date` (days since epoch) as `YYYY-MM-DD`. Negative values
670/// for pre-1970 dates render with a leading `-` on the year.
671pub fn format_date(days: i32) -> String {
672    if days == i32::MAX {
673        return "infinity".into();
674    }
675    if days == i32::MIN {
676        return "-infinity".into();
677    }
678    let (y, m, d) = civil_from_days(days);
679    // v7.39 (GUC knife 6, BC) — PG renders astronomical year <= 0 as
680    // the positive year + " BC" (1 BC is astronomical year 0).
681    if y <= 0 {
682        return format!("{:04}-{m:02}-{d:02} BC", 1 - y);
683    }
684    format!("{y:04}-{m:02}-{d:02}")
685}
686
687/// Render a `Timestamp` (microseconds since epoch) as
688/// `YYYY-MM-DD HH:MM:SS[.fff...]`. Trailing-zero fractional digits are
689/// dropped; a whole-second value has no fractional part.
690/// v7.15.0 — PG-canonical TIMESTAMPTZ wire format. Storage is
691/// the same i64 microseconds UTC as TIMESTAMP, but the canonical
692/// PG text output appends the session's UTC-offset suffix (`+00`
693/// for the default UTC session, the form pg_dump emits). Mailrs
694/// round-8 acceptance criterion: `SELECT col FROM tstz` should
695/// round-trip to a literal that re-INSERTs without semantic
696/// drift.
697pub fn format_timestamptz(micros: i64) -> String {
698    format_timestamptz_at(micros, 0)
699}
700
701/// v7.38 (T-tstz Phase 2) — render a UTC instant in a fixed-offset zone: shift
702/// the wall clock by `offset_micros`, then append PG's offset suffix (`+09`,
703/// `-05`, `+05:30`, `+00`). Minutes are shown only when non-zero, matching PG.
704/// UTC (`offset_micros == 0`) reproduces the old `+00` output byte-for-byte.
705pub fn format_timestamptz_at(micros: i64, offset_micros: i64) -> String {
706    if micros == i64::MAX || micros == i64::MIN {
707        return format_timestamp(micros);
708    }
709    let base = format_timestamp(micros + offset_micros);
710    // v7.39 (GUC knife 6, BC) — the offset suffix goes before " BC"
711    // (PG: `0044-03-15 10:20:30+00 BC`).
712    let (base, bc) = match base.strip_suffix(" BC") {
713        Some(b) => (String::from(b), " BC"),
714        None => (base, ""),
715    };
716    let mut s = String::with_capacity(base.len() + 9);
717    s.push_str(&base);
718    let total_min = (offset_micros / 60_000_000).abs();
719    let (h, m) = (total_min / 60, total_min % 60);
720    s.push(if offset_micros < 0 { '-' } else { '+' });
721    s.push_str(&alloc::format!("{h:02}"));
722    if m != 0 {
723        s.push(':');
724        s.push_str(&alloc::format!("{m:02}"));
725    }
726    s.push_str(bc);
727    s
728}
729
730/// v7.17.0 Phase 3.P0-35 — PG `money` canonical text form, en_US
731/// PG `float8out` — the shortest round-trip decimal, rendered in
732/// scientific notation when the base-10 exponent is `< -4` or `> 14`
733/// (matching float.c's choice), otherwise fixed. Learned from read01
734/// float.c study: PG switches to `1e+15` / `1e-05` where SPG used to
735/// spell every digit (`1000000000000000000000000000000`). The exponent
736/// is read from Rust's `{:e}` (exact — avoids log10 rounding at powers
737/// of ten) and reformatted to PG's `e±NN` (sign always shown, ≥ 2
738/// digits). Infinities / NaN / signed zero match `float8out` too.
739pub fn format_float(x: f64) -> String {
740    if x.is_nan() {
741        return "NaN".into();
742    }
743    if x.is_infinite() {
744        return if x > 0.0 { "Infinity" } else { "-Infinity" }.into();
745    }
746    if x == 0.0 {
747        return if x.is_sign_negative() { "-0" } else { "0" }.into();
748    }
749    let sci = shortest_float_sci(x); // e.g. "1.234e15", "1e-5", "-2.5e-10"
750    let epos = sci.find('e').expect("{:e} always has an 'e'");
751    let exp_val: i32 = sci[epos + 1..].parse().unwrap_or(0);
752    if (-4..=14).contains(&exp_val) {
753        // Fixed-point rendering of the SAME digits the strict test
754        // chose — `{x}` would re-derive them under Rust's rule.
755        return fixed_from_sci(&sci, exp_val);
756    }
757    let mant = &sci[..epos];
758    let exp = &sci[epos + 1..];
759    let (sign, digits) = match exp.strip_prefix('-') {
760        Some(d) => ('-', d),
761        None => ('+', exp),
762    };
763    alloc::format!("{mant}e{sign}{digits:0>2}")
764}
765
766/// v7.39 (round 270) — the shortest decimal for an f32 in PG's sense,
767/// as `{:e}` style ("1.5000001e10").
768///
769/// Rust's `{:e}` gives the shortest decimal that ROUND-TRIPS. PG wants
770/// the shortest that lies STRICTLY INSIDE the value's rounding
771/// interval. The two differ exactly on the values whose short form sits
772/// on a half-ulp boundary: ties-to-even parses that boundary back to
773/// the same float, so Rust accepts it, while PG does not. Measured on
774/// PG 18.4: `15000000512::real` prints 1.5000001e+10, not 1.5e+10 —
775/// 1.5e10 is exactly half an ulp below the value. The same rule shows
776/// up in float8 (`1e23` prints 9.999999999999999e+22).
777///
778/// The boundary test is exact here because an f32 and its neighbour
779/// both widen losslessly into f64 and their midpoint needs only 25
780/// bits, so the midpoint is an f64 value that a <= 9-digit decimal
781/// parses to exactly when — and only when — it IS that midpoint.
782fn shortest_real_sci(x: f32) -> String {
783    let wide = f64::from(x);
784    let below = f64::from(next_f32(x, false));
785    let above = f64::from(next_f32(x, true));
786    // Midpoints to either neighbour; these are exact in f64.
787    let lo = (wide + below) / 2.0;
788    let hi = (wide + above) / 2.0;
789    for p in 1..=9u32 {
790        let cand = alloc::format!("{x:.*e}", (p - 1) as usize);
791        let Ok(v) = cand.parse::<f64>() else { continue };
792        // Round-trips as an f32 AND is not sitting on either boundary.
793        #[allow(clippy::cast_possible_truncation)]
794        if v as f32 == x && v != lo && v != hi {
795            return cand;
796        }
797    }
798    alloc::format!("{x:e}")
799}
800
801/// The adjacent f32 toward +inf (`up`) or -inf. Only ever called on a
802/// finite non-zero value.
803fn next_f32(x: f32, up: bool) -> f32 {
804    let bits = x.to_bits();
805    let stepped = if (x > 0.0) == up { bits + 1 } else { bits - 1 };
806    f32::from_bits(stepped)
807}
808
809/// Re-render a `{:e}`-style string in fixed-point notation.
810fn fixed_from_sci(sci: &str, exp: i32) -> String {
811    let epos = sci.find('e').expect("{:e} always has an 'e'");
812    let (mant, _) = sci.split_at(epos);
813    let (sign, mant) = match mant.strip_prefix('-') {
814        Some(m) => ("-", m),
815        None => ("", mant),
816    };
817    let digits: String = mant.chars().filter(char::is_ascii_digit).collect();
818    let point = exp + 1; // digits before the decimal point
819    let mut out = String::from(sign);
820    if point <= 0 {
821        out.push_str("0.");
822        for _ in 0..-point {
823            out.push('0');
824        }
825        out.push_str(&digits);
826    } else if (point as usize) >= digits.len() {
827        out.push_str(&digits);
828        for _ in 0..(point as usize - digits.len()) {
829            out.push('0');
830        }
831    } else {
832        out.push_str(&digits[..point as usize]);
833        out.push('.');
834        out.push_str(&digits[point as usize..]);
835    }
836    out
837}
838
839/// v7.38 (read01, T-float4) — PG `float4out`: the f32 shortest round-trip, in
840/// fixed-point for decimal exponents in `-4..=5` and scientific otherwise
841/// (a tighter window than float8's `-4..=14`, so `12345678::real` =
842/// `1.2345678e+07` while `12345678::float8` stays `12345678`).
843pub fn format_real(x: f32) -> String {
844    if x.is_nan() {
845        return "NaN".into();
846    }
847    if x.is_infinite() {
848        return if x > 0.0 { "Infinity" } else { "-Infinity" }.into();
849    }
850    if x == 0.0 {
851        return if x.is_sign_negative() { "-0" } else { "0" }.into();
852    }
853    let sci = shortest_real_sci(x);
854    let epos = sci.find('e').expect("{:e} always has an 'e'");
855    let exp_val: i32 = sci[epos + 1..].parse().unwrap_or(0);
856    if (-4..=5).contains(&exp_val) {
857        // Re-expand the (possibly longer than Rust's) mantissa in
858        // fixed-point rather than falling back to `{x}`, which would
859        // reintroduce Rust's choice of digits.
860        return fixed_from_sci(&sci, exp_val);
861    }
862    let mant = &sci[..epos];
863    let exp = &sci[epos + 1..];
864    let (sign, digits) = match exp.strip_prefix('-') {
865        Some(d) => ('-', d),
866        None => ('+', exp),
867    };
868    alloc::format!("{mant}e{sign}{digits:0>2}")
869}
870
871/// locale: `$N,NNN.CC`, negative → `-$1.23`. Mirrors PG's
872/// `cash_out` for `lc_monetary = 'en_US.UTF-8'`.
873pub fn format_money(cents: i64) -> String {
874    let neg = cents < 0;
875    let abs = cents.unsigned_abs();
876    let dollars = abs / 100;
877    let cc = abs % 100;
878    // Insert comma thousands separators in the integer portion.
879    let dollar_str = dollars.to_string();
880    let bytes = dollar_str.as_bytes();
881    let mut int_part = String::with_capacity(dollar_str.len() + dollar_str.len() / 3);
882    for (i, b) in bytes.iter().enumerate() {
883        // Position from the right: insert ',' before every 3rd
884        // digit (except the first).
885        let from_right = bytes.len() - i;
886        if i > 0 && from_right % 3 == 0 {
887            int_part.push(',');
888        }
889        int_part.push(*b as char);
890    }
891    let sign = if neg { "-" } else { "" };
892    format!("{sign}${int_part}.{cc:02}")
893}
894
895/// v7.17.0 Phase 3.P0-34 — PG `TIMETZ` canonical text form
896/// `HH:MM:SS[.ffffff]±HH[:MM]`. Mirrors PG `timetz_out`. The
897/// offset uses `±HH` for whole-hour offsets and `±HH:MM` for
898/// sub-hour offsets (matching PG's "minimal display" rule).
899pub fn format_timetz(us: i64, offset_secs: i32) -> String {
900    let time = format_time(us);
901    let sign = if offset_secs < 0 { '-' } else { '+' };
902    let abs = offset_secs.unsigned_abs();
903    let oh = abs / 3600;
904    let om = (abs % 3600) / 60;
905    if om == 0 {
906        format!("{time}{sign}{oh:02}")
907    } else {
908        format!("{time}{sign}{oh:02}:{om:02}")
909    }
910}
911
912/// v7.17.0 Phase 3.P0-32 — PG `TIME` canonical text form
913/// `HH:MM:SS[.ffffff]`. Mirrors PG `time_out`. Trailing zeros in
914/// the fractional component are stripped — `12:00:00.500000`
915/// renders as `12:00:00.5` to match PG's text output.
916pub fn format_time(us: i64) -> String {
917    let total_secs = us.div_euclid(1_000_000);
918    let frac = us.rem_euclid(1_000_000);
919    let hh = total_secs / 3600;
920    let mm = (total_secs / 60) % 60;
921    let ss = total_secs % 60;
922    if frac == 0 {
923        format!("{hh:02}:{mm:02}:{ss:02}")
924    } else {
925        let raw = format!("{frac:06}");
926        let trimmed = raw.trim_end_matches('0');
927        format!("{hh:02}:{mm:02}:{ss:02}.{trimmed}")
928    }
929}
930
931pub fn format_timestamp(micros: i64) -> String {
932    // PG infinity sentinels.
933    if micros == i64::MAX {
934        return "infinity".into();
935    }
936    if micros == i64::MIN {
937        return "-infinity".into();
938    }
939    const MICROS_PER_DAY: i64 = 86_400_000_000;
940    // Split into day + intra-day part with proper floor division so
941    // negative timestamps render right too.
942    let days = micros.div_euclid(MICROS_PER_DAY);
943    let day_micros = micros.rem_euclid(MICROS_PER_DAY);
944    let day_i32 = i32::try_from(days).unwrap_or(i32::MAX);
945    let (y, m, d) = civil_from_days(day_i32);
946    // v7.39 (GUC knife 6, BC) — " BC" trails the TIME part in PG.
947    let (y, bc) = if y <= 0 { (1 - y, " BC") } else { (y, "") };
948    let secs = day_micros / 1_000_000;
949    let frac = day_micros % 1_000_000;
950    let hh = secs / 3600;
951    let mm = (secs / 60) % 60;
952    let ss = secs % 60;
953    if frac == 0 {
954        format!("{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}:{ss:02}{bc}")
955    } else {
956        // Strip trailing zeros from the 6-digit fractional component.
957        let raw = format!("{frac:06}");
958        let trimmed = raw.trim_end_matches('0');
959        format!("{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}:{ss:02}.{trimmed}{bc}")
960    }
961}
962
963/// Inverse of `civil_from_days` — converts (year, month, day) to days
964/// since 1970-01-01. Out-of-range months / days saturate.
965#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
966pub fn days_from_civil(y: i32, m: u32, d: u32) -> i32 {
967    let y_adj = if m <= 2 {
968        i64::from(y) - 1
969    } else {
970        i64::from(y)
971    };
972    let era = y_adj.div_euclid(400);
973    let yoe = (y_adj - era * 400) as u32;
974    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d.saturating_sub(1);
975    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
976    let total = era * 146_097 + i64::from(doe) - 719_468;
977    i32::try_from(total).unwrap_or(i32::MAX)
978}
979
980/// Parse `YYYY-MM-DD` into a `Date` (days since Unix epoch). Returns
981/// `None` on shape / numeric failure; the engine surfaces that as a
982/// `TypeMismatch` with the original text included.
983pub fn parse_date_literal(s: &str) -> Option<i32> {
984    parse_date_literal_ordered(s, DateOrder::Mdy)
985}
986
987/// v7.39 (GUC knife 5) — DateOrder-aware date input. PG disambiguates
988/// a three-field numeric date (`01/02/2024`, `02.01.2024`, `1/2/24`)
989/// by the DateStyle field order: MDY reads month first (the default —
990/// so `'01/02/2024'` is Jan 2 even with no SET), DMY day first, YMD
991/// year first (`'24/01/02'`, and `'1/2/24'` is 2001-02-24!). A
992/// two-digit year is < 70 → 20xx, >= 70 → 19xx. Field values that
993/// don't fit the order error (MDY `'13/02/2024'` is out of range —
994/// PG does NOT auto-swap). ISO year-first four-digit forms parse the
995/// same under every order.
996pub fn parse_date_literal_ordered(s: &str, order: DateOrder) -> Option<i32> {
997    let s = s.trim();
998    // v7.39 (GUC knife 6, BC) — a trailing era marker: `NNNN-MM-DD BC`
999    // maps year N to astronomical year 1-N (there is no year zero);
1000    // an explicit AD is accepted and is the default.
1001    if let Some(base) = s
1002        .strip_suffix(" BC")
1003        .or_else(|| s.strip_suffix(" bc"))
1004        .or_else(|| s.strip_suffix(" Bc"))
1005    {
1006        let days = parse_date_literal_ordered(base, order)?;
1007        let (y, m, d) = civil_from_days(days);
1008        if y < 1 {
1009            return None;
1010        }
1011        return Some(days_from_civil(1 - y, m, d));
1012    }
1013    if let Some(base) = s.strip_suffix(" AD").or_else(|| s.strip_suffix(" ad")) {
1014        return parse_date_literal_ordered(base, order);
1015    }
1016    // PG special date values.
1017    if s.eq_ignore_ascii_case("epoch") {
1018        return Some(days_from_civil(1970, 1, 1));
1019    }
1020    if s.eq_ignore_ascii_case("infinity") || s.eq_ignore_ascii_case("+infinity") {
1021        return Some(i32::MAX);
1022    }
1023    if s.eq_ignore_ascii_case("-infinity") {
1024        return Some(i32::MIN);
1025    }
1026    let bytes = s.as_bytes();
1027    // ISO 8601 basic (compact) form `YYYYMMDD` — no separators.
1028    if bytes.len() == 8 && bytes.iter().all(u8::is_ascii_digit) {
1029        let y: i32 = s[0..4].parse().ok()?;
1030        let m: u32 = s[4..6].parse().ok()?;
1031        let d: u32 = s[6..8].parse().ok()?;
1032        if !(1..=12).contains(&m) || d < 1 || d > super::days_in_month(y, m) {
1033            return None;
1034        }
1035        return Some(days_from_civil(y, m, d));
1036    }
1037    // v7.38 (read01) — month-name forms in any of PG's field orders
1038    // (`Jan 5, 2020`, `5 Jan 2020`, `2020-Jan-05`, `5-Jan-2020`), case
1039    // insensitive. Try this before the numeric split so a `Mon-D-Y` dashed
1040    // form isn't mistaken for a numeric field.
1041    if s.bytes().any(|b| b.is_ascii_alphabetic()) {
1042        // v7.39 (read01 utils/adt, datetime.c) — 'J2451545' is a Julian
1043        // day number (JD of the Unix epoch is 2440588).
1044        if let Some(jd) = s.strip_prefix(['J', 'j'])
1045            && !jd.is_empty()
1046            && jd.bytes().all(|b| b.is_ascii_digit())
1047        {
1048            let jd: i64 = jd.parse().ok()?;
1049            return i32::try_from(jd - 2_440_588).ok();
1050        }
1051        return parse_month_name_date(s, order);
1052    }
1053    // v7.38 (read01) — year-first numeric form with `-`, `/` or `.` separators
1054    // and non-zero-padded month/day (`2020-1-5`, `2020/01/5`, `2020.1.05`), all
1055    // of which PG accepts. Requires exactly three all-digit fields, the first
1056    // being the 4-digit year, so it stays unambiguous (no MDY/DMY guessing).
1057    // v7.39 (read01 utils/adt, datetime.c) — the day-of-year form:
1058    // YEAR + exactly-three-digit ordinal ('2024-060' = Feb 29 2024).
1059    {
1060        let mut two = s.splitn(2, ['-', '/', '.']);
1061        if let (Some(ya), Some(dd)) = (two.next(), two.next())
1062            && ya.len() >= 3
1063            && dd.len() == 3
1064            && !dd.contains(['-', '/', '.', ' '])
1065            && ya.bytes().all(|b| b.is_ascii_digit())
1066            && dd.bytes().all(|b| b.is_ascii_digit())
1067        {
1068            let y: i32 = ya.parse().ok()?;
1069            let doy: i64 = dd.parse().ok()?;
1070            if y != 0 && (1..=366).contains(&doy) {
1071                let jan1 = days_from_civil(y, 1, 1);
1072                let days = jan1 + i32::try_from(doy).ok()? - 1;
1073                let (yy, _, _) = civil_from_days(days);
1074                if yy == y {
1075                    return Some(days);
1076                }
1077                return None; // 366 in a non-leap year
1078            }
1079        }
1080    }
1081    let mut parts = s.splitn(3, ['-', '/', '.']);
1082    let (fa, fb, fc) = (parts.next()?, parts.next()?, parts.next()?);
1083    if fc.contains(['-', '/', '.', ' ']) {
1084        return None; // trailing separator / extra field / garbage
1085    }
1086    if [fa, fb, fc]
1087        .iter()
1088        .any(|p| p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()))
1089    {
1090        return None;
1091    }
1092    // Year-first: a 3-or-more-digit first field is unambiguously the
1093    // year regardless of DateOrder (PG DecodeNumber's flen >= 3 rule:
1094    // `2020-1-5`, `123-4-5` = 0123-04-05).
1095    if fa.len() >= 3 && fb.len() <= 2 && fc.len() <= 2 {
1096        let y: i32 = fa.parse().ok()?;
1097        // No year zero in the Gregorian era notation (PG: out of range).
1098        if y == 0 {
1099            return None;
1100        }
1101        // DOY form: exactly-three-digit second field after a year with
1102        // no third field is handled below (two-field split); with three
1103        // fields this is plain Y-M-D.
1104        let m: u32 = fb.parse().ok()?;
1105        let d: u32 = fc.parse().ok()?;
1106        // PG validates the day against the actual (leap-aware) month
1107        // length: `'2024-02-30'` raises "date/time field value out of
1108        // range" rather than rolling forward into March.
1109        if !(1..=12).contains(&m) || d < 1 || d > super::days_in_month(y, m) {
1110            return None;
1111        }
1112        return Some(days_from_civil(y, m, d));
1113    }
1114    // Order-disambiguated short forms.
1115    let expand_year = |t: &str| -> Option<i32> {
1116        match t.len() {
1117            4 => t.parse().ok(),
1118            // PG's two-digit-year window.
1119            1 | 2 => {
1120                let n: i32 = t.parse().ok()?;
1121                Some(if n < 70 { 2000 + n } else { 1900 + n })
1122            }
1123            _ => None,
1124        }
1125    };
1126    let (ys, ms, ds) = match order {
1127        DateOrder::Mdy => (fc, fa, fb),
1128        DateOrder::Dmy => (fc, fb, fa),
1129        DateOrder::Ymd => (fa, fb, fc),
1130    };
1131    if ms.len() > 2 || ds.len() > 2 {
1132        return None;
1133    }
1134    let y = expand_year(ys)?;
1135    let m: u32 = ms.parse().ok()?;
1136    let d: u32 = ds.parse().ok()?;
1137    if !(1..=12).contains(&m) || d < 1 || d > super::days_in_month(y, m) {
1138        return None;
1139    }
1140    Some(days_from_civil(y, m, d))
1141}
1142
1143/// v7.38 (read01) — resolve a month-name date in any of PG's field orders.
1144/// Tokenises on space / comma / dash, then classifies exactly one month name,
1145/// one 4-digit year, and one 1–2 digit day, in any order. Case insensitive;
1146/// both `Jan` and `January` spellings. Leap-aware day validation, like the
1147/// numeric path. Returns `None` for anything ambiguous or malformed so the
1148/// caller raises the same "invalid input" / "out of range" errors as PG.
1149fn parse_month_name_date(s: &str, order: DateOrder) -> Option<i32> {
1150    let tokens: alloc::vec::Vec<&str> =
1151        s.split([' ', ',', '-']).filter(|t| !t.is_empty()).collect();
1152    if tokens.len() != 3 {
1153        return None;
1154    }
1155    let month_of = |t: &str| -> Option<u32> {
1156        let up = t.to_ascii_uppercase();
1157        MONTH_ABBR
1158            .iter()
1159            .position(|a| a.eq_ignore_ascii_case(&up))
1160            .or_else(|| MONTH_FULL.iter().position(|f| f.eq_ignore_ascii_case(&up)))
1161            .map(|i| i as u32 + 1)
1162    };
1163    let mut month: Option<u32> = None;
1164    let mut nums: alloc::vec::Vec<&str> = alloc::vec::Vec::new();
1165    for t in tokens {
1166        if let Some(m) = month_of(t) {
1167            if month.replace(m).is_some() {
1168                return None; // two month names
1169            }
1170        } else if t.bytes().all(|b| b.is_ascii_digit()) {
1171            nums.push(t);
1172        } else {
1173            return None; // a non-month alphabetic token (`Foo`)
1174        }
1175    }
1176    let m = month?;
1177    if nums.len() != 2 {
1178        return None;
1179    }
1180    // v7.39 (read01 utils/adt, datetime.c DecodeNumber) — with a text
1181    // month, a 3+-digit numeric field is the YEAR; two short fields
1182    // disambiguate by DateOrder ('Jan-23-24' is day 23 / year 2024
1183    // under MDY/DMY, year 2023 / day 24 under YMD). Two-digit years
1184    // take PG's 1970-2069 window.
1185    let (ys, ds) = match (nums[0].len() >= 3, nums[1].len() >= 3) {
1186        (true, true) => return None,
1187        (true, false) => (nums[0], nums[1]),
1188        (false, true) => (nums[1], nums[0]),
1189        (false, false) => {
1190            if order == DateOrder::Ymd {
1191                (nums[0], nums[1])
1192            } else {
1193                (nums[1], nums[0])
1194            }
1195        }
1196    };
1197    let mut y: i32 = ys.parse().ok()?;
1198    if ys.len() <= 2 {
1199        y += if y < 70 { 2000 } else { 1900 };
1200    }
1201    let d: u32 = ds.parse().ok()?;
1202    if !(1..=12).contains(&m) || d < 1 || d > super::days_in_month(y, m) {
1203        return None;
1204    }
1205    Some(days_from_civil(y, m, d))
1206}
1207
1208/// Parse `YYYY-MM-DD[ HH:MM:SS[.ffffff]]` into a `Timestamp`
1209/// (microseconds since Unix epoch). The time portion is optional;
1210/// missing → midnight. The fractional portion accepts 1–6 digits and
1211/// pads with zeros to microseconds.
1212pub fn parse_timestamp_literal(s: &str) -> Option<i64> {
1213    parse_timestamp_literal_ordered(s, DateOrder::Mdy)
1214}
1215
1216/// v7.39 (GUC knife 5) — true when `s` tokenises as a numeric date shape
1217/// (three digit fields, or the 8-digit compact form) whose FIELDS parse but
1218/// whose values fail the calendar range checks — PG reports these as
1219/// `date/time field value out of range` (with a DateStyle hint), while
1220/// non-date-shaped text gets `invalid input syntax for type date`.
1221pub fn date_text_is_field_shaped(s: &str) -> bool {
1222    let s = s.trim();
1223    let date_part = match s.find([' ', 'T']) {
1224        Some(i) => &s[..i],
1225        None => s,
1226    };
1227    let b = date_part.as_bytes();
1228    if b.len() == 8 && b.iter().all(u8::is_ascii_digit) {
1229        return true;
1230    }
1231    let fields: alloc::vec::Vec<&str> = date_part.split(['-', '/', '.']).collect();
1232    fields.len() == 3
1233        && fields
1234            .iter()
1235            .all(|f| !f.is_empty() && f.len() <= 4 && f.bytes().all(|c| c.is_ascii_digit()))
1236}
1237
1238/// v7.39 (GUC knife 5) — DateOrder-aware timestamp input; the date
1239/// part follows `parse_date_literal_ordered`'s disambiguation.
1240pub fn parse_timestamp_literal_ordered(s: &str, order: DateOrder) -> Option<i64> {
1241    parse_timestamp_literal_tz_ordered(s, order).map(|(us, _)| us)
1242}
1243
1244/// v7.39 (tz epic) — like `parse_timestamp_literal_ordered` but also
1245/// reports whether the literal carried an explicit offset. A naive
1246/// literal's micros are the WALL clock (caller localises against the
1247/// session zone for a timestamptz); an offset-bearing literal's are
1248/// UTC already.
1249pub fn parse_timestamp_literal_tz_ordered(s: &str, order: DateOrder) -> Option<(i64, bool)> {
1250    if let Some(v) = timestamp_sentinel(s) {
1251        return Some((v, true));
1252    }
1253    let (days, day_micros, tz) = parse_timestamp_parts(s, order)?;
1254    let t = i64::from(days)
1255        .checked_mul(86_400_000_000)?
1256        .checked_add(day_micros)?
1257        .checked_sub(tz.unwrap_or(0))?;
1258    Some((t, tz.is_some()))
1259}
1260
1261/// v7.39 (round 289) — the pieces every timestamp-literal reader needs:
1262/// the day number, the LOCAL clock inside that day, and the zone offset
1263/// the literal carried (if any). Split out so the wall-clock reader and
1264/// the UTC reader cannot drift apart — the first attempt duplicated the
1265/// body and promptly lost the `BC` era handling.
1266fn parse_timestamp_parts(s: &str, order: DateOrder) -> Option<(i32, i64, Option<i64>)> {
1267    let trimmed = s.trim();
1268    // PG special timestamp values. `infinity` / `-infinity` use the i64
1269    // sentinels (they compare greater/less than every finite timestamp).
1270    if trimmed.eq_ignore_ascii_case("epoch") {
1271        return Some((0, 0, Some(0)));
1272    }
1273    // The infinity sentinels are whole i64 instants, not a day+offset
1274    // pair, so they cannot ride the parts shape — the two public
1275    // readers below special-case them before calling here.
1276    if trimmed.eq_ignore_ascii_case("infinity")
1277        || trimmed.eq_ignore_ascii_case("+infinity")
1278        || trimmed.eq_ignore_ascii_case("-infinity")
1279    {
1280        return None;
1281    }
1282    // v7.39 (GUC knife 6, BC) — the era marker trails the TIME part
1283    // (`0044-03-15 10:20:30 BC`); strip it and re-map the parsed date.
1284    let (trimmed, era_bc) = match trimmed
1285        .strip_suffix(" BC")
1286        .or_else(|| trimmed.strip_suffix(" bc"))
1287    {
1288        Some(b) => (b.trim_end(), true),
1289        None => (
1290            trimmed
1291                .strip_suffix(" AD")
1292                .or_else(|| trimmed.strip_suffix(" ad"))
1293                .map_or(trimmed, str::trim_end),
1294            false,
1295        ),
1296    };
1297    let (date_part, time_part) = match trimmed.find([' ', 'T']) {
1298        Some(i) => (&trimmed[..i], Some(&trimmed[i + 1..])),
1299        None => (trimmed, None),
1300    };
1301    // v7.39 (round 662) — a date may carry the zone directly, with no time
1302    // between them: PG reads `'2020-01-01+00'::timestamptz` as midnight in
1303    // that zone. SPG had no split point there — the scan above looks for a
1304    // space or a `T` — so the whole string went to the date parser and came
1305    // back `invalid input syntax`. The zone cannot simply be scanned for,
1306    // because an ISO date is full of hyphens; the split is accepted only
1307    // when the prefix parses as a date AND the suffix as a zone.
1308    if time_part.is_none() && parse_date_literal_ordered(date_part, order).is_none() {
1309        if let Some(rest) = date_part.strip_suffix(['Z', 'z']) {
1310            if let Some(d) = parse_date_literal_ordered(rest, order) {
1311                return Some((d, 0, Some(0)));
1312            }
1313        }
1314        // Only `+`. PG REFUSES `'2020-01-01-05'` — measured — because a
1315        // trailing `-05` cannot be told apart from the date's own hyphens,
1316        // and it would rather reject than guess. The first version here
1317        // accepted it and answered `2020-01-01 05:00:00+00`, i.e. invented
1318        // an instant PG declines to name.
1319        for (i, c) in date_part.char_indices().rev() {
1320            if c != '+' {
1321                continue;
1322            }
1323            let (head, tail) = date_part.split_at(i);
1324            let (Some(d), Some(off)) = (
1325                parse_date_literal_ordered(head, order),
1326                parse_tz_offset_suffix(tail, c == '+'),
1327            ) else {
1328                continue;
1329            };
1330            return Some((d, 0, Some(off)));
1331        }
1332    }
1333    let mut days = parse_date_literal_ordered(date_part, order)?;
1334    if era_bc {
1335        let (y, m, d) = civil_from_days(days);
1336        if y < 1 {
1337            return None;
1338        }
1339        days = days_from_civil(1 - y, m, d);
1340    }
1341    let (day_micros, tz_offset) = match time_part {
1342        None => (0, None),
1343        Some(t) => parse_time_of_day_micros_tz(t)?,
1344    };
1345    Some((days, day_micros, tz_offset))
1346}
1347
1348/// v7.39 (round 289) — the WALL-CLOCK value a literal spells, with any
1349/// zone designation ignored.
1350///
1351/// PG drops the zone when the target type has none: `'2020-01-01
1352/// 10:00:00+02'::timestamp` is `10:00:00`, not the `08:00:00` you get by
1353/// converting to UTC. SPG converted, so the cast returned a DIFFERENT
1354/// INSTANT with no error — the silent-wrong shape. It also refused a
1355/// named zone outright, where PG accepts and ignores it.
1356///
1357/// This is the same parse; it just does not apply the offset (the time
1358/// parser already reports the local clock separately) and strips a
1359/// trailing named zone the numeric-offset scanner cannot see.
1360/// A NAMED zone (`… America/New_York`) is not stripped here: PG
1361/// validates it — `'… Bogus/Zone'::timestamp` is `time zone
1362/// "bogus/zone" not recognized`, not a silent drop — and the zone
1363/// database lives on the host, not in this no_std crate. That case
1364/// still errors, which is wrong but LOUD; the numeric-offset case was
1365/// wrong and silent, which is why it is the one fixed here.
1366pub fn parse_timestamp_literal_wall_ordered(s: &str, order: DateOrder) -> Option<i64> {
1367    if let Some(v) = timestamp_sentinel(s) {
1368        return Some(v);
1369    }
1370    // Ignoring the offset is simply not applying it: the parts reader
1371    // already reports the LOCAL clock separately.
1372    let (days, day_micros, _tz) = parse_timestamp_parts(s, order)?;
1373    i64::from(days)
1374        .checked_mul(86_400_000_000)?
1375        .checked_add(day_micros)
1376}
1377
1378/// `epoch` / `±infinity`, which are whole instants rather than a
1379/// date-and-time to assemble.
1380fn timestamp_sentinel(s: &str) -> Option<i64> {
1381    let t = s.trim();
1382    if t.eq_ignore_ascii_case("epoch") {
1383        return Some(0);
1384    }
1385    if t.eq_ignore_ascii_case("infinity") || t.eq_ignore_ascii_case("+infinity") {
1386        return Some(i64::MAX);
1387    }
1388    if t.eq_ignore_ascii_case("-infinity") {
1389        return Some(i64::MIN);
1390    }
1391    None
1392}
1393
1394/// v7.15.0 — Parse `HH:MM:SS[.frac][<tz>]` and return
1395/// `(day_micros, tz_offset_micros)` where `day_micros` is the
1396/// local-clock seconds-of-day in microseconds and
1397/// `tz_offset_micros` is the UTC offset (positive = east of
1398/// UTC, negative = west). Caller subtracts the offset to
1399/// normalise to UTC. PG's recognised TZ shapes after the
1400/// seconds (or frac) part:
1401///   * `+OO[:MM]` / `-OO[:MM]` — numeric offset
1402///   * `+OOMM` / `-OOMM` (no colon, less common but legal)
1403///   * ` UTC` / `UTC` / `Z` — explicit zero offset
1404/// Anything else after the seconds = parse failure (the caller
1405/// v7.39 (round 324, V42) — PG's message for a text literal that will not
1406/// become a date/time value. Two shapes, measured on PG 18.4:
1407///
1408///   * `invalid input syntax for type <t>: "<text>"` — the text is not
1409///     date/time shaped at all (`not-a-date`, `2020`, `2020-01-01 abc`);
1410///   * `date/time field value out of range: "<text>"` — it IS shaped like
1411///     one but a field's value is impossible (`2020-13-01`, `2020-02-30`,
1412///     `2020-01-01 25:00:00`).
1413///
1414/// The second form carries `HINT: Perhaps you need a different "DateStyle"
1415/// setting.` only when the offending field is a month or day outside its
1416/// UNIVERSAL range — the case a different field order could have
1417/// explained. `2020-02-30` (a day that is fine for the field but not for
1418/// that month), a time-of-day overflow and an oversized year get no hint.
1419/// The wire splits the `\nHINT:  ` tail into the ErrorResponse `H` field.
1420#[must_use]
1421pub(crate) fn datetime_input_error_text(text: &str, type_name: &str) -> alloc::string::String {
1422    let (kind, hint) = classify_datetime_input(text);
1423    match kind {
1424        DatetimeInputProblem::Syntax => {
1425            alloc::format!("invalid input syntax for type {type_name}: \"{text}\"")
1426        }
1427        DatetimeInputProblem::OutOfRange => {
1428            let mut m = alloc::format!("date/time field value out of range: \"{text}\"");
1429            if hint {
1430                m.push_str("\nHINT:  Perhaps you need a different \"DateStyle\" setting.");
1431            }
1432            m
1433        }
1434    }
1435}
1436
1437enum DatetimeInputProblem {
1438    Syntax,
1439    OutOfRange,
1440}
1441
1442/// `(problem, wants_datestyle_hint)` for a literal that failed to parse.
1443fn classify_datetime_input(text: &str) -> (DatetimeInputProblem, bool) {
1444    let t = text.trim();
1445    // Any character outside the date/time alphabet means PG never got as
1446    // far as reading a field value.
1447    if t.is_empty()
1448        || !t
1449            .chars()
1450            .all(|c| c.is_ascii_digit() || matches!(c, '-' | ':' | '.' | ' ' | 'T' | '+' | 'Z'))
1451    {
1452        return (DatetimeInputProblem::Syntax, false);
1453    }
1454    // The date part is everything before the first space or `T`.
1455    let date_part = t.split([' ', 'T']).next().unwrap_or("");
1456    let fields: alloc::vec::Vec<&str> = date_part.split('-').collect();
1457    if fields.len() != 3
1458        || fields
1459            .iter()
1460            .any(|f| f.is_empty() || !f.chars().all(|c| c.is_ascii_digit()))
1461    {
1462        return (DatetimeInputProblem::Syntax, false);
1463    }
1464    // Shaped like a date; a month or day outside its universal range is
1465    // the case a different DateStyle could have explained.
1466    let month = fields[1].parse::<u32>().unwrap_or(0);
1467    let day = fields[2].parse::<u32>().unwrap_or(0);
1468    let field_out_of_range = !(1..=12).contains(&month) || !(1..=31).contains(&day);
1469    (DatetimeInputProblem::OutOfRange, field_out_of_range)
1470}
1471
1472/// surfaces as "cannot parse … as TIMESTAMP").
1473fn parse_time_of_day_micros(t: &str) -> Option<(i64, i64)> {
1474    parse_time_of_day_micros_tz(t).map(|(us, tz)| (us, tz.unwrap_or(0)))
1475}
1476
1477/// v7.39 (tz epic) — like `parse_time_of_day_micros` but reports
1478/// whether the literal carried an explicit offset (`None` = naive; a
1479/// timestamptz cast then interprets the wall clock in the session
1480/// zone, like PG).
1481fn parse_time_of_day_micros_tz(t: &str) -> Option<(i64, Option<i64>)> {
1482    let t = t.trim();
1483    // Detect & strip optional TZ suffix. Anchor on the first
1484    // `+` / `-` AFTER position 8 (so the leading sign on a
1485    // negative offset can't be mistaken for an `HH:MM:SS-OO`
1486    // boundary if the time itself is somehow malformed).
1487    // ` UTC` and trailing `Z` also count as zero-offset TZ tags.
1488    let (core, tz_micros) = if let Some(rest) = t.strip_suffix('Z') {
1489        (rest, Some(0i64))
1490    } else if let Some(rest) = t.strip_suffix(" UTC").or_else(|| t.strip_suffix("UTC")) {
1491        (rest, Some(0i64))
1492    } else if let Some((idx, sign_byte)) = find_offset_sign(t) {
1493        let suffix = &t[idx..];
1494        let micros = parse_tz_offset_suffix(suffix, sign_byte == b'+')?;
1495        (&t[..idx], Some(micros))
1496    } else {
1497        (t, None)
1498    };
1499    let (time, frac_str) = match core.split_once('.') {
1500        Some((a, b)) => (a, Some(b)),
1501        None => (core, None),
1502    };
1503    let bytes = time.as_bytes();
1504    // PG accepts both `HH:MM:SS` and the seconds-optional `HH:MM`
1505    // form in a TIMESTAMP literal (`'2024-01-15 10:30'::timestamp`
1506    // → `10:30:00`); hour-only (`'... 10'`) stays a parse error.
1507    let (hh, mm, ss): (i64, i64, i64) = if bytes.len() == 8 && bytes[2] == b':' && bytes[5] == b':'
1508    {
1509        (
1510            time[0..2].parse().ok()?,
1511            time[3..5].parse().ok()?,
1512            time[6..8].parse().ok()?,
1513        )
1514    } else if bytes.len() == 5 && bytes[2] == b':' {
1515        (time[0..2].parse().ok()?, time[3..5].parse().ok()?, 0)
1516    } else {
1517        return None;
1518    };
1519    if !(0..24).contains(&hh) || !(0..60).contains(&mm) || !(0..60).contains(&ss) {
1520        return None;
1521    }
1522    let frac_micros: i64 = match frac_str {
1523        None => 0,
1524        Some(f) => {
1525            // Pad right with zeros to 6 digits, then truncate extras.
1526            if f.is_empty() || f.len() > 9 {
1527                return None;
1528            }
1529            let mut padded = String::with_capacity(6);
1530            padded.push_str(&f[..f.len().min(6)]);
1531            while padded.len() < 6 {
1532                padded.push('0');
1533            }
1534            padded.parse().ok()?
1535        }
1536    };
1537    Some((
1538        ((hh * 3600 + mm * 60 + ss) * 1_000_000) + frac_micros,
1539        tz_micros,
1540    ))
1541}
1542
1543/// Find the index of the TZ-offset sign byte (`+` or `-`) that
1544/// terminates an `HH:MM:SS[.fff]` time string, or `None` when
1545/// the time carries no numeric TZ suffix. Anchors past the first
1546/// 8 bytes (`HH:MM:SS`) so the seconds/minutes colons don't
1547/// confuse the scan.
1548fn find_offset_sign(t: &str) -> Option<(usize, u8)> {
1549    let bytes = t.as_bytes();
1550    // Start past `HH:MM` (5 bytes) — the seconds-optional literal
1551    // (`'2024-07-15 12:00+00'`) carries its offset at index 5; a
1552    // time body itself never contains `+`/`-`.
1553    if bytes.len() < 6 {
1554        return None;
1555    }
1556    for i in 5..bytes.len() {
1557        match bytes[i] {
1558            b'+' | b'-' => return Some((i, bytes[i])),
1559            _ => {}
1560        }
1561    }
1562    None
1563}
1564
1565/// Parse `+OO`, `+OO:MM`, `+OOMM`, `-OO`, `-OO:MM`, `-OOMM` into
1566/// a UTC-offset microsecond delta. `is_positive` reflects the
1567/// already-stripped sign.
1568fn parse_tz_offset_suffix(suffix: &str, is_positive: bool) -> Option<i64> {
1569    // suffix starts with `+` or `-`; strip it.
1570    let body = &suffix[1..];
1571    let (hh, mm): (i64, i64) = if let Some((h, m)) = body.split_once(':') {
1572        (h.parse().ok()?, m.parse().ok()?)
1573    } else {
1574        match body.len() {
1575            2 => (body.parse().ok()?, 0),
1576            3 => {
1577                // PG's "+0530" form lacks the colon; but a 3-char
1578                // body is `OOM` which is ambiguous (`+053` ?). PG
1579                // doesn't emit that; reject.
1580                return None;
1581            }
1582            4 => {
1583                let h: i64 = body[0..2].parse().ok()?;
1584                let m: i64 = body[2..4].parse().ok()?;
1585                (h, m)
1586            }
1587            _ => return None,
1588        }
1589    };
1590    if !(0..=18).contains(&hh) || !(0..60).contains(&mm) {
1591        return None;
1592    }
1593    let abs = (hh * 3600 + mm * 60) * 1_000_000;
1594    Some(if is_positive { abs } else { -abs })
1595}
1596
1597/// Render an `Interval { months, days, micros }` in a PG-ish shape.
1598/// The output mirrors `psql`'s text format: years/months from the
1599/// months part, days from its own dimension (no carry from micros —
1600/// this is the PG-canonical separation so `'1 day'` ≠ `'24 hours'`),
1601/// HH:MM:SS[.frac] from micros. v7.37.5 β added the `days` parameter
1602/// for PG byte-equal; `micros` may still carry hours ≥ 24 (PG keeps
1603/// the unnormalised form on the wire).
1604pub fn format_interval(months: i32, days: i32, micros: i64) -> String {
1605    let mut parts: Vec<String> = Vec::new();
1606    let years = months / 12;
1607    let mons = months % 12;
1608    // PG renders the unit in the singular only for `+1`; `-1` and any
1609    // other value pluralise. Helper closes over that rule.
1610    let unit = |n: i64, singular: &'static str, plural: &'static str| -> &'static str {
1611        if n == 1 { singular } else { plural }
1612    };
1613    // PG shows an explicit `+` on a positive field that follows a
1614    // negative one (`-2 mons +3 days`), so mixed signs stay readable.
1615    let mut prev_negative = false;
1616    if years != 0 {
1617        parts.push(format!(
1618            "{years} {}",
1619            unit(i64::from(years), "year", "years")
1620        ));
1621        prev_negative = years < 0;
1622    }
1623    if mons != 0 {
1624        let plus = if prev_negative && mons > 0 { "+" } else { "" };
1625        parts.push(format!(
1626            "{plus}{mons} {}",
1627            unit(i64::from(mons), "mon", "mons")
1628        ));
1629        prev_negative = mons < 0;
1630    }
1631    if days != 0 {
1632        let plus = if prev_negative && days > 0 { "+" } else { "" };
1633        parts.push(format!(
1634            "{plus}{days} {}",
1635            unit(i64::from(days), "day", "days")
1636        ));
1637    }
1638    let mut rem = micros;
1639    if rem != 0 {
1640        let neg = rem < 0;
1641        if neg {
1642            rem = -rem;
1643        }
1644        let secs = rem / 1_000_000;
1645        let frac = rem % 1_000_000;
1646        let hh = secs / 3600;
1647        let mm = (secs / 60) % 60;
1648        let ss = secs % 60;
1649        // PG shows an explicit `+` on the time part when a preceding date
1650        // field was negative but the time itself is positive, e.g.
1651        // `-1 days +02:00:00`. `is_before` = the last-printed date field's
1652        // sign.
1653        let is_before = if days != 0 {
1654            days < 0
1655        } else if mons != 0 {
1656            mons < 0
1657        } else {
1658            years < 0
1659        };
1660        let sign = if neg {
1661            "-"
1662        } else if is_before {
1663            "+"
1664        } else {
1665            ""
1666        };
1667        if frac == 0 {
1668            parts.push(format!("{sign}{hh:02}:{mm:02}:{ss:02}"));
1669        } else {
1670            let raw = format!("{frac:06}");
1671            let trimmed = raw.trim_end_matches('0');
1672            parts.push(format!("{sign}{hh:02}:{mm:02}:{ss:02}.{trimmed}"));
1673        }
1674    }
1675    if parts.is_empty() {
1676        // PG renders a zero interval as `00:00:00`, not `0`.
1677        "00:00:00".into()
1678    } else {
1679        parts.join(" ")
1680    }
1681}
1682
1683/// v7.10.9 — render a TEXT[] in PG's external array form
1684/// (`{a,b,NULL}`). Elements containing whitespace, commas,
1685/// quotes, or braces get double-quoted with `\\` / `\"` escapes.
1686/// NULL elements use the literal token `NULL`. Public so the
1687/// wire layer can produce the canonical text-mode encoding.
1688pub fn format_text_array(items: &[Option<String>]) -> String {
1689    let mut out = String::with_capacity(2 + items.len() * 8);
1690    out.push('{');
1691    for (i, item) in items.iter().enumerate() {
1692        if i > 0 {
1693            out.push(',');
1694        }
1695        match item {
1696            None => out.push_str("NULL"),
1697            Some(s) => {
1698                // PG array_out quotes an element containing any structural
1699                // char or any whitespace `array_isspace` recognises — space,
1700                // tab, newline, carriage return, vertical tab, form feed —
1701                // not just space/tab.
1702                let needs_quote = s.is_empty()
1703                    || s.eq_ignore_ascii_case("NULL")
1704                    || s.chars().any(|c| {
1705                        matches!(
1706                            c,
1707                            ',' | '{'
1708                                | '}'
1709                                | '"'
1710                                | '\\'
1711                                | ' '
1712                                | '\t'
1713                                | '\n'
1714                                | '\r'
1715                                | '\x0b'
1716                                | '\x0c'
1717                        )
1718                    });
1719                if needs_quote {
1720                    out.push('"');
1721                    for c in s.chars() {
1722                        if c == '"' || c == '\\' {
1723                            out.push('\\');
1724                        }
1725                        out.push(c);
1726                    }
1727                    out.push('"');
1728                } else {
1729                    out.push_str(s);
1730                }
1731            }
1732        }
1733    }
1734    out.push('}');
1735    out
1736}
1737
1738/// v7.11.14 — render an INT[] in PG's external array form
1739/// (`{1,2,NULL}`). Integer payloads never need quoting. NULL
1740/// elements use the literal token `NULL`.
1741pub fn format_int_array(items: &[Option<i32>]) -> String {
1742    let mut out = String::with_capacity(2 + items.len() * 4);
1743    out.push('{');
1744    for (i, item) in items.iter().enumerate() {
1745        if i > 0 {
1746            out.push(',');
1747        }
1748        match item {
1749            None => out.push_str("NULL"),
1750            Some(n) => out.push_str(&n.to_string()),
1751        }
1752    }
1753    out.push('}');
1754    out
1755}
1756
1757/// v7.11.14 — render a BIGINT[] in PG's external array form
1758/// (`{1,2,NULL}`).
1759pub fn format_bigint_array(items: &[Option<i64>]) -> String {
1760    let mut out = String::with_capacity(2 + items.len() * 6);
1761    out.push('{');
1762    for (i, item) in items.iter().enumerate() {
1763        if i > 0 {
1764            out.push(',');
1765        }
1766        match item {
1767            None => out.push_str("NULL"),
1768            Some(n) => out.push_str(&n.to_string()),
1769        }
1770    }
1771    out.push('}');
1772    out
1773}
1774
1775/// v7.37.5 γ — render a BOOL[] in PG external form.
1776/// PG uses single-letter `t` / `f` for booleans (matching the
1777/// scalar wire convention).
1778pub fn format_bool_array(items: &[Option<bool>]) -> String {
1779    let mut out = String::with_capacity(2 + items.len() * 2);
1780    out.push('{');
1781    for (i, item) in items.iter().enumerate() {
1782        if i > 0 {
1783            out.push(',');
1784        }
1785        match item {
1786            None => out.push_str("NULL"),
1787            Some(b) => out.push(if *b { 't' } else { 'f' }),
1788        }
1789    }
1790    out.push('}');
1791    out
1792}
1793
1794/// v7.37.5 γ — render a SMALLINT[] in PG external form.
1795pub fn format_smallint_array(items: &[Option<i16>]) -> String {
1796    let mut out = String::with_capacity(2 + items.len() * 4);
1797    out.push('{');
1798    for (i, item) in items.iter().enumerate() {
1799        if i > 0 {
1800            out.push(',');
1801        }
1802        match item {
1803            None => out.push_str("NULL"),
1804            Some(n) => out.push_str(&n.to_string()),
1805        }
1806    }
1807    out.push('}');
1808    out
1809}
1810
1811/// v7.37.5 γ — render a FLOAT[] / DOUBLE PRECISION[] in PG
1812/// external form. PG renders floats via the engine's existing
1813/// f64 → text path so this just calls Rust's default Display.
1814pub fn format_float_array(items: &[Option<f64>]) -> String {
1815    let mut out = String::with_capacity(2 + items.len() * 8);
1816    out.push('{');
1817    for (i, item) in items.iter().enumerate() {
1818        if i > 0 {
1819            out.push(',');
1820        }
1821        match item {
1822            None => out.push_str("NULL"),
1823            // PG float8[] elements use float8out too — scientific past
1824            // the exponent thresholds (`{1e+30,2}`), not every digit.
1825            Some(x) => out.push_str(&format_float(*x)),
1826        }
1827    }
1828    out.push('}');
1829    out
1830}
1831
1832/// v7.37.5 γ — render a NUMERIC[] in PG external form.
1833pub fn format_numeric_array(items: &[Option<(i128, u16)>]) -> String {
1834    let mut out = String::with_capacity(2 + items.len() * 6);
1835    out.push('{');
1836    for (i, item) in items.iter().enumerate() {
1837        if i > 0 {
1838            out.push(',');
1839        }
1840        match item {
1841            None => out.push_str("NULL"),
1842            Some((scaled, scale)) => out.push_str(&format_numeric(*scaled, *scale)),
1843        }
1844    }
1845    out.push('}');
1846    out
1847}
1848
1849/// v7.37.5 γ — render a DATE[] in PG external form. Each
1850/// non-NULL element is rendered as `YYYY-MM-DD`.
1851pub fn format_date_array(items: &[Option<i32>]) -> String {
1852    let mut out = String::with_capacity(2 + items.len() * 12);
1853    out.push('{');
1854    for (i, item) in items.iter().enumerate() {
1855        if i > 0 {
1856            out.push(',');
1857        }
1858        match item {
1859            None => out.push_str("NULL"),
1860            Some(d) => out.push_str(&format_date(*d)),
1861        }
1862    }
1863    out.push('}');
1864    out
1865}
1866
1867/// v7.37.5 γ — render a TIMESTAMP[] (`with_tz=false`) or
1868/// TIMESTAMPTZ[] (`with_tz=true`) in PG external form. Each
1869/// non-NULL element is double-quoted because the canonical
1870/// timestamp text contains a space (`2024-06-01 12:00:00`)
1871/// that would otherwise split on commas wrong.
1872pub fn format_timestamp_array(items: &[Option<i64>], with_tz: bool) -> String {
1873    let mut out = String::with_capacity(2 + items.len() * 22);
1874    out.push('{');
1875    for (i, item) in items.iter().enumerate() {
1876        if i > 0 {
1877            out.push(',');
1878        }
1879        match item {
1880            None => out.push_str("NULL"),
1881            Some(t) => {
1882                out.push('"');
1883                if with_tz {
1884                    out.push_str(&format_timestamptz(*t));
1885                } else {
1886                    out.push_str(&format_timestamp(*t));
1887                }
1888                out.push('"');
1889            }
1890        }
1891    }
1892    out.push('}');
1893    out
1894}
1895
1896/// v7.37.5 γ — render a UUID[] in PG external form. UUID text is
1897/// the canonical lowercase 8-4-4-4-12 hyphenated form; no quoting
1898/// needed (hex + dashes, no spaces / commas).
1899pub fn format_uuid_array(items: &[Option<[u8; 16]>]) -> String {
1900    let mut out = String::with_capacity(2 + items.len() * 38);
1901    out.push('{');
1902    for (i, item) in items.iter().enumerate() {
1903        if i > 0 {
1904            out.push(',');
1905        }
1906        match item {
1907            None => out.push_str("NULL"),
1908            Some(b) => out.push_str(&spg_storage::format_uuid(b)),
1909        }
1910    }
1911    out.push('}');
1912    out
1913}
1914
1915/// v7.37.5 γ — render a BYTEA[] in PG external form. Each
1916/// non-NULL element is `\\x<hex>` (the PG hex output form) and
1917/// is double-quoted because the leading backslash is a PG array
1918/// escape character.
1919pub fn format_bytea_array(items: &[Option<Vec<u8>>]) -> String {
1920    let mut out = String::with_capacity(2 + items.len() * 8);
1921    out.push('{');
1922    for (i, item) in items.iter().enumerate() {
1923        if i > 0 {
1924            out.push(',');
1925        }
1926        match item {
1927            None => out.push_str("NULL"),
1928            Some(b) => {
1929                out.push('"');
1930                let hex = format_bytea_hex(b);
1931                // Escape leading backslash (`\` → `\\`) per PG
1932                // array-element quoting rules.
1933                for c in hex.chars() {
1934                    if c == '\\' {
1935                        out.push('\\');
1936                    }
1937                    out.push(c);
1938                }
1939                out.push('"');
1940            }
1941        }
1942    }
1943    out.push('}');
1944    out
1945}
1946
1947/// v7.37.5 β-P4 — render an INTERVAL[] in PG's external array form.
1948/// Each non-NULL element is double-quoted because interval text
1949/// contains spaces (`1 day`) and colons (`24:00:00`) that would
1950/// confuse the comma-separated parse: `{"1 day","24:00:00",NULL}`.
1951/// Inner `"` doesn't occur in interval text so no escaping is
1952/// needed; backslashes likewise can't appear.
1953pub fn format_interval_array(items: &[Option<spg_storage::IntervalSpan>]) -> String {
1954    let mut out = String::with_capacity(2 + items.len() * 12);
1955    out.push('{');
1956    for (i, item) in items.iter().enumerate() {
1957        if i > 0 {
1958            out.push(',');
1959        }
1960        match item {
1961            None => out.push_str("NULL"),
1962            Some(span) => {
1963                // v7.38.19 — an element can be infinite too, and the
1964                // word carries no comma or space, so PostgreSQL prints
1965                // it unquoted: `{infinity,"1 day"}`.
1966                if span.kind.is_finite() {
1967                    out.push('"');
1968                    out.push_str(&format_interval(span.months, span.days, span.micros));
1969                    out.push('"');
1970                } else {
1971                    out.push_str(&format_interval_kinded(0, 0, 0, span.kind));
1972                }
1973            }
1974        }
1975    }
1976    out.push('}');
1977    out
1978}
1979
1980/// v7.10.4 — render a BYTEA payload in PG's hex output format
1981/// (`\x` prefix, lowercase hex pairs). Public so the wire layer
1982/// can emit the canonical bytea-as-text representation.
1983/// v7.39 (round 524) — PG's `escape` bytea form: a printable byte as
1984/// itself, a backslash doubled, everything else `\ooo` octal.
1985#[must_use]
1986pub fn format_bytea_escape(b: &[u8]) -> String {
1987    let mut out = String::with_capacity(b.len());
1988    for &byte in b {
1989        match byte {
1990            b'\\' => out.push_str("\\\\"),
1991            0x20..=0x7e => out.push(byte as char),
1992            _ => out.push_str(&alloc::format!("\\{byte:03o}")),
1993        }
1994    }
1995    out
1996}
1997
1998pub fn format_bytea_hex(b: &[u8]) -> String {
1999    let mut out = String::with_capacity(2 + 2 * b.len());
2000    out.push_str("\\x");
2001    const HEX: &[u8; 16] = b"0123456789abcdef";
2002    for byte in b {
2003        out.push(HEX[(byte >> 4) as usize] as char);
2004        out.push(HEX[(byte & 0x0F) as usize] as char);
2005    }
2006    out
2007}
2008
2009/// Render a `Numeric { scaled, scale }` as its decimal text form.
2010/// Negative `scaled` prepends `-` to the absolute value's digits; the
2011/// integer / fractional split is by character count, padding the
2012/// fractional side with leading zeros to exactly `scale` chars.
2013/// v7.38 (read01, T6) — render a NUMERIC honoring its special kind. Finite uses
2014/// `format_numeric`; the specials render PG's full-word spellings.
2015pub fn format_numeric_kind(kind: spg_storage::NumericKind, scaled: i128, scale: u16) -> String {
2016    use spg_storage::NumericKind;
2017    match kind {
2018        NumericKind::Finite => format_numeric(scaled, scale),
2019        NumericKind::NaN => String::from("NaN"),
2020        NumericKind::PosInf => String::from("Infinity"),
2021        NumericKind::NegInf => String::from("-Infinity"),
2022    }
2023}
2024
2025pub fn format_numeric(scaled: i128, scale: u16) -> String {
2026    if scale == 0 {
2027        return format!("{scaled}");
2028    }
2029    let negative = scaled < 0;
2030    let mag_str = scaled.unsigned_abs().to_string();
2031    let mag_bytes = mag_str.as_bytes();
2032    let scale_u = scale as usize;
2033    let mut out = String::with_capacity(mag_str.len() + 3);
2034    if negative {
2035        out.push('-');
2036    }
2037    if mag_bytes.len() <= scale_u {
2038        out.push('0');
2039        out.push('.');
2040        for _ in mag_bytes.len()..scale_u {
2041            out.push('0');
2042        }
2043        out.push_str(&mag_str);
2044    } else {
2045        let split = mag_bytes.len() - scale_u;
2046        out.push_str(&mag_str[..split]);
2047        out.push('.');
2048        out.push_str(&mag_str[split..]);
2049    }
2050    out
2051}
2052
2053/// v7.39 (round 292) — the shortest decimal for an f64 in PG's sense,
2054/// as `{:e}` style. The f64 sibling of `shortest_real_sci`.
2055///
2056/// Rust's `{:e}` gives the shortest decimal that ROUND-TRIPS; PG wants
2057/// the shortest that lies STRICTLY INSIDE the rounding interval. The
2058/// two differ exactly on values whose short form sits on a half-ulp
2059/// boundary — measured on PG 18.4, `1e23::float8` prints
2060/// `9.999999999999999e+22`, because 1e23 IS the boundary and
2061/// ties-to-even parses it back to the same double, so Rust accepts it.
2062///
2063/// The f32 version can test the boundary by widening to f64. There is
2064/// no wider float here, so the test is done in exact INTEGER
2065/// arithmetic instead: a midpoint is `M · 2^E` with M odd, a candidate
2066/// is `D · 10^K`, and the two are equal only if their odd parts and
2067/// their powers of two both match. That forces `5^|K|` to divide a
2068/// 57-bit number, which bounds |K| — so the whole comparison fits in
2069/// u128 and needs no bignum at all.
2070fn shortest_float_sci(x: f64) -> String {
2071    let (m, e) = f64_mantissa_exp(x);
2072    // Midpoints to the neighbours, as odd·2^exp. The gap BELOW a power
2073    // of two is half the gap above it, so that side needs one more bit.
2074    let (hi_m, hi_e) = (2 * m + 1, e - 1);
2075    let (lo_m, lo_e) = if m == 1 << 52 && e > f64_min_exp() {
2076        (4 * m - 1, e - 2)
2077    } else {
2078        (2 * m - 1, e - 1)
2079    };
2080    for p in 1..=17u32 {
2081        let cand = alloc::format!("{x:.*e}", (p - 1) as usize);
2082        let Ok(v) = cand.parse::<f64>() else { continue };
2083        if v != x {
2084            continue;
2085        }
2086        let Some((d, k)) = sci_to_digits_exp(&cand) else {
2087            continue;
2088        };
2089        if !decimal_eq_binary(d, k, hi_m, hi_e) && !decimal_eq_binary(d, k, lo_m, lo_e) {
2090            return cand;
2091        }
2092    }
2093    alloc::format!("{x:e}")
2094}
2095
2096/// `x` as `mantissa · 2^exp` with the mantissa a positive integer.
2097/// Only called on finite non-zero values.
2098fn f64_mantissa_exp(x: f64) -> (u128, i32) {
2099    let bits = x.abs().to_bits();
2100    let biased = ((bits >> 52) & 0x7ff) as i32;
2101    let frac = u128::from(bits & 0x000f_ffff_ffff_ffff);
2102    if biased == 0 {
2103        (frac, -1074) // subnormal: no implicit leading bit
2104    } else {
2105        ((1u128 << 52) | frac, biased - 1075)
2106    }
2107}
2108
2109/// The exponent of the smallest normal f64, below which the
2110/// gap-below-a-power-of-two rule no longer applies.
2111const fn f64_min_exp() -> i32 {
2112    -1074
2113}
2114
2115/// Split a `{:e}` string into `(digits, exponent)` such that the value
2116/// is `digits · 10^exponent`. `None` when it does not fit u128 (which
2117/// cannot happen for the ≤ 17 digits generated here).
2118fn sci_to_digits_exp(sci: &str) -> Option<(u128, i32)> {
2119    let epos = sci.find('e')?;
2120    let (mant, rest) = sci.split_at(epos);
2121    let exp: i32 = rest[1..].parse().ok()?;
2122    let mant = mant.strip_prefix('-').unwrap_or(mant);
2123    let (int_part, frac_part) = match mant.split_once('.') {
2124        Some((a, b)) => (a, b),
2125        None => (mant, ""),
2126    };
2127    let mut digits: u128 = 0;
2128    for c in int_part.chars().chain(frac_part.chars()) {
2129        digits = digits
2130            .checked_mul(10)?
2131            .checked_add(u128::from(c as u8 - b'0'))?;
2132    }
2133    Some((digits, exp - i32::try_from(frac_part.len()).ok()?))
2134}
2135
2136/// Exactly: does `d · 10^k` equal `m · 2^e`, with `m` odd?
2137///
2138/// Both sides are split into an odd part and a power of two; they are
2139/// equal iff both halves match. `10^k = 2^k · 5^k`, so the 5s must
2140/// divide out exactly — which is what bounds the powers involved.
2141fn decimal_eq_binary(d: u128, k: i32, m: u128, e: i32) -> bool {
2142    if d == 0 {
2143        return false;
2144    }
2145    let a = i32::try_from(d.trailing_zeros()).unwrap_or(i32::MAX);
2146    let d_odd = d >> d.trailing_zeros();
2147    if k >= 0 {
2148        // odd part is d_odd · 5^k — bail as soon as it can only exceed m.
2149        let mut lhs = d_odd;
2150        for _ in 0..k {
2151            match lhs.checked_mul(5) {
2152                Some(v) if v <= m => lhs = v,
2153                _ => return false,
2154            }
2155        }
2156        lhs == m && a + k == e
2157    } else {
2158        // d_odd must be divisible by 5^|k|; the quotient is the odd part.
2159        let j = -k;
2160        let mut lhs = d_odd;
2161        for _ in 0..j {
2162            if lhs % 5 != 0 {
2163                return false;
2164            }
2165            lhs /= 5;
2166        }
2167        lhs == m && a - j == e
2168    }
2169}