Skip to main content

haystack_core/codecs/zinc/
parser.rs

1// Zinc recursive descent parser for scalars and grids.
2
3use crate::codecs::CodecError;
4use crate::data::{HCol, HDict, HGrid};
5use crate::kinds::*;
6use chrono::{NaiveDate, NaiveTime};
7
8/// Hand-written recursive descent parser for Zinc wire format.
9pub struct ZincParser<'a> {
10    src: &'a str,
11    pos: usize,
12}
13
14impl<'a> ZincParser<'a> {
15    /// Create a new parser for the given input.
16    pub fn new(src: &'a str) -> Self {
17        Self { src, pos: 0 }
18    }
19
20    /// Create a new parser starting at the given position within the source.
21    pub fn new_at(src: &'a str, pos: usize) -> Self {
22        Self { src, pos }
23    }
24
25    /// Return the current byte position of the parser.
26    pub fn pos(&self) -> usize {
27        self.pos
28    }
29
30    /// Parse a single scalar value, consuming the entire input.
31    pub fn parse_scalar(&mut self) -> Result<Kind, CodecError> {
32        let val = self.read_val()?;
33        self.skip_spaces();
34        if !self.at_end() {
35            return Err(self.err(format!(
36                "unexpected trailing input: {:?}",
37                &self.src[self.pos..]
38            )));
39        }
40        Ok(val)
41    }
42
43    // ── Navigation helpers ──
44
45    pub fn at_end(&self) -> bool {
46        self.pos >= self.src.len()
47    }
48
49    fn peek(&self) -> Option<char> {
50        self.src[self.pos..].chars().next()
51    }
52
53    fn peek_ahead(&self, n: usize) -> Option<char> {
54        self.src[self.pos..].chars().nth(n)
55    }
56
57    fn consume(&mut self) -> Option<char> {
58        let ch = self.peek()?;
59        self.pos += ch.len_utf8();
60        Some(ch)
61    }
62
63    fn consume_if(&mut self, ch: char) -> bool {
64        if self.peek() == Some(ch) {
65            self.pos += ch.len_utf8();
66            true
67        } else {
68            false
69        }
70    }
71
72    pub fn skip_spaces(&mut self) {
73        while let Some(ch) = self.peek() {
74            if ch == ' ' || ch == '\t' {
75                self.pos += 1;
76            } else {
77                break;
78            }
79        }
80    }
81
82    fn remaining(&self) -> &str {
83        &self.src[self.pos..]
84    }
85
86    fn err(&self, msg: impl Into<String>) -> CodecError {
87        CodecError::Parse {
88            pos: self.pos,
89            message: msg.into(),
90        }
91    }
92
93    // ── Value dispatch ──
94
95    pub fn read_val(&mut self) -> Result<Kind, CodecError> {
96        self.skip_spaces();
97        if self.at_end() {
98            return Ok(Kind::Null);
99        }
100
101        let ch = self.peek().unwrap();
102
103        // N → Null or NA
104        if ch == 'N' {
105            let next = self.peek_ahead(1);
106            if next == Some('A') && !self.is_alpha_at(2) {
107                self.pos += 2;
108                return Ok(Kind::NA);
109            }
110            if next.is_none() || !next.unwrap().is_alphanumeric() {
111                self.pos += 1;
112                return Ok(Kind::Null);
113            }
114            // Fall through to xstr_or_keyword for NaN etc.
115        }
116
117        // T → true
118        if ch == 'T' && !self.is_alpha_at(1) {
119            self.pos += 1;
120            return Ok(Kind::Bool(true));
121        }
122
123        // F → false
124        if ch == 'F' && !self.is_alpha_at(1) {
125            self.pos += 1;
126            return Ok(Kind::Bool(false));
127        }
128
129        // M → Marker
130        if ch == 'M' && !self.is_alpha_at(1) {
131            self.pos += 1;
132            return Ok(Kind::Marker);
133        }
134
135        // R → Remove
136        if ch == 'R' && !self.is_alpha_at(1) {
137            self.pos += 1;
138            return Ok(Kind::Remove);
139        }
140
141        // -INF (must check before general number since '-' followed by 'I' is not a digit)
142        if ch == '-' && self.remaining().starts_with("-INF") {
143            self.pos += 4;
144            return Ok(Kind::Number(Number::unitless(f64::NEG_INFINITY)));
145        }
146
147        // Number, Date, Time, DateTime (starts with digit or '-' followed by digit)
148        let is_neg_num = ch == '-' && self.peek_ahead(1).is_some_and(|c| c.is_ascii_digit());
149        if ch.is_ascii_digit() || is_neg_num {
150            return self.read_number();
151        }
152
153        // String
154        if ch == '"' {
155            let s = self.read_str()?;
156            return Ok(Kind::Str(s));
157        }
158
159        // Ref
160        if ch == '@' {
161            return self.read_ref();
162        }
163
164        // URI
165        if ch == '`' {
166            return self.read_uri();
167        }
168
169        // Symbol
170        if ch == '^' {
171            return self.read_symbol();
172        }
173
174        // Coord
175        if ch == 'C' && self.peek_ahead(1) == Some('(') {
176            return self.read_coord();
177        }
178
179        // List
180        if ch == '[' {
181            return self.read_list();
182        }
183
184        // Dict
185        if ch == '{' {
186            return self.read_dict();
187        }
188
189        // XStr or keyword (INF, NaN, NA, etc.)
190        if ch.is_uppercase() {
191            return self.read_xstr_or_keyword();
192        }
193
194        Err(self.err(format!("unexpected character '{ch}'")))
195    }
196
197    fn is_alpha_at(&self, offset: usize) -> bool {
198        self.src[self.pos..]
199            .chars()
200            .nth(offset)
201            .is_some_and(|c| c.is_alphanumeric())
202    }
203
204    // ── Number / Date / Time / DateTime ──
205
206    fn read_number(&mut self) -> Result<Kind, CodecError> {
207        // Check for date pattern: YYYY-MM-DD
208        if self.looks_like_date() {
209            return self.read_date_or_datetime();
210        }
211
212        // Check for time pattern: HH:MM
213        if self.looks_like_time() {
214            return self.read_time();
215        }
216
217        // Parse sign
218        let neg = self.consume_if('-');
219
220        // Integer part
221        let int_part = self.read_digits()?;
222
223        // Decimal part
224        let frac_part = if self.peek() == Some('.') {
225            self.pos += 1;
226            Some(self.read_digits()?)
227        } else {
228            None
229        };
230
231        // Exponent
232        let exp_part = if self.peek() == Some('e') || self.peek() == Some('E') {
233            let mut exp = String::new();
234            exp.push(self.consume().unwrap());
235            if self.peek() == Some('+') || self.peek() == Some('-') {
236                exp.push(self.consume().unwrap());
237            }
238            exp.push_str(&self.read_digits()?);
239            Some(exp)
240        } else {
241            None
242        };
243
244        // Build number string
245        let mut num_str = String::new();
246        if neg {
247            num_str.push('-');
248        }
249        num_str.push_str(&int_part);
250        if let Some(ref frac) = frac_part {
251            num_str.push('.');
252            num_str.push_str(frac);
253        }
254        if let Some(ref exp) = exp_part {
255            num_str.push_str(exp);
256        }
257
258        let val: f64 = num_str
259            .parse()
260            .map_err(|_| self.err(format!("invalid number: {num_str}")))?;
261
262        // Unit
263        let unit = self.read_unit();
264
265        Ok(Kind::Number(Number::new(
266            val,
267            if unit.is_empty() { None } else { Some(unit) },
268        )))
269    }
270
271    fn read_digits(&mut self) -> Result<String, CodecError> {
272        let start = self.pos;
273        while let Some(ch) = self.peek() {
274            if ch.is_ascii_digit() || ch == '_' {
275                self.pos += ch.len_utf8();
276            } else {
277                break;
278            }
279        }
280        let raw = &self.src[start..self.pos];
281        let result: String = raw.chars().filter(|&c| c != '_').collect();
282        if result.is_empty() {
283            return Err(self.err("expected digits"));
284        }
285        Ok(result)
286    }
287
288    fn read_unit(&mut self) -> String {
289        let start = self.pos;
290        let mut first = true;
291        while let Some(ch) = self.peek() {
292            if ch.is_alphabetic()
293                || ch as u32 > 127
294                || ch == '_'
295                || ch == '/'
296                || ch == '%'
297                || ch == '$'
298            {
299                self.pos += ch.len_utf8();
300                first = false;
301            } else if ch.is_ascii_digit() && !first {
302                // Digits allowed after first unit char
303                self.pos += 1;
304            } else {
305                break;
306            }
307        }
308        self.src[start..self.pos].to_string()
309    }
310
311    fn looks_like_date(&self) -> bool {
312        // YYYY-MM-DD: need at least 10 chars
313        let rem = self.remaining();
314        if rem.len() < 10 {
315            return false;
316        }
317        let bytes = rem.as_bytes();
318        bytes[0..4].iter().all(|b| b.is_ascii_digit())
319            && bytes[4] == b'-'
320            && bytes[5..7].iter().all(|b| b.is_ascii_digit())
321            && bytes[7] == b'-'
322            && bytes[8..10].iter().all(|b| b.is_ascii_digit())
323    }
324
325    fn looks_like_time(&self) -> bool {
326        // HH:MM
327        let rem = self.remaining();
328        if rem.len() < 5 {
329            return false;
330        }
331        let bytes = rem.as_bytes();
332        bytes[0..2].iter().all(|b| b.is_ascii_digit())
333            && bytes[2] == b':'
334            && bytes[3..5].iter().all(|b| b.is_ascii_digit())
335    }
336
337    fn read_date_or_datetime(&mut self) -> Result<Kind, CodecError> {
338        // Read YYYY-MM-DD
339        let date_str = &self.src[self.pos..self.pos + 10];
340        let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
341            .map_err(|e| self.err(format!("invalid date: {e}")))?;
342        self.pos += 10;
343
344        // Check for T → datetime
345        if self.peek() == Some('T') {
346            return self.read_datetime_after_date(date);
347        }
348
349        Ok(Kind::Date(date))
350    }
351
352    fn read_datetime_after_date(&mut self, date: NaiveDate) -> Result<Kind, CodecError> {
353        self.pos += 1; // skip T
354        let time_str = self.read_time_str()?;
355        let offset_str = self.read_offset()?;
356
357        // Build ISO string and parse
358        let iso = format!("{}T{}{}", date, time_str, offset_str);
359        let dt = chrono::DateTime::parse_from_str(&iso, "%Y-%m-%dT%H:%M:%S%.f%:z")
360            .or_else(|_| chrono::DateTime::parse_from_str(&iso, "%Y-%m-%dT%H:%M:%S%:z"))
361            .map_err(|e| self.err(format!("invalid datetime: {e} (from '{iso}')")))?;
362
363        // Read optional timezone name
364        self.skip_spaces();
365        let tz_name = self.read_tz_name();
366
367        let tz = if tz_name.is_empty() {
368            "UTC".to_string()
369        } else {
370            tz_name
371        };
372
373        Ok(Kind::DateTime(HDateTime::new(dt, tz)))
374    }
375
376    fn read_time_str(&mut self) -> Result<String, CodecError> {
377        let start = self.pos;
378        // HH:MM
379        if self.remaining().len() < 5 {
380            return Err(self.err("expected time HH:MM"));
381        }
382        self.pos += 5;
383        // Optional :SS
384        if self.peek() == Some(':') {
385            if self.remaining().len() < 3 {
386                return Err(self.err("incomplete seconds in time"));
387            }
388            self.pos += 3; // :SS
389            // Optional .FFF...
390            if self.peek() == Some('.') {
391                self.pos += 1;
392                while let Some(ch) = self.peek() {
393                    if ch.is_ascii_digit() {
394                        self.pos += 1;
395                    } else {
396                        break;
397                    }
398                }
399            }
400        }
401        Ok(self.src[start..self.pos].to_string())
402    }
403
404    fn read_offset(&mut self) -> Result<String, CodecError> {
405        if self.at_end() {
406            return Ok(String::new());
407        }
408        if self.peek() == Some('Z') {
409            self.pos += 1;
410            return Ok("+00:00".to_string());
411        }
412        if self.peek() == Some('+') || self.peek() == Some('-') {
413            let start = self.pos;
414            if self.remaining().len() < 3 {
415                return Err(self.err("incomplete UTC offset"));
416            }
417            self.pos += 1; // sign
418            self.pos += 2; // HH
419            if self.peek() == Some(':') {
420                if self.remaining().len() < 3 {
421                    return Err(self.err("incomplete UTC offset minutes"));
422                }
423                self.pos += 3; // :MM
424            }
425            return Ok(self.src[start..self.pos].to_string());
426        }
427        Ok(String::new())
428    }
429
430    fn read_tz_name(&mut self) -> String {
431        let start = self.pos;
432        while let Some(ch) = self.peek() {
433            if ch.is_alphanumeric() || ch == '_' || ch == '-' || ch == '/' {
434                self.pos += ch.len_utf8();
435            } else {
436                break;
437            }
438        }
439        self.src[start..self.pos].to_string()
440    }
441
442    fn read_time(&mut self) -> Result<Kind, CodecError> {
443        let time_str = self.read_time_str()?;
444        let time = NaiveTime::parse_from_str(&time_str, "%H:%M:%S%.f")
445            .or_else(|_| NaiveTime::parse_from_str(&time_str, "%H:%M:%S"))
446            .or_else(|_| NaiveTime::parse_from_str(&time_str, "%H:%M"))
447            .map_err(|e| self.err(format!("invalid time: {e}")))?;
448        Ok(Kind::Time(time))
449    }
450
451    // ── Strings ──
452
453    fn read_str(&mut self) -> Result<String, CodecError> {
454        self.pos += 1; // skip opening "
455        let mut result = String::new();
456        while !self.at_end() {
457            let ch = self.peek().unwrap();
458            if ch == '"' {
459                self.pos += 1;
460                return Ok(result);
461            }
462            if ch == '\\' {
463                self.pos += 1;
464                result.push(self.read_escape()?);
465            } else {
466                result.push(ch);
467                self.pos += ch.len_utf8();
468            }
469        }
470        Err(self.err("unterminated string"))
471    }
472
473    fn read_escape(&mut self) -> Result<char, CodecError> {
474        if self.at_end() {
475            return Err(self.err("unexpected end of escape sequence"));
476        }
477        let ch = self.consume().unwrap();
478        match ch {
479            'n' => Ok('\n'),
480            'r' => Ok('\r'),
481            't' => Ok('\t'),
482            '\\' => Ok('\\'),
483            '"' => Ok('"'),
484            '$' => Ok('$'),
485            'b' => Ok('\u{0008}'),
486            'f' => Ok('\u{000C}'),
487            'u' => {
488                if self.remaining().len() < 4 {
489                    return Err(self.err("incomplete unicode escape"));
490                }
491                let hex = &self.src[self.pos..self.pos + 4];
492                self.pos += 4;
493                let code = u32::from_str_radix(hex, 16)
494                    .map_err(|_| self.err(format!("invalid unicode escape: {hex}")))?;
495                char::from_u32(code)
496                    .ok_or_else(|| self.err(format!("invalid unicode codepoint: {code}")))
497            }
498            _ => Err(self.err(format!("unknown escape sequence: \\{ch}"))),
499        }
500    }
501
502    // ── Ref ──
503
504    fn read_ref(&mut self) -> Result<Kind, CodecError> {
505        self.pos += 1; // skip @
506        let start = self.pos;
507        while let Some(ch) = self.peek() {
508            if is_ref_char(ch) {
509                self.pos += ch.len_utf8();
510            } else {
511                break;
512            }
513        }
514        let val = self.src[start..self.pos].to_string();
515
516        // Optional display string
517        self.skip_spaces();
518        let dis = if self.peek() == Some('"') {
519            Some(self.read_str()?)
520        } else {
521            None
522        };
523
524        Ok(Kind::Ref(HRef::new(val, dis)))
525    }
526
527    // ── URI ──
528
529    fn read_uri(&mut self) -> Result<Kind, CodecError> {
530        self.pos += 1; // skip `
531        let mut result = String::new();
532        while !self.at_end() {
533            let ch = self.peek().unwrap();
534            if ch == '`' {
535                self.pos += 1;
536                return Ok(Kind::Uri(Uri::new(result)));
537            }
538            if ch == '\\' {
539                self.pos += 1;
540                if let Some(next) = self.consume() {
541                    result.push(next);
542                }
543            } else {
544                result.push(ch);
545                self.pos += ch.len_utf8();
546            }
547        }
548        Err(self.err("unterminated URI"))
549    }
550
551    // ── Symbol ──
552
553    fn read_symbol(&mut self) -> Result<Kind, CodecError> {
554        self.pos += 1; // skip ^
555        let start = self.pos;
556        while let Some(ch) = self.peek() {
557            if is_ref_char(ch) {
558                self.pos += ch.len_utf8();
559            } else {
560                break;
561            }
562        }
563        Ok(Kind::Symbol(Symbol::new(&self.src[start..self.pos])))
564    }
565
566    // ── Coord ──
567
568    fn read_coord(&mut self) -> Result<Kind, CodecError> {
569        self.pos += 2; // skip C(
570        let start = self.pos;
571        while self.peek() != Some(',') && !self.at_end() {
572            self.pos += 1;
573        }
574        if self.at_end() {
575            return Err(self.err("unterminated coord literal, expected ','"));
576        }
577        let lat: f64 = self.src[start..self.pos]
578            .trim()
579            .parse()
580            .map_err(|_| self.err("invalid coord latitude"))?;
581        if !(-90.0..=90.0).contains(&lat) {
582            return Err(self.err("coord latitude must be between -90 and 90"));
583        }
584        self.pos += 1; // skip comma
585        let start = self.pos;
586        while self.peek() != Some(')') && !self.at_end() {
587            self.pos += 1;
588        }
589        if self.at_end() {
590            return Err(self.err("unterminated coord literal, expected ')'"));
591        }
592        let lng: f64 = self.src[start..self.pos]
593            .trim()
594            .parse()
595            .map_err(|_| self.err("invalid coord longitude"))?;
596        if !(-180.0..=180.0).contains(&lng) {
597            return Err(self.err("coord longitude must be between -180 and 180"));
598        }
599        self.pos += 1; // skip )
600        Ok(Kind::Coord(Coord::new(lat, lng)))
601    }
602
603    // ── List ──
604
605    fn read_list(&mut self) -> Result<Kind, CodecError> {
606        self.pos += 1; // skip [
607        let mut vals = Vec::new();
608        self.skip_spaces();
609        while !self.at_end() && self.peek() != Some(']') {
610            vals.push(self.read_val()?);
611            self.skip_spaces();
612            self.consume_if(',');
613            self.skip_spaces();
614        }
615        if !self.at_end() {
616            self.pos += 1; // skip ]
617        }
618        Ok(Kind::List(vals))
619    }
620
621    // ── Dict ──
622
623    fn read_dict(&mut self) -> Result<Kind, CodecError> {
624        self.pos += 1; // skip {
625        let mut dict = HDict::new();
626        self.skip_spaces();
627        while !self.at_end() && self.peek() != Some('}') {
628            let name = self.read_tag_name()?;
629            self.skip_spaces();
630            if self.peek() == Some(':') {
631                self.pos += 1;
632                self.skip_spaces();
633                let val = self.read_val()?;
634                dict.set(name, val);
635            } else {
636                dict.set(name, Kind::Marker);
637            }
638            self.skip_spaces();
639            self.consume_if(',');
640            self.skip_spaces();
641        }
642        if !self.at_end() {
643            self.pos += 1; // skip }
644        }
645        Ok(Kind::Dict(Box::new(dict)))
646    }
647
648    fn read_tag_name(&mut self) -> Result<String, CodecError> {
649        let start = self.pos;
650        while let Some(ch) = self.peek() {
651            if ch.is_alphanumeric() || ch == '_' {
652                self.pos += ch.len_utf8();
653            } else {
654                break;
655            }
656        }
657        let name = self.src[start..self.pos].to_string();
658        if name.is_empty() {
659            return Err(self.err("expected tag name"));
660        }
661        Ok(name)
662    }
663
664    // ── XStr or keyword ──
665
666    fn read_xstr_or_keyword(&mut self) -> Result<Kind, CodecError> {
667        let start = self.pos;
668        while let Some(ch) = self.peek() {
669            if ch.is_alphanumeric() || ch == '_' {
670                self.pos += ch.len_utf8();
671            } else {
672                break;
673            }
674        }
675        let name = &self.src[start..self.pos];
676
677        match name {
678            "INF" => return Ok(Kind::Number(Number::unitless(f64::INFINITY))),
679            "NaN" => return Ok(Kind::Number(Number::unitless(f64::NAN))),
680            "NA" => return Ok(Kind::NA),
681            _ => {}
682        }
683
684        // XStr: Type("value")
685        if self.peek() == Some('(') {
686            self.pos += 1; // skip (
687            self.skip_spaces();
688            let val = self.read_str()?;
689            self.skip_spaces();
690            if self.peek() == Some(')') {
691                self.pos += 1;
692            }
693            return Ok(Kind::XStr(XStr::new(name, val)));
694        }
695
696        Err(self.err(format!("unknown keyword '{name}'")))
697    }
698
699    /// Read an identifier (alphanumeric + underscore).
700    pub fn read_id(&mut self) -> String {
701        let start = self.pos;
702        while let Some(ch) = self.peek() {
703            if ch.is_alphanumeric() || ch == '_' {
704                self.pos += ch.len_utf8();
705            } else {
706                break;
707            }
708        }
709        self.src[start..self.pos].to_string()
710    }
711}
712
713fn is_ref_char(ch: char) -> bool {
714    ch.is_alphanumeric() || ch == '_' || ch == ':' || ch == '-' || ch == '.' || ch == '~'
715}
716
717/// Decode a single Zinc scalar value from a string.
718pub fn decode_scalar(input: &str) -> Result<Kind, CodecError> {
719    let mut parser = ZincParser::new(input.trim());
720    parser.parse_scalar()
721}
722
723// ── Grid decoding ──
724
725/// Decode a Zinc-formatted string into an HGrid.
726pub fn decode_grid(input: &str) -> Result<HGrid, CodecError> {
727    let lines: Vec<&str> = input
728        .lines()
729        .map(|l| l.trim())
730        .filter(|l| !l.is_empty() && !l.starts_with("//"))
731        .collect();
732
733    if lines.is_empty() {
734        return Ok(HGrid::new());
735    }
736
737    let mut line_idx = 0;
738
739    // Line 1: ver + grid meta
740    let ver_line = lines[line_idx];
741    line_idx += 1;
742
743    if !ver_line.starts_with("ver:") {
744        return Err(CodecError::Parse {
745            pos: 0,
746            message: format!("expected 'ver:' header, got: {ver_line:?}"),
747        });
748    }
749
750    // Skip past ver:"X.X"
751    let meta = parse_ver_line_meta(ver_line)?;
752
753    // Line 2: columns
754    if line_idx >= lines.len() {
755        return Ok(HGrid::from_parts(meta, vec![], vec![]));
756    }
757
758    let col_line = lines[line_idx];
759    line_idx += 1;
760
761    let cols = if col_line == "empty" {
762        vec![]
763    } else {
764        parse_cols(col_line)?
765    };
766
767    // Remaining lines: rows
768    let mut rows = Vec::new();
769    while line_idx < lines.len() {
770        let row_line = lines[line_idx];
771        line_idx += 1;
772        if row_line.is_empty() {
773            continue;
774        }
775        let row = parse_row(row_line, &cols)?;
776        rows.push(row);
777    }
778
779    Ok(HGrid::from_parts(meta, cols, rows))
780}
781
782fn parse_ver_line_meta(ver_line: &str) -> Result<HDict, CodecError> {
783    // Skip past ver:"X.X"
784    let mut parser = ZincParser::new(ver_line);
785    // Consume ver:"3.0" — find the first space
786    while !parser.at_end() && parser.peek() != Some(' ') {
787        parser.consume();
788    }
789    parser.skip_spaces();
790    if parser.at_end() {
791        return Ok(HDict::new());
792    }
793    parse_inline_meta(&mut parser)
794}
795
796fn parse_cols(line: &str) -> Result<Vec<HCol>, CodecError> {
797    let parts = split_csv_aware(line);
798    let mut cols = Vec::new();
799    for part in parts {
800        let part = part.trim();
801        if part.is_empty() {
802            continue;
803        }
804        let mut parser = ZincParser::new(part);
805        let name = read_col_name(&mut parser);
806        parser.skip_spaces();
807        let meta = if !parser.at_end() {
808            parse_inline_meta(&mut parser)?
809        } else {
810            HDict::new()
811        };
812        cols.push(HCol::with_meta(name, meta));
813    }
814    Ok(cols)
815}
816
817fn parse_row(line: &str, cols: &[HCol]) -> Result<HDict, CodecError> {
818    let parts = split_csv_aware(line);
819    let mut dict = HDict::new();
820    for (i, col) in cols.iter().enumerate() {
821        if i < parts.len() {
822            let cell = parts[i].trim();
823            if !cell.is_empty() && cell != "N" {
824                let mut parser = ZincParser::new(cell);
825                let val = parser.read_val()?;
826                dict.set(&col.name, val);
827            }
828        }
829    }
830    Ok(dict)
831}
832
833fn parse_inline_meta(parser: &mut ZincParser<'_>) -> Result<HDict, CodecError> {
834    let mut dict = HDict::new();
835    while !parser.at_end() {
836        parser.skip_spaces();
837        if parser.at_end() {
838            break;
839        }
840        let name = read_col_name(parser);
841        if name.is_empty() {
842            break;
843        }
844        parser.skip_spaces();
845        if parser.peek() == Some(':') {
846            parser.consume();
847            parser.skip_spaces();
848            let val = parser.read_val()?;
849            dict.set(name, val);
850        } else {
851            dict.set(name, Kind::Marker);
852        }
853        parser.skip_spaces();
854    }
855    Ok(dict)
856}
857
858fn read_col_name(parser: &mut ZincParser<'_>) -> String {
859    parser.read_id()
860}
861
862/// Split a line by commas, respecting quoted strings and nested structures.
863fn split_csv_aware(line: &str) -> Vec<String> {
864    let mut parts = Vec::new();
865    let mut current = String::new();
866    let mut depth = 0i32;
867    let mut in_str = false;
868    let mut escaped = false;
869
870    for ch in line.chars() {
871        if escaped {
872            current.push(ch);
873            escaped = false;
874            continue;
875        }
876        if ch == '\\' {
877            current.push(ch);
878            escaped = true;
879            continue;
880        }
881        if ch == '"' && depth == 0 {
882            in_str = !in_str;
883            current.push(ch);
884            continue;
885        }
886        if in_str {
887            current.push(ch);
888            continue;
889        }
890        match ch {
891            '(' | '[' | '{' => {
892                depth += 1;
893                current.push(ch);
894            }
895            ')' | ']' | '}' => {
896                depth -= 1;
897                current.push(ch);
898            }
899            ',' if depth == 0 => {
900                parts.push(std::mem::take(&mut current));
901            }
902            _ => {
903                current.push(ch);
904            }
905        }
906    }
907    parts.push(current);
908    parts
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914    use crate::data::{HDict, HGrid};
915    use chrono::{Datelike, FixedOffset, NaiveDate, NaiveTime, TimeZone};
916
917    // ── Scalar round-trip tests ──
918
919    fn round_trip(kind: &Kind) -> Kind {
920        let encoded = crate::codecs::zinc::encode_scalar(kind).unwrap();
921        decode_scalar(&encoded).unwrap()
922    }
923
924    #[test]
925    fn parse_null() {
926        assert_eq!(decode_scalar("N").unwrap(), Kind::Null);
927    }
928
929    #[test]
930    fn parse_true() {
931        assert_eq!(decode_scalar("T").unwrap(), Kind::Bool(true));
932    }
933
934    #[test]
935    fn parse_false() {
936        assert_eq!(decode_scalar("F").unwrap(), Kind::Bool(false));
937    }
938
939    #[test]
940    fn parse_marker() {
941        assert_eq!(decode_scalar("M").unwrap(), Kind::Marker);
942    }
943
944    #[test]
945    fn parse_na() {
946        assert_eq!(decode_scalar("NA").unwrap(), Kind::NA);
947    }
948
949    #[test]
950    fn parse_remove() {
951        assert_eq!(decode_scalar("R").unwrap(), Kind::Remove);
952    }
953
954    #[test]
955    fn roundtrip_null() {
956        assert_eq!(round_trip(&Kind::Null), Kind::Null);
957    }
958
959    #[test]
960    fn roundtrip_bool_true() {
961        assert_eq!(round_trip(&Kind::Bool(true)), Kind::Bool(true));
962    }
963
964    #[test]
965    fn roundtrip_bool_false() {
966        assert_eq!(round_trip(&Kind::Bool(false)), Kind::Bool(false));
967    }
968
969    #[test]
970    fn roundtrip_marker() {
971        assert_eq!(round_trip(&Kind::Marker), Kind::Marker);
972    }
973
974    #[test]
975    fn roundtrip_na() {
976        assert_eq!(round_trip(&Kind::NA), Kind::NA);
977    }
978
979    #[test]
980    fn roundtrip_remove() {
981        assert_eq!(round_trip(&Kind::Remove), Kind::Remove);
982    }
983
984    // ── Numbers ──
985
986    #[test]
987    fn parse_number_zero() {
988        assert_eq!(
989            decode_scalar("0").unwrap(),
990            Kind::Number(Number::unitless(0.0))
991        );
992    }
993
994    #[test]
995    fn parse_number_integer() {
996        assert_eq!(
997            decode_scalar("42").unwrap(),
998            Kind::Number(Number::unitless(42.0))
999        );
1000    }
1001
1002    #[test]
1003    fn parse_number_float() {
1004        assert_eq!(
1005            decode_scalar("72.5").unwrap(),
1006            Kind::Number(Number::unitless(72.5))
1007        );
1008    }
1009
1010    #[test]
1011    fn parse_number_negative() {
1012        assert_eq!(
1013            decode_scalar("-23.45").unwrap(),
1014            Kind::Number(Number::unitless(-23.45))
1015        );
1016    }
1017
1018    #[test]
1019    fn parse_number_scientific() {
1020        let k = decode_scalar("5.4e8").unwrap();
1021        if let Kind::Number(n) = &k {
1022            assert!((n.val - 5.4e8).abs() < 1.0);
1023        } else {
1024            panic!("expected Number, got {k:?}");
1025        }
1026    }
1027
1028    #[test]
1029    fn parse_number_inf() {
1030        let k = decode_scalar("INF").unwrap();
1031        if let Kind::Number(n) = &k {
1032            assert!(n.val.is_infinite() && n.val > 0.0);
1033        } else {
1034            panic!("expected Number(INF)");
1035        }
1036    }
1037
1038    #[test]
1039    fn parse_number_neg_inf() {
1040        let k = decode_scalar("-INF").unwrap();
1041        if let Kind::Number(n) = &k {
1042            assert!(n.val.is_infinite() && n.val < 0.0);
1043        } else {
1044            panic!("expected Number(-INF)");
1045        }
1046    }
1047
1048    #[test]
1049    fn parse_number_nan() {
1050        let k = decode_scalar("NaN").unwrap();
1051        if let Kind::Number(n) = &k {
1052            assert!(n.val.is_nan());
1053        } else {
1054            panic!("expected Number(NaN)");
1055        }
1056    }
1057
1058    #[test]
1059    fn parse_number_with_unit() {
1060        let k = decode_scalar("72.5\u{00B0}F").unwrap();
1061        if let Kind::Number(n) = &k {
1062            assert_eq!(n.val, 72.5);
1063            assert_eq!(n.unit.as_deref(), Some("\u{00B0}F"));
1064        } else {
1065            panic!("expected Number with unit");
1066        }
1067    }
1068
1069    #[test]
1070    fn roundtrip_number_zero() {
1071        assert_eq!(
1072            round_trip(&Kind::Number(Number::unitless(0.0))),
1073            Kind::Number(Number::unitless(0.0))
1074        );
1075    }
1076
1077    #[test]
1078    fn roundtrip_number_integer() {
1079        assert_eq!(
1080            round_trip(&Kind::Number(Number::unitless(42.0))),
1081            Kind::Number(Number::unitless(42.0))
1082        );
1083    }
1084
1085    #[test]
1086    fn roundtrip_number_float() {
1087        assert_eq!(
1088            round_trip(&Kind::Number(Number::unitless(72.5))),
1089            Kind::Number(Number::unitless(72.5))
1090        );
1091    }
1092
1093    #[test]
1094    fn roundtrip_number_negative() {
1095        assert_eq!(
1096            round_trip(&Kind::Number(Number::unitless(-23.45))),
1097            Kind::Number(Number::unitless(-23.45))
1098        );
1099    }
1100
1101    #[test]
1102    fn roundtrip_number_with_unit() {
1103        let k = Kind::Number(Number::new(72.5, Some("\u{00B0}F".into())));
1104        let rt = round_trip(&k);
1105        if let Kind::Number(n) = &rt {
1106            assert_eq!(n.val, 72.5);
1107            assert_eq!(n.unit.as_deref(), Some("\u{00B0}F"));
1108        } else {
1109            panic!("expected Number");
1110        }
1111    }
1112
1113    #[test]
1114    fn roundtrip_inf() {
1115        let k = Kind::Number(Number::unitless(f64::INFINITY));
1116        let rt = round_trip(&k);
1117        if let Kind::Number(n) = &rt {
1118            assert!(n.val.is_infinite() && n.val > 0.0);
1119        } else {
1120            panic!("expected Number(INF)");
1121        }
1122    }
1123
1124    #[test]
1125    fn roundtrip_neg_inf() {
1126        let k = Kind::Number(Number::unitless(f64::NEG_INFINITY));
1127        let rt = round_trip(&k);
1128        if let Kind::Number(n) = &rt {
1129            assert!(n.val.is_infinite() && n.val < 0.0);
1130        } else {
1131            panic!("expected Number(-INF)");
1132        }
1133    }
1134
1135    #[test]
1136    fn roundtrip_nan() {
1137        let k = Kind::Number(Number::unitless(f64::NAN));
1138        let rt = round_trip(&k);
1139        if let Kind::Number(n) = &rt {
1140            assert!(n.val.is_nan());
1141        } else {
1142            panic!("expected Number(NaN)");
1143        }
1144    }
1145
1146    // ── Strings ──
1147
1148    #[test]
1149    fn parse_string_empty() {
1150        assert_eq!(decode_scalar("\"\"").unwrap(), Kind::Str(String::new()));
1151    }
1152
1153    #[test]
1154    fn parse_string_simple() {
1155        assert_eq!(
1156            decode_scalar("\"hello\"").unwrap(),
1157            Kind::Str("hello".into())
1158        );
1159    }
1160
1161    #[test]
1162    fn parse_string_escapes() {
1163        assert_eq!(
1164            decode_scalar("\"line1\\nline2\"").unwrap(),
1165            Kind::Str("line1\nline2".into())
1166        );
1167        assert_eq!(
1168            decode_scalar("\"tab\\there\"").unwrap(),
1169            Kind::Str("tab\there".into())
1170        );
1171        assert_eq!(
1172            decode_scalar("\"back\\\\slash\"").unwrap(),
1173            Kind::Str("back\\slash".into())
1174        );
1175        assert_eq!(
1176            decode_scalar("\"q\\\"uote\"").unwrap(),
1177            Kind::Str("q\"uote".into())
1178        );
1179        assert_eq!(
1180            decode_scalar("\"dollar\\$sign\"").unwrap(),
1181            Kind::Str("dollar$sign".into())
1182        );
1183    }
1184
1185    #[test]
1186    fn parse_string_unicode_escape() {
1187        assert_eq!(decode_scalar("\"\\u0041\"").unwrap(), Kind::Str("A".into()));
1188    }
1189
1190    #[test]
1191    fn roundtrip_string_empty() {
1192        assert_eq!(
1193            round_trip(&Kind::Str(String::new())),
1194            Kind::Str(String::new())
1195        );
1196    }
1197
1198    #[test]
1199    fn roundtrip_string_escapes() {
1200        let s = "line1\nline2\ttab\\slash\"quote$dollar";
1201        assert_eq!(round_trip(&Kind::Str(s.into())), Kind::Str(s.into()));
1202    }
1203
1204    // ── Refs ──
1205
1206    #[test]
1207    fn parse_ref_simple() {
1208        let k = decode_scalar("@site-1").unwrap();
1209        if let Kind::Ref(r) = &k {
1210            assert_eq!(r.val, "site-1");
1211            assert_eq!(r.dis, None);
1212        } else {
1213            panic!("expected Ref");
1214        }
1215    }
1216
1217    #[test]
1218    fn parse_ref_with_dis() {
1219        let k = decode_scalar("@site-1 \"Main Site\"").unwrap();
1220        if let Kind::Ref(r) = &k {
1221            assert_eq!(r.val, "site-1");
1222            assert_eq!(r.dis, Some("Main Site".into()));
1223        } else {
1224            panic!("expected Ref");
1225        }
1226    }
1227
1228    #[test]
1229    fn roundtrip_ref_simple() {
1230        let k = Kind::Ref(HRef::from_val("site-1"));
1231        let rt = round_trip(&k);
1232        if let Kind::Ref(r) = &rt {
1233            assert_eq!(r.val, "site-1");
1234        } else {
1235            panic!("expected Ref");
1236        }
1237    }
1238
1239    #[test]
1240    fn roundtrip_ref_with_dis() {
1241        let k = Kind::Ref(HRef::new("site-1", Some("Main Site".into())));
1242        let rt = round_trip(&k);
1243        if let Kind::Ref(r) = &rt {
1244            assert_eq!(r.val, "site-1");
1245            assert_eq!(r.dis, Some("Main Site".into()));
1246        } else {
1247            panic!("expected Ref");
1248        }
1249    }
1250
1251    // ── URIs ──
1252
1253    #[test]
1254    fn parse_uri_simple() {
1255        let k = decode_scalar("`http://example.com`").unwrap();
1256        assert_eq!(k, Kind::Uri(Uri::new("http://example.com")));
1257    }
1258
1259    #[test]
1260    fn parse_uri_with_special() {
1261        let k = decode_scalar("`http://ex.com/path?q=1&b=2`").unwrap();
1262        assert_eq!(k, Kind::Uri(Uri::new("http://ex.com/path?q=1&b=2")));
1263    }
1264
1265    #[test]
1266    fn roundtrip_uri() {
1267        let k = Kind::Uri(Uri::new("http://example.com/path"));
1268        assert_eq!(round_trip(&k), k);
1269    }
1270
1271    // ── Symbols ──
1272
1273    #[test]
1274    fn parse_symbol_simple() {
1275        let k = decode_scalar("^site").unwrap();
1276        assert_eq!(k, Kind::Symbol(Symbol::new("site")));
1277    }
1278
1279    #[test]
1280    fn parse_symbol_compound() {
1281        let k = decode_scalar("^hot-water").unwrap();
1282        assert_eq!(k, Kind::Symbol(Symbol::new("hot-water")));
1283    }
1284
1285    #[test]
1286    fn roundtrip_symbol() {
1287        let k = Kind::Symbol(Symbol::new("hot-water"));
1288        assert_eq!(round_trip(&k), k);
1289    }
1290
1291    // ── Dates ──
1292
1293    #[test]
1294    fn parse_date() {
1295        let k = decode_scalar("2024-03-13").unwrap();
1296        assert_eq!(k, Kind::Date(NaiveDate::from_ymd_opt(2024, 3, 13).unwrap()));
1297    }
1298
1299    #[test]
1300    fn roundtrip_date() {
1301        let k = Kind::Date(NaiveDate::from_ymd_opt(2024, 3, 13).unwrap());
1302        assert_eq!(round_trip(&k), k);
1303    }
1304
1305    // ── Times ──
1306
1307    #[test]
1308    fn parse_time() {
1309        let k = decode_scalar("08:12:05").unwrap();
1310        assert_eq!(k, Kind::Time(NaiveTime::from_hms_opt(8, 12, 5).unwrap()));
1311    }
1312
1313    #[test]
1314    fn parse_time_with_frac() {
1315        let k = decode_scalar("14:30:00.123").unwrap();
1316        assert_eq!(
1317            k,
1318            Kind::Time(NaiveTime::from_hms_milli_opt(14, 30, 0, 123).unwrap())
1319        );
1320    }
1321
1322    #[test]
1323    fn roundtrip_time() {
1324        let k = Kind::Time(NaiveTime::from_hms_opt(8, 12, 5).unwrap());
1325        assert_eq!(round_trip(&k), k);
1326    }
1327
1328    #[test]
1329    fn roundtrip_time_frac() {
1330        let k = Kind::Time(NaiveTime::from_hms_milli_opt(14, 30, 0, 123).unwrap());
1331        assert_eq!(round_trip(&k), k);
1332    }
1333
1334    // ── DateTimes ──
1335
1336    #[test]
1337    fn parse_datetime() {
1338        let k = decode_scalar("2024-01-01T08:12:05-05:00 New_York").unwrap();
1339        if let Kind::DateTime(hdt) = &k {
1340            assert_eq!(hdt.tz_name, "New_York");
1341            assert_eq!(hdt.dt.year(), 2024);
1342        } else {
1343            panic!("expected DateTime");
1344        }
1345    }
1346
1347    #[test]
1348    fn parse_datetime_utc() {
1349        let k = decode_scalar("2024-06-15T12:00:00+00:00 UTC").unwrap();
1350        if let Kind::DateTime(hdt) = &k {
1351            assert_eq!(hdt.tz_name, "UTC");
1352        } else {
1353            panic!("expected DateTime");
1354        }
1355    }
1356
1357    #[test]
1358    fn parse_datetime_z() {
1359        let k = decode_scalar("2024-06-15T12:00:00Z UTC").unwrap();
1360        if let Kind::DateTime(hdt) = &k {
1361            assert_eq!(hdt.tz_name, "UTC");
1362            assert_eq!(hdt.dt.offset(), &FixedOffset::east_opt(0).unwrap());
1363        } else {
1364            panic!("expected DateTime");
1365        }
1366    }
1367
1368    #[test]
1369    fn roundtrip_datetime() {
1370        let offset = FixedOffset::west_opt(5 * 3600).unwrap();
1371        let dt = offset.with_ymd_and_hms(2024, 1, 1, 8, 12, 5).unwrap();
1372        let k = Kind::DateTime(HDateTime::new(dt, "New_York"));
1373        let rt = round_trip(&k);
1374        if let Kind::DateTime(hdt) = &rt {
1375            assert_eq!(hdt.tz_name, "New_York");
1376            assert_eq!(hdt.dt, dt);
1377        } else {
1378            panic!("expected DateTime");
1379        }
1380    }
1381
1382    #[test]
1383    fn roundtrip_datetime_utc() {
1384        let offset = FixedOffset::east_opt(0).unwrap();
1385        let dt = offset.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
1386        let k = Kind::DateTime(HDateTime::new(dt, "UTC"));
1387        let rt = round_trip(&k);
1388        if let Kind::DateTime(hdt) = &rt {
1389            assert_eq!(hdt.tz_name, "UTC");
1390            assert_eq!(hdt.dt, dt);
1391        } else {
1392            panic!("expected DateTime");
1393        }
1394    }
1395
1396    // ── Coords ──
1397
1398    #[test]
1399    fn parse_coord() {
1400        let k = decode_scalar("C(37.5458266,-77.4491888)").unwrap();
1401        assert_eq!(k, Kind::Coord(Coord::new(37.5458266, -77.4491888)));
1402    }
1403
1404    #[test]
1405    fn parse_coord_negative() {
1406        let k = decode_scalar("C(-33.8688,151.2093)").unwrap();
1407        assert_eq!(k, Kind::Coord(Coord::new(-33.8688, 151.2093)));
1408    }
1409
1410    #[test]
1411    fn roundtrip_coord() {
1412        let k = Kind::Coord(Coord::new(37.5458266, -77.4491888));
1413        assert_eq!(round_trip(&k), k);
1414    }
1415
1416    // ── XStr ──
1417
1418    #[test]
1419    fn parse_xstr() {
1420        let k = decode_scalar("Color(\"red\")").unwrap();
1421        assert_eq!(k, Kind::XStr(XStr::new("Color", "red")));
1422    }
1423
1424    #[test]
1425    fn roundtrip_xstr() {
1426        let k = Kind::XStr(XStr::new("Color", "red"));
1427        assert_eq!(round_trip(&k), k);
1428    }
1429
1430    // ── Lists ──
1431
1432    #[test]
1433    fn parse_list_empty() {
1434        assert_eq!(decode_scalar("[]").unwrap(), Kind::List(vec![]));
1435    }
1436
1437    #[test]
1438    fn parse_list_mixed() {
1439        let k = decode_scalar("[1, \"two\", M]").unwrap();
1440        assert_eq!(
1441            k,
1442            Kind::List(vec![
1443                Kind::Number(Number::unitless(1.0)),
1444                Kind::Str("two".into()),
1445                Kind::Marker,
1446            ])
1447        );
1448    }
1449
1450    #[test]
1451    fn parse_list_nested() {
1452        let k = decode_scalar("[[1, 2], [3, 4]]").unwrap();
1453        assert_eq!(
1454            k,
1455            Kind::List(vec![
1456                Kind::List(vec![
1457                    Kind::Number(Number::unitless(1.0)),
1458                    Kind::Number(Number::unitless(2.0)),
1459                ]),
1460                Kind::List(vec![
1461                    Kind::Number(Number::unitless(3.0)),
1462                    Kind::Number(Number::unitless(4.0)),
1463                ]),
1464            ])
1465        );
1466    }
1467
1468    #[test]
1469    fn roundtrip_list_empty() {
1470        assert_eq!(round_trip(&Kind::List(vec![])), Kind::List(vec![]));
1471    }
1472
1473    #[test]
1474    fn roundtrip_list_mixed() {
1475        let k = Kind::List(vec![
1476            Kind::Number(Number::unitless(1.0)),
1477            Kind::Str("two".into()),
1478            Kind::Marker,
1479        ]);
1480        assert_eq!(round_trip(&k), k);
1481    }
1482
1483    // ── Dicts ──
1484
1485    #[test]
1486    fn parse_dict_empty() {
1487        let k = decode_scalar("{}").unwrap();
1488        assert_eq!(k, Kind::Dict(Box::new(HDict::new())));
1489    }
1490
1491    #[test]
1492    fn parse_dict_with_marker() {
1493        let k = decode_scalar("{site}").unwrap();
1494        if let Kind::Dict(d) = &k {
1495            assert_eq!(d.get("site"), Some(&Kind::Marker));
1496        } else {
1497            panic!("expected Dict");
1498        }
1499    }
1500
1501    #[test]
1502    fn parse_dict_with_values() {
1503        let k = decode_scalar("{dis:\"Main\" area:42}").unwrap();
1504        if let Kind::Dict(d) = &k {
1505            assert_eq!(d.get("dis"), Some(&Kind::Str("Main".into())));
1506            assert_eq!(d.get("area"), Some(&Kind::Number(Number::unitless(42.0))));
1507        } else {
1508            panic!("expected Dict");
1509        }
1510    }
1511
1512    #[test]
1513    fn parse_dict_mixed() {
1514        let k = decode_scalar("{site dis:\"Main\" area:42}").unwrap();
1515        if let Kind::Dict(d) = &k {
1516            assert_eq!(d.get("site"), Some(&Kind::Marker));
1517            assert_eq!(d.get("dis"), Some(&Kind::Str("Main".into())));
1518            assert_eq!(d.get("area"), Some(&Kind::Number(Number::unitless(42.0))));
1519        } else {
1520            panic!("expected Dict");
1521        }
1522    }
1523
1524    #[test]
1525    fn roundtrip_dict_empty() {
1526        let k = Kind::Dict(Box::new(HDict::new()));
1527        assert_eq!(round_trip(&k), k);
1528    }
1529
1530    #[test]
1531    fn roundtrip_dict_with_values() {
1532        let mut d = HDict::new();
1533        d.set("dis", Kind::Str("Main".into()));
1534        d.set("site", Kind::Marker);
1535        let k = Kind::Dict(Box::new(d));
1536        let rt = round_trip(&k);
1537        if let Kind::Dict(d) = &rt {
1538            assert_eq!(d.get("dis"), Some(&Kind::Str("Main".into())));
1539            assert_eq!(d.get("site"), Some(&Kind::Marker));
1540        } else {
1541            panic!("expected Dict");
1542        }
1543    }
1544
1545    // ── Grid decoding tests ──
1546
1547    #[test]
1548    fn decode_grid_empty() {
1549        let zinc = "ver:\"3.0\"\nempty\n";
1550        let g = decode_grid(zinc).unwrap();
1551        assert!(g.cols.is_empty());
1552        assert!(g.rows.is_empty());
1553    }
1554
1555    #[test]
1556    fn decode_grid_simple() {
1557        let zinc = "ver:\"3.0\"\ndis,area\n\"Site One\",4500\n\"Site Two\",3200\n";
1558        let g = decode_grid(zinc).unwrap();
1559        assert_eq!(g.num_cols(), 2);
1560        assert_eq!(g.cols[0].name, "dis");
1561        assert_eq!(g.cols[1].name, "area");
1562        assert_eq!(g.len(), 2);
1563
1564        let r0 = g.row(0).unwrap();
1565        assert_eq!(r0.get("dis"), Some(&Kind::Str("Site One".into())));
1566        assert_eq!(
1567            r0.get("area"),
1568            Some(&Kind::Number(Number::unitless(4500.0)))
1569        );
1570
1571        let r1 = g.row(1).unwrap();
1572        assert_eq!(r1.get("dis"), Some(&Kind::Str("Site Two".into())));
1573        assert_eq!(
1574            r1.get("area"),
1575            Some(&Kind::Number(Number::unitless(3200.0)))
1576        );
1577    }
1578
1579    #[test]
1580    fn decode_grid_with_meta() {
1581        let zinc = "ver:\"3.0\" err dis:\"some error\"\nempty\n";
1582        let g = decode_grid(zinc).unwrap();
1583        assert!(g.is_err());
1584        assert_eq!(g.meta.get("dis"), Some(&Kind::Str("some error".into())));
1585    }
1586
1587    #[test]
1588    fn decode_grid_with_col_meta() {
1589        let zinc = "ver:\"3.0\"\nname,power unit:\"kW\"\n\"AHU-1\",75\n";
1590        let g = decode_grid(zinc).unwrap();
1591        assert_eq!(g.num_cols(), 2);
1592        assert_eq!(g.cols[0].name, "name");
1593        assert_eq!(g.cols[1].name, "power");
1594        assert_eq!(g.cols[1].meta.get("unit"), Some(&Kind::Str("kW".into())));
1595    }
1596
1597    #[test]
1598    fn decode_grid_with_null_cells() {
1599        let zinc = "ver:\"3.0\"\na,b\n1,N\nN,2\n";
1600        let g = decode_grid(zinc).unwrap();
1601        assert_eq!(g.len(), 2);
1602        let r0 = g.row(0).unwrap();
1603        assert_eq!(r0.get("a"), Some(&Kind::Number(Number::unitless(1.0))));
1604        assert!(r0.missing("b"));
1605
1606        let r1 = g.row(1).unwrap();
1607        assert!(r1.missing("a"));
1608        assert_eq!(r1.get("b"), Some(&Kind::Number(Number::unitless(2.0))));
1609    }
1610
1611    #[test]
1612    fn decode_grid_with_comments() {
1613        let zinc = "// comment\nver:\"3.0\"\nempty\n";
1614        let g = decode_grid(zinc).unwrap();
1615        assert!(g.cols.is_empty());
1616    }
1617
1618    // ── Grid round-trip tests ──
1619
1620    #[test]
1621    fn grid_roundtrip_empty() {
1622        let g = HGrid::new();
1623        let encoded = crate::codecs::zinc::encode_grid(&g).unwrap();
1624        let decoded = decode_grid(&encoded).unwrap();
1625        assert!(decoded.cols.is_empty());
1626        assert!(decoded.rows.is_empty());
1627    }
1628
1629    #[test]
1630    fn grid_roundtrip_with_data() {
1631        let cols = vec![HCol::new("dis"), HCol::new("area")];
1632        let mut row1 = HDict::new();
1633        row1.set("dis", Kind::Str("Site One".into()));
1634        row1.set("area", Kind::Number(Number::unitless(4500.0)));
1635        let mut row2 = HDict::new();
1636        row2.set("dis", Kind::Str("Site Two".into()));
1637        row2.set("area", Kind::Number(Number::unitless(3200.0)));
1638        let g = HGrid::from_parts(HDict::new(), cols, vec![row1, row2]);
1639
1640        let encoded = crate::codecs::zinc::encode_grid(&g).unwrap();
1641        let decoded = decode_grid(&encoded).unwrap();
1642        assert_eq!(decoded.num_cols(), 2);
1643        assert_eq!(decoded.len(), 2);
1644        assert_eq!(
1645            decoded.row(0).unwrap().get("dis"),
1646            Some(&Kind::Str("Site One".into()))
1647        );
1648        assert_eq!(
1649            decoded.row(0).unwrap().get("area"),
1650            Some(&Kind::Number(Number::unitless(4500.0)))
1651        );
1652    }
1653
1654    #[test]
1655    fn grid_roundtrip_with_meta() {
1656        let mut meta = HDict::new();
1657        meta.set("err", Kind::Marker);
1658        meta.set("dis", Kind::Str("some error".into()));
1659        let g = HGrid::from_parts(meta, vec![], vec![]);
1660
1661        let encoded = crate::codecs::zinc::encode_grid(&g).unwrap();
1662        let decoded = decode_grid(&encoded).unwrap();
1663        assert!(decoded.is_err());
1664        assert_eq!(
1665            decoded.meta.get("dis"),
1666            Some(&Kind::Str("some error".into()))
1667        );
1668    }
1669
1670    #[test]
1671    fn grid_roundtrip_error_grid() {
1672        let mut meta = HDict::new();
1673        meta.set("err", Kind::Marker);
1674        meta.set("dis", Kind::Str("Error occurred".into()));
1675        meta.set("errTrace", Kind::Str("stack trace here".into()));
1676        let g = HGrid::from_parts(meta, vec![], vec![]);
1677
1678        let encoded = crate::codecs::zinc::encode_grid(&g).unwrap();
1679        let decoded = decode_grid(&encoded).unwrap();
1680        assert!(decoded.is_err());
1681        assert_eq!(
1682            decoded.meta.get("errTrace"),
1683            Some(&Kind::Str("stack trace here".into()))
1684        );
1685    }
1686
1687    // ── CSV-aware splitting ──
1688
1689    #[test]
1690    fn split_csv_simple() {
1691        let parts = split_csv_aware("a,b,c");
1692        assert_eq!(parts, vec!["a", "b", "c"]);
1693    }
1694
1695    #[test]
1696    fn split_csv_with_quotes() {
1697        let parts = split_csv_aware("\"a,b\",c");
1698        assert_eq!(parts, vec!["\"a,b\"", "c"]);
1699    }
1700
1701    #[test]
1702    fn split_csv_with_nested() {
1703        let parts = split_csv_aware("[1,2],3");
1704        assert_eq!(parts, vec!["[1,2]", "3"]);
1705    }
1706
1707    // ── -INF as negative number start ──
1708
1709    #[test]
1710    fn parse_neg_inf_standalone() {
1711        let k = decode_scalar("-INF").unwrap();
1712        if let Kind::Number(n) = &k {
1713            assert!(n.val.is_infinite() && n.val < 0.0);
1714        } else {
1715            panic!("expected Number(-INF)");
1716        }
1717    }
1718
1719    // ── Codec trait tests ──
1720
1721    #[test]
1722    fn zinc_codec_trait() {
1723        use crate::codecs::Codec;
1724        let codec = crate::codecs::zinc::ZincCodec;
1725        assert_eq!(codec.mime_type(), "text/zinc");
1726
1727        let encoded = codec.encode_scalar(&Kind::Bool(true)).unwrap();
1728        assert_eq!(encoded, "T");
1729
1730        let decoded = codec.decode_scalar("T").unwrap();
1731        assert_eq!(decoded, Kind::Bool(true));
1732
1733        let g = HGrid::new();
1734        let grid_str = codec.encode_grid(&g).unwrap();
1735        let decoded_grid = codec.decode_grid(&grid_str).unwrap();
1736        assert!(decoded_grid.cols.is_empty());
1737    }
1738
1739    // ── Bug fix: trailing input rejection ──
1740
1741    #[test]
1742    fn parse_scalar_rejects_trailing_input() {
1743        assert!(decode_scalar("T extra garbage").is_err());
1744        assert!(decode_scalar("42 xyz").is_err());
1745        assert!(decode_scalar("\"hello\" world").is_err());
1746        assert!(decode_scalar("M extra").is_err());
1747    }
1748
1749    #[test]
1750    fn parse_scalar_allows_trailing_whitespace() {
1751        assert_eq!(decode_scalar("T  ").unwrap(), Kind::Bool(true));
1752        assert_eq!(decode_scalar("M ").unwrap(), Kind::Marker);
1753        assert_eq!(
1754            decode_scalar("42 ").unwrap(),
1755            Kind::Number(Number::unitless(42.0))
1756        );
1757    }
1758
1759    // ── Bug fix: unknown escape sequences ──
1760
1761    #[test]
1762    fn parse_string_rejects_unknown_escapes() {
1763        assert!(decode_scalar("\"bad\\x\"").is_err());
1764        assert!(decode_scalar("\"bad\\a\"").is_err());
1765        assert!(decode_scalar("\"bad\\z\"").is_err());
1766    }
1767
1768    #[test]
1769    fn parse_string_accepts_valid_escapes() {
1770        assert!(decode_scalar("\"\\n\"").is_ok());
1771        assert!(decode_scalar("\"\\r\"").is_ok());
1772        assert!(decode_scalar("\"\\t\"").is_ok());
1773        assert!(decode_scalar("\"\\\\\"").is_ok());
1774        assert!(decode_scalar("\"\\\"\"").is_ok());
1775        assert!(decode_scalar("\"\\$\"").is_ok());
1776        assert!(decode_scalar("\"\\b\"").is_ok());
1777        assert!(decode_scalar("\"\\f\"").is_ok());
1778        assert!(decode_scalar("\"\\u0041\"").is_ok());
1779    }
1780}