Skip to main content

cqlite_core/util/
value_fmt.rs

1//! Value formatting shared by output writers
2//! Implements the Value → String mapping per QUERY_RESULT_CONTRACT.md
3//!
4//! This module provides stable, cqlsh-compatible formatting for all CQL value
5//! types.  It originally lived in `cqlite-cli/src/output/value_fmt.rs` and was
6//! moved into core (Issue #683) so that the Parquet export writer — which uses
7//! it for textual fallbacks such as inet and duration — can live in
8//! `cqlite-core` together with its formatting helpers.  The CLI re-exports it
9//! unchanged from `cqlite_cli::output::value_fmt`.
10
11use crate::types::Value;
12use chrono::DateTime;
13use std::net::{Ipv4Addr, Ipv6Addr};
14
15/// ValueFormatter provides cqlsh-compatible string formatting for CQL values
16pub struct ValueFormatter;
17
18/// Lowercase hex digits for the lookup-table UUID/hex encoders.
19const HEX_LOWER: &[u8; 16] = b"0123456789abcdef";
20
21impl ValueFormatter {
22    /// Returns `true` when the value represents a genuine CQL `NULL`.
23    ///
24    /// This is the authoritative null predicate used by output writers to decide
25    /// whether to emit an empty CSV field. It replaces the fragile
26    /// `format_value(v) == "null"` sentinel, which wrongly collapsed a literal
27    /// text value `"null"` to an empty field (issue #1499).
28    ///
29    /// A genuine null in a frozen column is represented as
30    /// `Value::Frozen(Box::new(Value::Null))` (which `format_value` renders as the
31    /// bare string `"null"`), so `is_null` unwraps `Value::Frozen` recursively
32    /// before checking for `Value::Null`. This restores the pre-#1499 CSV
33    /// null-output contract for frozen columns without re-introducing the original
34    /// bug: a literal text value `Value::text("null")` is NOT null and still emits
35    /// `"null"`. `Value::Null` and `Value::Frozen(..)` are the only genuine-null
36    /// representations that format to the bare string `"null"` (a UDT/collection
37    /// with null members formats as `{field: null}`/`[null]`, not `"null"`).
38    #[inline]
39    pub fn is_null(value: &Value) -> bool {
40        match value {
41            Value::Null => true,
42            Value::Frozen(inner) => Self::is_null(inner),
43            _ => false,
44        }
45    }
46
47    /// Append the formatted string representation of `value` to `out`.
48    ///
49    /// Byte-for-byte identical to `format_value`, but writes into a caller-owned
50    /// scratch buffer so hot loops (CSV rows) can reuse one allocation across all
51    /// cells instead of allocating a fresh `String` per cell (issue #1499). Scalar
52    /// hot paths are written directly; rarer complex types delegate to
53    /// `format_value` for a single append.
54    pub fn format_into(value: &Value, out: &mut String) {
55        use std::fmt::Write as _;
56        match value {
57            Value::Null => out.push_str("null"),
58            Value::Boolean(b) => out.push_str(if *b { "true" } else { "false" }),
59            Value::TinyInt(i) => {
60                let _ = write!(out, "{}", i);
61            }
62            Value::SmallInt(i) => {
63                let _ = write!(out, "{}", i);
64            }
65            Value::Integer(i) => {
66                let _ = write!(out, "{}", i);
67            }
68            Value::BigInt(i) => {
69                let _ = write!(out, "{}", i);
70            }
71            Value::Counter(i) => {
72                let _ = write!(out, "{}", i);
73            }
74            // `Text`'s bytes are UTF-8-validated at construction (issue #1644),
75            // so the lossy decode is exact — identical output to the former String.
76            Value::Text(s) => out.push_str(&String::from_utf8_lossy(s)),
77            Value::Uuid(bytes) => Self::format_uuid_into(bytes, out),
78            // Complex / rarer types: single append via the owned formatter. This
79            // keeps output byte-identical without duplicating their logic.
80            other => out.push_str(&Self::format_value(other)),
81        }
82    }
83
84    /// Encode a 16-byte UUID as lowercase hyphenated text into `out` using a hex
85    /// lookup table (no per-cell `format!` machinery). Shared by `format_uuid`
86    /// and the JSON writer (issue #1499).
87    pub fn format_uuid_into(bytes: &[u8; 16], out: &mut String) {
88        out.reserve(36);
89        for (i, b) in bytes.iter().enumerate() {
90            // Hyphens after bytes 4, 6, 8, and 10 (1-based).
91            if matches!(i, 4 | 6 | 8 | 10) {
92                out.push('-');
93            }
94            out.push(HEX_LOWER[(b >> 4) as usize] as char);
95            out.push(HEX_LOWER[(b & 0x0f) as usize] as char);
96        }
97    }
98
99    /// Format a Value to its string representation according to the contract specification
100    ///
101    /// # Contract Guarantees
102    /// - UUID/TimeUUID: lowercase hyphenated (e.g., "a8f167f0-ebe7-4f20-a386-31ff138bec3b")
103    /// - Timestamps: `YYYY-MM-DD HH:MM:SS[.fff][+0000]`, default UTC
104    /// - Collections: list `[a, b]`, set `{a, b}`, map `{k: v}`
105    /// - Blob: `0x`-prefixed lowercase hex
106    /// - Boolean: `true`/`false`
107    /// - Numbers: standard Rust formatting, avoid scientific notation unless necessary
108    pub fn format_value(value: &Value) -> String {
109        match value {
110            Value::Null => "null".to_string(),
111
112            // Boolean: lowercase true/false
113            Value::Boolean(b) => b.to_string(),
114
115            // Integer types: standard decimal formatting
116            Value::TinyInt(i) => i.to_string(),
117            Value::SmallInt(i) => i.to_string(),
118            Value::Integer(i) => i.to_string(),
119            Value::BigInt(i) => i.to_string(),
120            Value::Counter(i) => i.to_string(),
121
122            // Floating point: avoid scientific notation for reasonable ranges
123            Value::Float32(f) => Self::format_float32(*f),
124            Value::Float(f) => Self::format_float64(*f),
125
126            // Text: output as-is (no quotes for CLI display). `Text`'s bytes are
127            // UTF-8-validated at construction, so lossy decode is exact.
128            Value::Text(s) => String::from_utf8_lossy(s).into_owned(),
129
130            // Blob: 0x-prefixed lowercase hex
131            Value::Blob(bytes) => format!("0x{}", hex::encode(bytes)),
132
133            // Timestamp: milliseconds since epoch → YYYY-MM-DD HH:MM:SS.fff+0000
134            Value::Timestamp(millis) => Self::format_timestamp(*millis),
135
136            // Date: days since epoch → YYYY-MM-DD
137            Value::Date(days) => Self::format_date(*days),
138
139            // Time: nanoseconds since midnight → HH:MM:SS.nnnnnnnnn
140            Value::Time(nanos) => Self::format_time(*nanos),
141
142            // UUID: lowercase hyphenated format
143            Value::Uuid(bytes) => Self::format_uuid(bytes),
144
145            // Varint: arbitrary precision integer as decimal string
146            Value::Varint(bytes) => Self::format_varint(bytes),
147
148            // Decimal: scale + unscaled value → decimal string
149            Value::Decimal { scale, unscaled } => Self::format_decimal(*scale, unscaled),
150
151            // Duration: months, days, nanoseconds → "XmoYdZns" format
152            Value::Duration {
153                months,
154                days,
155                nanos,
156            } => Self::format_duration(*months, *days, *nanos),
157
158            // JSON: serialize to JSON string
159            Value::Json(json_value) => json_value.to_string(),
160
161            // Collections
162            Value::List(elements) => Self::format_list(elements),
163            Value::Set(elements) => Self::format_set(elements),
164            Value::Map(pairs) => Self::format_map(pairs),
165            Value::Tuple(fields) => Self::format_tuple(fields),
166
167            // User Defined Type
168            Value::Udt(udt) => Self::format_udt(udt),
169
170            // Frozen: unwrap and format inner value
171            Value::Frozen(inner) => Self::format_value(inner),
172
173            // Tombstone: special marker (should rarely appear in query results)
174            Value::Tombstone(info) => format!("<deleted@{}>", info.deletion_time),
175
176            // Inet: IPv4 or IPv6 address
177            Value::Inet(bytes) => Self::format_inet(bytes),
178        }
179    }
180
181    // ==================== Helper Methods ====================
182
183    /// Format float32 avoiding scientific notation for reasonable ranges
184    fn format_float32(f: f32) -> String {
185        if f.is_nan() {
186            "NaN".to_string()
187        } else if f.is_infinite() {
188            if f.is_sign_positive() {
189                "Infinity".to_string()
190            } else {
191                "-Infinity".to_string()
192            }
193        } else if f.abs() < 1e-6 || f.abs() > 1e10 {
194            format!("{:e}", f)
195        } else {
196            format!("{}", f)
197        }
198    }
199
200    /// Format float64 avoiding scientific notation for reasonable ranges
201    fn format_float64(f: f64) -> String {
202        if f.is_nan() {
203            "NaN".to_string()
204        } else if f.is_infinite() {
205            if f.is_sign_positive() {
206                "Infinity".to_string()
207            } else {
208                "-Infinity".to_string()
209            }
210        } else if f.abs() < 1e-6 || f.abs() > 1e10 {
211            format!("{:e}", f)
212        } else {
213            format!("{}", f)
214        }
215    }
216
217    /// Format timestamp (milliseconds since epoch) as YYYY-MM-DD HH:MM:SS.fff+0000
218    fn format_timestamp(millis: i64) -> String {
219        // Use from_timestamp_millis to correctly handle pre-epoch timestamps
220        // (truncating division was incorrect for negative values)
221        if let Some(datetime) = DateTime::from_timestamp_millis(millis) {
222            // Format with milliseconds: YYYY-MM-DD HH:MM:SS.fff+0000
223            datetime.format("%Y-%m-%d %H:%M:%S%.3f+0000").to_string()
224        } else {
225            format!("<invalid-timestamp:{}>", millis)
226        }
227    }
228
229    /// Format date (days since Unix epoch) as YYYY-MM-DD
230    fn format_date(days: i32) -> String {
231        // Unix epoch: 1970-01-01
232        let epoch = DateTime::from_timestamp(0, 0)
233            .map(|dt| dt.date_naive())
234            .unwrap_or_else(|| {
235                // Fallback: construct epoch date directly if timestamp fails
236                // Ultimate fallback for Date value formatting
237                chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap_or(chrono::NaiveDate::MIN)
238            });
239
240        if let Some(date) = epoch.checked_add_signed(chrono::Duration::days(days as i64)) {
241            date.format("%Y-%m-%d").to_string()
242        } else {
243            format!("<invalid-date:{}>", days)
244        }
245    }
246
247    /// Format time (nanoseconds since midnight) as HH:MM:SS.nnnnnnnnn
248    fn format_time(nanos: i64) -> String {
249        if nanos < 0 {
250            return format!("<invalid-time:{}>", nanos);
251        }
252
253        let total_secs = nanos / 1_000_000_000;
254        let hours = total_secs / 3600;
255        let minutes = (total_secs % 3600) / 60;
256        let seconds = total_secs % 60;
257        let remaining_nanos = nanos % 1_000_000_000;
258
259        if hours >= 24 {
260            return format!("<invalid-time:{}>", nanos);
261        }
262
263        format!(
264            "{:02}:{:02}:{:02}.{:09}",
265            hours, minutes, seconds, remaining_nanos
266        )
267    }
268
269    /// Format UUID as lowercase hyphenated format
270    fn format_uuid(bytes: &[u8; 16]) -> String {
271        let mut s = String::with_capacity(36);
272        Self::format_uuid_into(bytes, &mut s);
273        s
274    }
275
276    /// Format varint as decimal string
277    fn format_varint(bytes: &[u8]) -> String {
278        if bytes.is_empty() {
279            return "0".to_string();
280        }
281
282        // Use from_signed_bytes_be to handle both positive and negative values correctly
283        let result = num_bigint::BigInt::from_signed_bytes_be(bytes);
284        result.to_string()
285    }
286
287    /// Format decimal (scale + unscaled value) as decimal string
288    fn format_decimal(scale: i32, unscaled: &[u8]) -> String {
289        if unscaled.is_empty() {
290            return "0".to_string();
291        }
292
293        // Sanity ceiling (issue #1754), kept consistent with the Node binding's
294        // `decimal_to_string`. A Cassandra `decimal` unscaled value is a Java
295        // BigInteger, so it is legitimately arbitrary-precision; we must NOT call a
296        // merely-large-but-well-formed value "corrupt". The only hard cost is the
297        // single `BigInt` → decimal-string base conversion, which is superlinear in
298        // the digit count. A 32 KB magnitude (~79k decimal digits) still converts
299        // in tens of milliseconds; only a genuinely pathological magnitude beyond
300        // that could stall a render, so fail closed ONLY above the ceiling.
301        // Infallible signature: this stays total (never panics).
302        const DECIMAL_MAX_UNSCALED_BYTES: usize = 32 * 1024;
303        if unscaled.len() > DECIMAL_MAX_UNSCALED_BYTES {
304            return format!(
305                "<corrupt-decimal:scale={scale},unscaled_len={}bytes>",
306                unscaled.len()
307            );
308        }
309
310        // Convert the unscaled bytes to a BigInt. Cassandra encodes the unscaled
311        // value as a two's-complement big-endian BigInteger, which is exactly what
312        // `from_signed_bytes_be` decodes (positive when the high bit is clear).
313        let bigint = num_bigint::BigInt::from_signed_bytes_be(unscaled);
314        // ONE base-10 conversion — the sole superlinear step; every branch below is
315        // a single O(digits) pass over the resulting string (no repeated division,
316        // no scale-width padding blowup).
317        let mut decimal_str = bigint.to_string();
318        let is_neg = decimal_str.starts_with('-');
319        if is_neg {
320            decimal_str = decimal_str[1..].to_string();
321        }
322
323        // Faithful, bounded exponent form for over-bound cases (issue #1754). Two
324        // triggers, both of which would otherwise require an O(digits)-wide
325        // positional expansion:
326        //   - a large-but-valid unscaled magnitude (thousands+ of digits), and
327        //   - a pathological `scale` used as a `repeat`/padding width (which at
328        //     `scale == i32::MIN` would also overflow `(-scale)`).
329        // Rendering `<digits>e<-scale>` (value = unscaled × 10^(−scale)) preserves
330        // EVERY digit exactly with no unbounded padding. Legitimate, normal-size
331        // decimals fall through to the byte-identical positional form below.
332        const DECIMAL_POSITIONAL_MAX_BYTES: usize = 1024;
333        const SCALE_RENDER_CAP: usize = 1_000_000;
334        if unscaled.len() > DECIMAL_POSITIONAL_MAX_BYTES
335            || scale.unsigned_abs() as usize > SCALE_RENDER_CAP
336        {
337            let body = if is_neg {
338                format!("-{decimal_str}")
339            } else {
340                decimal_str
341            };
342            // `unsigned_abs()`/`i64` avoid the `(-scale)` overflow at `i32::MIN`.
343            return if scale == 0 {
344                body
345            } else {
346                format!("{body}e{}", -(scale as i64))
347            };
348        }
349
350        // Insert decimal point based on scale
351        if scale <= 0 {
352            // Scale <= 0: multiply by 10^(-scale). `unsigned_abs()` avoids the
353            // `(-scale)` overflow panic at `scale == i32::MIN`.
354            decimal_str.push_str(&"0".repeat(scale.unsigned_abs() as usize));
355        } else if scale as usize >= decimal_str.len() {
356            // Need leading zeros
357            let leading_zeros = scale as usize - decimal_str.len() + 1;
358            decimal_str = format!("0.{}{}", "0".repeat(leading_zeros - 1), decimal_str);
359        } else {
360            // Insert decimal point
361            let pos = decimal_str.len() - scale as usize;
362            decimal_str.insert(pos, '.');
363        }
364
365        if is_neg {
366            format!("-{}", decimal_str)
367        } else {
368            decimal_str
369        }
370    }
371
372    /// Format duration as "XmoYdZns" (cqlsh format)
373    fn format_duration(months: i32, days: i32, nanos: i64) -> String {
374        let mut parts = Vec::new();
375
376        if months != 0 {
377            parts.push(format!("{}mo", months));
378        }
379        if days != 0 {
380            parts.push(format!("{}d", days));
381        }
382        if nanos != 0 {
383            parts.push(format!("{}ns", nanos));
384        }
385
386        if parts.is_empty() {
387            "0ns".to_string()
388        } else {
389            parts.join("")
390        }
391    }
392
393    /// Format list as [a, b, c]
394    fn format_list(elements: &[Value]) -> String {
395        let formatted_elements: Vec<String> = elements.iter().map(Self::format_value).collect();
396        format!("[{}]", formatted_elements.join(", "))
397    }
398
399    /// Format set as {a, b, c}
400    fn format_set(elements: &[Value]) -> String {
401        let formatted_elements: Vec<String> = elements.iter().map(Self::format_value).collect();
402        format!("{{{}}}", formatted_elements.join(", "))
403    }
404
405    /// Format map as {k1: v1, k2: v2}
406    fn format_map(pairs: &[(Value, Value)]) -> String {
407        let formatted_pairs: Vec<String> = pairs
408            .iter()
409            .map(|(k, v)| format!("{}: {}", Self::format_value(k), Self::format_value(v)))
410            .collect();
411        format!("{{{}}}", formatted_pairs.join(", "))
412    }
413
414    /// Format tuple as (a, b, c)
415    fn format_tuple(fields: &[Value]) -> String {
416        let formatted_fields: Vec<String> = fields.iter().map(Self::format_value).collect();
417        format!("({})", formatted_fields.join(", "))
418    }
419
420    /// Format UDT as {field1: value1, field2: value2}
421    fn format_udt(udt: &crate::types::UdtValue) -> String {
422        let formatted_fields: Vec<String> = udt
423            .fields
424            .iter()
425            .map(|field| {
426                let value_str = field
427                    .value
428                    .as_ref()
429                    .map(Self::format_value)
430                    .unwrap_or_else(|| "null".to_string());
431                format!("{}: {}", field.name, value_str)
432            })
433            .collect();
434        format!("{{{}}}", formatted_fields.join(", "))
435    }
436
437    /// Format inet address (IPv4 or IPv6)
438    fn format_inet(bytes: &[u8]) -> String {
439        if bytes.len() == 4 {
440            // IPv4
441            let addr = Ipv4Addr::new(bytes[0], bytes[1], bytes[2], bytes[3]);
442            addr.to_string()
443        } else if bytes.len() == 16 {
444            // IPv6
445            let mut octets = [0u8; 16];
446            octets.copy_from_slice(bytes);
447            let addr = Ipv6Addr::from(octets);
448            addr.to_string()
449        } else {
450            format!("<invalid-inet:{}-bytes>", bytes.len())
451        }
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use crate::types::{UdtField, UdtValue};
459
460    #[test]
461    fn test_null() {
462        assert_eq!(ValueFormatter::format_value(&Value::Null), "null");
463    }
464
465    #[test]
466    fn test_boolean() {
467        assert_eq!(ValueFormatter::format_value(&Value::Boolean(true)), "true");
468        assert_eq!(
469            ValueFormatter::format_value(&Value::Boolean(false)),
470            "false"
471        );
472    }
473
474    #[test]
475    fn test_integers() {
476        assert_eq!(ValueFormatter::format_value(&Value::TinyInt(127)), "127");
477        assert_eq!(ValueFormatter::format_value(&Value::TinyInt(-128)), "-128");
478        assert_eq!(
479            ValueFormatter::format_value(&Value::SmallInt(32767)),
480            "32767"
481        );
482        assert_eq!(
483            ValueFormatter::format_value(&Value::Integer(2147483647)),
484            "2147483647"
485        );
486        assert_eq!(
487            ValueFormatter::format_value(&Value::BigInt(9223372036854775807)),
488            "9223372036854775807"
489        );
490        assert_eq!(
491            ValueFormatter::format_value(&Value::Counter(1000000)),
492            "1000000"
493        );
494    }
495
496    #[test]
497    fn test_floats() {
498        assert_eq!(ValueFormatter::format_value(&Value::Float32(3.25)), "3.25");
499        assert_eq!(ValueFormatter::format_value(&Value::Float(2.75)), "2.75");
500
501        // Special values
502        assert_eq!(
503            ValueFormatter::format_value(&Value::Float32(f32::NAN)),
504            "NaN"
505        );
506        assert_eq!(
507            ValueFormatter::format_value(&Value::Float32(f32::INFINITY)),
508            "Infinity"
509        );
510        assert_eq!(
511            ValueFormatter::format_value(&Value::Float32(f32::NEG_INFINITY)),
512            "-Infinity"
513        );
514
515        // Scientific notation for very small/large numbers
516        let small = Value::Float(1e-7);
517        let formatted = ValueFormatter::format_value(&small);
518        assert!(formatted.contains('e') || formatted.contains('E'));
519    }
520
521    #[test]
522    fn test_text() {
523        assert_eq!(
524            ValueFormatter::format_value(&Value::text("hello world".to_string())),
525            "hello world"
526        );
527        assert_eq!(
528            ValueFormatter::format_value(&Value::text("".to_string())),
529            ""
530        );
531    }
532
533    #[test]
534    fn test_blob() {
535        let blob = Value::blob(vec![0xDE, 0xAD, 0xBE, 0xEF]);
536        assert_eq!(ValueFormatter::format_value(&blob), "0xdeadbeef");
537
538        let empty_blob = Value::blob(vec![]);
539        assert_eq!(ValueFormatter::format_value(&empty_blob), "0x");
540    }
541
542    #[test]
543    fn test_uuid() {
544        // UUID: a8f167f0-ebe7-4f20-a386-31ff138bec3b
545        let uuid = Value::Uuid([
546            0xa8, 0xf1, 0x67, 0xf0, 0xeb, 0xe7, 0x4f, 0x20, 0xa3, 0x86, 0x31, 0xff, 0x13, 0x8b,
547            0xec, 0x3b,
548        ]);
549        assert_eq!(
550            ValueFormatter::format_value(&uuid),
551            "a8f167f0-ebe7-4f20-a386-31ff138bec3b"
552        );
553    }
554
555    #[test]
556    fn test_timestamp() {
557        // 2023-01-15 10:30:45.123 UTC = 1673778645123 milliseconds
558        let timestamp = Value::Timestamp(1673778645123);
559        let formatted = ValueFormatter::format_value(&timestamp);
560        assert!(formatted.starts_with("2023-01-15"));
561        assert!(formatted.contains("10:30:45"));
562        assert!(formatted.ends_with("+0000"));
563    }
564
565    #[test]
566    fn test_date() {
567        // 2023-01-01 = 19358 days since 1970-01-01
568        let date = Value::Date(19358);
569        assert_eq!(ValueFormatter::format_value(&date), "2023-01-01");
570
571        // Unix epoch
572        let epoch = Value::Date(0);
573        assert_eq!(ValueFormatter::format_value(&epoch), "1970-01-01");
574    }
575
576    #[test]
577    fn test_time() {
578        // 14:30:45.123456789
579        let nanos =
580            14 * 3600 * 1_000_000_000 + 30 * 60 * 1_000_000_000 + 45 * 1_000_000_000 + 123_456_789;
581        let time = Value::Time(nanos);
582        assert_eq!(ValueFormatter::format_value(&time), "14:30:45.123456789");
583
584        // Midnight
585        let midnight = Value::Time(0);
586        assert_eq!(
587            ValueFormatter::format_value(&midnight),
588            "00:00:00.000000000"
589        );
590    }
591
592    #[test]
593    fn test_duration() {
594        let duration = Value::Duration {
595            months: 2,
596            days: 15,
597            nanos: 123456789,
598        };
599        assert_eq!(ValueFormatter::format_value(&duration), "2mo15d123456789ns");
600
601        let zero_duration = Value::Duration {
602            months: 0,
603            days: 0,
604            nanos: 0,
605        };
606        assert_eq!(ValueFormatter::format_value(&zero_duration), "0ns");
607
608        let partial_duration = Value::Duration {
609            months: 0,
610            days: 5,
611            nanos: 0,
612        };
613        assert_eq!(ValueFormatter::format_value(&partial_duration), "5d");
614    }
615
616    #[test]
617    fn test_list() {
618        let list = Value::List(vec![
619            Value::Integer(1),
620            Value::Integer(2),
621            Value::Integer(3),
622        ]);
623        assert_eq!(ValueFormatter::format_value(&list), "[1, 2, 3]");
624
625        let empty_list = Value::List(vec![]);
626        assert_eq!(ValueFormatter::format_value(&empty_list), "[]");
627    }
628
629    #[test]
630    fn test_set() {
631        let set = Value::Set(vec![
632            Value::text("apple".to_string()),
633            Value::text("banana".to_string()),
634        ]);
635        assert_eq!(ValueFormatter::format_value(&set), "{apple, banana}");
636
637        let empty_set = Value::Set(vec![]);
638        assert_eq!(ValueFormatter::format_value(&empty_set), "{}");
639    }
640
641    #[test]
642    fn test_map() {
643        let map = Value::Map(vec![
644            (Value::text("key1".to_string()), Value::Integer(100)),
645            (Value::text("key2".to_string()), Value::Integer(200)),
646        ]);
647        assert_eq!(ValueFormatter::format_value(&map), "{key1: 100, key2: 200}");
648
649        let empty_map = Value::Map(vec![]);
650        assert_eq!(ValueFormatter::format_value(&empty_map), "{}");
651    }
652
653    #[test]
654    fn test_tuple() {
655        let tuple = Value::Tuple(vec![
656            Value::Integer(42),
657            Value::text("hello".to_string()),
658            Value::Boolean(true),
659        ]);
660        assert_eq!(ValueFormatter::format_value(&tuple), "(42, hello, true)");
661    }
662
663    #[test]
664    fn test_udt() {
665        let udt = Value::Udt(Box::new(UdtValue {
666            type_name: "person".to_string(),
667            keyspace: "test_ks".to_string(),
668            fields: vec![
669                UdtField {
670                    name: "name".to_string(),
671                    value: Some(Value::text("Alice".to_string())),
672                },
673                UdtField {
674                    name: "age".to_string(),
675                    value: Some(Value::Integer(30)),
676                },
677                UdtField {
678                    name: "email".to_string(),
679                    value: None,
680                },
681            ],
682        }));
683        assert_eq!(
684            ValueFormatter::format_value(&udt),
685            "{name: Alice, age: 30, email: null}"
686        );
687    }
688
689    #[test]
690    fn test_frozen() {
691        let frozen = Value::Frozen(Box::new(Value::List(vec![
692            Value::Integer(1),
693            Value::Integer(2),
694        ])));
695        assert_eq!(ValueFormatter::format_value(&frozen), "[1, 2]");
696    }
697
698    #[test]
699    fn test_inet() {
700        // IPv4
701        let ipv4 = Value::inet(vec![192, 168, 1, 1]);
702        assert_eq!(ValueFormatter::format_value(&ipv4), "192.168.1.1");
703
704        // IPv6
705        let ipv6 = Value::inet(vec![
706            0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
707            0x00, 0x01,
708        ]);
709        let formatted = ValueFormatter::format_value(&ipv6);
710        assert!(formatted.contains("2001:db8"));
711    }
712
713    #[test]
714    fn test_nested_collections() {
715        // List of lists
716        let nested = Value::List(vec![
717            Value::List(vec![Value::Integer(1), Value::Integer(2)]),
718            Value::List(vec![Value::Integer(3), Value::Integer(4)]),
719        ]);
720        assert_eq!(ValueFormatter::format_value(&nested), "[[1, 2], [3, 4]]");
721
722        // Map with complex values
723        let complex_map = Value::Map(vec![(
724            Value::text("data".to_string()),
725            Value::Set(vec![Value::Integer(1), Value::Integer(2)]),
726        )]);
727        assert_eq!(ValueFormatter::format_value(&complex_map), "{data: {1, 2}}");
728    }
729
730    #[test]
731    fn test_json() {
732        let json = Value::Json(Box::new(serde_json::json!({
733            "name": "Alice",
734            "age": 30
735        })));
736        let formatted = ValueFormatter::format_value(&json);
737        assert!(formatted.contains("Alice"));
738        assert!(formatted.contains("30"));
739    }
740
741    #[test]
742    fn test_varint() {
743        // Positive varint
744        let varint = Value::varint(vec![0x01, 0x00]);
745        let formatted = ValueFormatter::format_value(&varint);
746        assert_eq!(formatted, "256");
747
748        // Zero
749        let zero = Value::varint(vec![]);
750        assert_eq!(ValueFormatter::format_value(&zero), "0");
751    }
752
753    #[test]
754    fn test_decimal() {
755        // 123.45 (scale=2, unscaled=12345)
756        let decimal = Value::Decimal {
757            scale: 2,
758            unscaled: vec![0x30, 0x39], // 12345 in big-endian
759        };
760        let formatted = ValueFormatter::format_value(&decimal);
761        // Should contain decimal point
762        assert!(formatted.contains('.'));
763    }
764
765    /// Issue #1754: a corrupt SSTable can carry a pathological DECIMAL `scale`.
766    /// `scale == i32::MIN` used to overflow-panic at `(-scale) as usize` (and
767    /// under `overflow-checks` in debug builds), and a huge positive/negative
768    /// scale would allocate an unbounded string. Formatting must stay total —
769    /// never panic — and render bounded output instead.
770    #[test]
771    fn test_decimal_pathological_scale_is_bounded_not_panic() {
772        // i32::MIN scale: the negation-overflow reproducer.
773        let d_min = Value::Decimal {
774            scale: i32::MIN,
775            unscaled: vec![0x01],
776        };
777        let s = ValueFormatter::format_value(&d_min);
778        assert!(
779            s.contains('e'),
780            "extreme scale renders in exponent form: {s}"
781        );
782        assert!(
783            s.len() < 64,
784            "must not materialize an unbounded string: {s}"
785        );
786
787        // Huge positive scale: no multi-hundred-megabyte padding allocation.
788        let d_max = Value::Decimal {
789            scale: i32::MAX,
790            unscaled: vec![0x01],
791        };
792        let s2 = ValueFormatter::format_value(&d_max);
793        assert!(
794            s2.len() < 64,
795            "must not materialize an unbounded string: {s2}"
796        );
797    }
798
799    /// Issue #1754 (follow-up correctness fix): a large-but-WELL-FORMED unscaled
800    /// magnitude (thousands of digits, above the 1024-byte positional threshold
801    /// but within the sanity ceiling) must render FAITHFULLY (precision-preserving
802    /// exponent form) and FAST — NOT be misclassified as corruption. This is the
803    /// behavior the owner mandated: an arbitrary-precision BigInteger value is not
804    /// corrupt just for being big.
805    #[test]
806    fn test_decimal_large_valid_renders_faithfully_fast() {
807        // 2 KB magnitude (~4930 decimal digits): over the positional threshold,
808        // under the ceiling. 0x7f keeps the high bit clear (a large POSITIVE
809        // value; all-0xff would be two's-complement -1). Scale 4 → ×10^-4.
810        let unscaled = vec![0x7fu8; 2048];
811        let start = std::time::Instant::now();
812        let s = ValueFormatter::format_value(&Value::Decimal { scale: 4, unscaled });
813        let elapsed = start.elapsed();
814        assert!(
815            !s.starts_with("<corrupt-decimal:"),
816            "a well-formed large decimal must not be called corrupt: {s}"
817        );
818        // Precision-preserving exponent form: all significant digits + "e-4".
819        assert!(
820            s.ends_with("e-4"),
821            "expected exponent form, got: {}",
822            &s[..40]
823        );
824        let digits = s.trim_end_matches("e-4");
825        // 2 KB of 0xff is a ~4933-digit integer — every digit is preserved.
826        assert!(
827            digits.len() >= 4900 && digits.chars().all(|c| c.is_ascii_digit()),
828            "expected full digit string, got {} chars",
829            digits.len()
830        );
831        assert!(
832            elapsed < std::time::Duration::from_millis(500),
833            "expected fast single-conversion render, took {elapsed:?}"
834        );
835    }
836
837    /// Issue #1754: a genuinely pathological magnitude beyond the sanity ceiling
838    /// still fails closed FAST (O(1) length check), without the superlinear
839    /// `BigInt` base conversion. A ~415 KB magnitude exercises this.
840    #[test]
841    fn test_decimal_beyond_ceiling_bounded_fast() {
842        let unscaled = vec![0xffu8; 415_000];
843        let start = std::time::Instant::now();
844        let s = ValueFormatter::format_value(&Value::Decimal { scale: 0, unscaled });
845        let elapsed = start.elapsed();
846        assert!(
847            s.starts_with("<corrupt-decimal:"),
848            "beyond-ceiling magnitude renders the bounded fallback: {s}"
849        );
850        assert!(
851            elapsed < std::time::Duration::from_millis(500),
852            "expected fast O(1) rejection, took {elapsed:?} — the base conversion ran"
853        );
854    }
855
856    /// Regression guard: an ordinary in-range scale is byte-identical after the
857    /// #1754 bound (no behavior change for legitimate decimals).
858    #[test]
859    fn test_decimal_in_range_scale_unchanged() {
860        let d = Value::Decimal {
861            scale: 5,
862            unscaled: vec![0x7B], // 123
863        };
864        assert_eq!(ValueFormatter::format_value(&d), "0.00123");
865        let dn = Value::Decimal {
866            scale: -2,
867            unscaled: vec![0x05], // 5 → 500
868        };
869        assert_eq!(ValueFormatter::format_value(&dn), "500");
870    }
871
872    #[test]
873    fn test_is_null_only_matches_null_variant() {
874        // Issue #1499: is_null must be exact — a literal text "null" is NOT null.
875        assert!(ValueFormatter::is_null(&Value::Null));
876        assert!(!ValueFormatter::is_null(&Value::text("null".to_string())));
877        assert!(!ValueFormatter::is_null(&Value::Integer(0)));
878        assert!(!ValueFormatter::is_null(&Value::text(String::new())));
879    }
880
881    #[test]
882    fn test_is_null_unwraps_frozen_null() {
883        // Regression: a genuine CQL null in a frozen column is
884        // Value::Frozen(Box::new(Value::Null)), which format_value renders as
885        // "null". is_null must treat it (and deeper nestings) as null so CSV emits
886        // an empty field, while a frozen NON-null must remain non-null.
887        assert!(ValueFormatter::is_null(&Value::Frozen(Box::new(
888            Value::Null
889        ))));
890        assert!(ValueFormatter::is_null(&Value::Frozen(Box::new(
891            Value::Frozen(Box::new(Value::Null))
892        ))));
893        assert!(!ValueFormatter::is_null(&Value::Frozen(Box::new(
894            Value::Integer(7)
895        ))));
896        // A frozen literal text "null" is still NOT a genuine null.
897        assert!(!ValueFormatter::is_null(&Value::Frozen(Box::new(
898            Value::text("null".to_string())
899        ))));
900    }
901
902    #[test]
903    fn test_format_into_matches_format_value() {
904        // Issue #1499: format_into must be byte-identical to format_value for every
905        // representative variant, including scalar hot paths and complex fallbacks.
906        let samples = vec![
907            Value::Null,
908            Value::Boolean(true),
909            Value::Boolean(false),
910            Value::TinyInt(-7),
911            Value::SmallInt(1234),
912            Value::Integer(-2147483648),
913            Value::BigInt(9223372036854775807),
914            Value::Counter(42),
915            Value::text("hello".to_string()),
916            Value::text("null".to_string()),
917            Value::text(String::new()),
918            Value::Uuid([
919                0xa8, 0xf1, 0x67, 0xf0, 0xeb, 0xe7, 0x4f, 0x20, 0xa3, 0x86, 0x31, 0xff, 0x13, 0x8b,
920                0xec, 0x3b,
921            ]),
922            Value::Float32(3.25),
923            Value::Float(2.75),
924            Value::blob(vec![0xDE, 0xAD, 0xBE, 0xEF]),
925            Value::List(vec![Value::Integer(1), Value::Integer(2)]),
926            Value::Set(vec![Value::text("a".to_string())]),
927            Value::Map(vec![(Value::text("k".to_string()), Value::Integer(1))]),
928            Value::varint(vec![0x01, 0x00]),
929        ];
930        for v in &samples {
931            let mut buf = String::new();
932            ValueFormatter::format_into(v, &mut buf);
933            assert_eq!(
934                buf,
935                ValueFormatter::format_value(v),
936                "format_into mismatch for {v:?}"
937            );
938        }
939    }
940
941    #[test]
942    fn test_format_into_reuses_buffer() {
943        // Clearing and reusing the scratch buffer yields the same result as fresh.
944        let mut buf = String::from("stale-contents");
945        buf.clear();
946        ValueFormatter::format_into(&Value::Integer(99), &mut buf);
947        assert_eq!(buf, "99");
948    }
949
950    #[test]
951    fn test_format_uuid_into_matches_reference() {
952        let bytes = [
953            0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66,
954            0x77, 0x88,
955        ];
956        let mut s = String::new();
957        ValueFormatter::format_uuid_into(&bytes, &mut s);
958        assert_eq!(s, "12345678-9abc-def0-1122-334455667788");
959        // And the owned formatter agrees.
960        assert_eq!(ValueFormatter::format_value(&Value::Uuid(bytes)), s);
961    }
962
963    #[test]
964    fn test_format_varint_negative() {
965        // Test negative varint: -1 in big-endian two's complement
966        let negative_bytes = vec![0xFF];
967        let formatted = ValueFormatter::format_value(&Value::Varint(negative_bytes.into()));
968        assert_eq!(
969            formatted, "-1",
970            "Negative varint -1 should format correctly"
971        );
972
973        // Test larger negative number: -256
974        let negative_256 = vec![0xFF, 0x00];
975        let formatted_256 = ValueFormatter::format_value(&Value::Varint(negative_256.into()));
976        assert_eq!(
977            formatted_256, "-256",
978            "Negative varint -256 should format correctly"
979        );
980
981        // Ensure no debug markers like '<' or '>' in output
982        assert!(!formatted.contains('<'), "Should not contain debug markers");
983        assert!(!formatted.contains('>'), "Should not contain debug markers");
984    }
985}