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            self.pos += 3; // :SS
386            // Optional .FFF...
387            if self.peek() == Some('.') {
388                self.pos += 1;
389                while let Some(ch) = self.peek() {
390                    if ch.is_ascii_digit() {
391                        self.pos += 1;
392                    } else {
393                        break;
394                    }
395                }
396            }
397        }
398        Ok(self.src[start..self.pos].to_string())
399    }
400
401    fn read_offset(&mut self) -> Result<String, CodecError> {
402        if self.at_end() {
403            return Ok(String::new());
404        }
405        if self.peek() == Some('Z') {
406            self.pos += 1;
407            return Ok("+00:00".to_string());
408        }
409        if self.peek() == Some('+') || self.peek() == Some('-') {
410            let start = self.pos;
411            self.pos += 1; // sign
412            self.pos += 2; // HH
413            if self.peek() == Some(':') {
414                self.pos += 3; // :MM
415            }
416            return Ok(self.src[start..self.pos].to_string());
417        }
418        Ok(String::new())
419    }
420
421    fn read_tz_name(&mut self) -> String {
422        let start = self.pos;
423        while let Some(ch) = self.peek() {
424            if ch.is_alphanumeric() || ch == '_' || ch == '-' || ch == '/' {
425                self.pos += ch.len_utf8();
426            } else {
427                break;
428            }
429        }
430        self.src[start..self.pos].to_string()
431    }
432
433    fn read_time(&mut self) -> Result<Kind, CodecError> {
434        let time_str = self.read_time_str()?;
435        let time = NaiveTime::parse_from_str(&time_str, "%H:%M:%S%.f")
436            .or_else(|_| NaiveTime::parse_from_str(&time_str, "%H:%M:%S"))
437            .or_else(|_| NaiveTime::parse_from_str(&time_str, "%H:%M"))
438            .map_err(|e| self.err(format!("invalid time: {e}")))?;
439        Ok(Kind::Time(time))
440    }
441
442    // ── Strings ──
443
444    fn read_str(&mut self) -> Result<String, CodecError> {
445        self.pos += 1; // skip opening "
446        let mut result = String::new();
447        while !self.at_end() {
448            let ch = self.peek().unwrap();
449            if ch == '"' {
450                self.pos += 1;
451                return Ok(result);
452            }
453            if ch == '\\' {
454                self.pos += 1;
455                result.push(self.read_escape()?);
456            } else {
457                result.push(ch);
458                self.pos += ch.len_utf8();
459            }
460        }
461        Err(self.err("unterminated string"))
462    }
463
464    fn read_escape(&mut self) -> Result<char, CodecError> {
465        if self.at_end() {
466            return Err(self.err("unexpected end of escape sequence"));
467        }
468        let ch = self.consume().unwrap();
469        match ch {
470            'n' => Ok('\n'),
471            'r' => Ok('\r'),
472            't' => Ok('\t'),
473            '\\' => Ok('\\'),
474            '"' => Ok('"'),
475            '$' => Ok('$'),
476            'b' => Ok('\u{0008}'),
477            'f' => Ok('\u{000C}'),
478            'u' => {
479                if self.remaining().len() < 4 {
480                    return Err(self.err("incomplete unicode escape"));
481                }
482                let hex = &self.src[self.pos..self.pos + 4];
483                self.pos += 4;
484                let code = u32::from_str_radix(hex, 16)
485                    .map_err(|_| self.err(format!("invalid unicode escape: {hex}")))?;
486                char::from_u32(code)
487                    .ok_or_else(|| self.err(format!("invalid unicode codepoint: {code}")))
488            }
489            _ => Err(self.err(format!("unknown escape sequence: \\{ch}"))),
490        }
491    }
492
493    // ── Ref ──
494
495    fn read_ref(&mut self) -> Result<Kind, CodecError> {
496        self.pos += 1; // skip @
497        let start = self.pos;
498        while let Some(ch) = self.peek() {
499            if is_ref_char(ch) {
500                self.pos += ch.len_utf8();
501            } else {
502                break;
503            }
504        }
505        let val = self.src[start..self.pos].to_string();
506
507        // Optional display string
508        self.skip_spaces();
509        let dis = if self.peek() == Some('"') {
510            Some(self.read_str()?)
511        } else {
512            None
513        };
514
515        Ok(Kind::Ref(HRef::new(val, dis)))
516    }
517
518    // ── URI ──
519
520    fn read_uri(&mut self) -> Result<Kind, CodecError> {
521        self.pos += 1; // skip `
522        let mut result = String::new();
523        while !self.at_end() {
524            let ch = self.peek().unwrap();
525            if ch == '`' {
526                self.pos += 1;
527                return Ok(Kind::Uri(Uri::new(result)));
528            }
529            if ch == '\\' {
530                self.pos += 1;
531                if let Some(next) = self.consume() {
532                    result.push(next);
533                }
534            } else {
535                result.push(ch);
536                self.pos += ch.len_utf8();
537            }
538        }
539        Err(self.err("unterminated URI"))
540    }
541
542    // ── Symbol ──
543
544    fn read_symbol(&mut self) -> Result<Kind, CodecError> {
545        self.pos += 1; // skip ^
546        let start = self.pos;
547        while let Some(ch) = self.peek() {
548            if is_ref_char(ch) {
549                self.pos += ch.len_utf8();
550            } else {
551                break;
552            }
553        }
554        Ok(Kind::Symbol(Symbol::new(&self.src[start..self.pos])))
555    }
556
557    // ── Coord ──
558
559    fn read_coord(&mut self) -> Result<Kind, CodecError> {
560        self.pos += 2; // skip C(
561        let start = self.pos;
562        while self.peek() != Some(',') && !self.at_end() {
563            self.pos += 1;
564        }
565        let lat: f64 = self.src[start..self.pos]
566            .trim()
567            .parse()
568            .map_err(|_| self.err("invalid coord latitude"))?;
569        self.pos += 1; // skip comma
570        let start = self.pos;
571        while self.peek() != Some(')') && !self.at_end() {
572            self.pos += 1;
573        }
574        let lng: f64 = self.src[start..self.pos]
575            .trim()
576            .parse()
577            .map_err(|_| self.err("invalid coord longitude"))?;
578        self.pos += 1; // skip )
579        Ok(Kind::Coord(Coord::new(lat, lng)))
580    }
581
582    // ── List ──
583
584    fn read_list(&mut self) -> Result<Kind, CodecError> {
585        self.pos += 1; // skip [
586        let mut vals = Vec::new();
587        self.skip_spaces();
588        while !self.at_end() && self.peek() != Some(']') {
589            vals.push(self.read_val()?);
590            self.skip_spaces();
591            self.consume_if(',');
592            self.skip_spaces();
593        }
594        if !self.at_end() {
595            self.pos += 1; // skip ]
596        }
597        Ok(Kind::List(vals))
598    }
599
600    // ── Dict ──
601
602    fn read_dict(&mut self) -> Result<Kind, CodecError> {
603        self.pos += 1; // skip {
604        let mut dict = HDict::new();
605        self.skip_spaces();
606        while !self.at_end() && self.peek() != Some('}') {
607            let name = self.read_tag_name()?;
608            self.skip_spaces();
609            if self.peek() == Some(':') {
610                self.pos += 1;
611                self.skip_spaces();
612                let val = self.read_val()?;
613                dict.set(name, val);
614            } else {
615                dict.set(name, Kind::Marker);
616            }
617            self.skip_spaces();
618            self.consume_if(',');
619            self.skip_spaces();
620        }
621        if !self.at_end() {
622            self.pos += 1; // skip }
623        }
624        Ok(Kind::Dict(Box::new(dict)))
625    }
626
627    fn read_tag_name(&mut self) -> Result<String, CodecError> {
628        let start = self.pos;
629        while let Some(ch) = self.peek() {
630            if ch.is_alphanumeric() || ch == '_' {
631                self.pos += ch.len_utf8();
632            } else {
633                break;
634            }
635        }
636        let name = self.src[start..self.pos].to_string();
637        if name.is_empty() {
638            return Err(self.err("expected tag name"));
639        }
640        Ok(name)
641    }
642
643    // ── XStr or keyword ──
644
645    fn read_xstr_or_keyword(&mut self) -> Result<Kind, CodecError> {
646        let start = self.pos;
647        while let Some(ch) = self.peek() {
648            if ch.is_alphanumeric() || ch == '_' {
649                self.pos += ch.len_utf8();
650            } else {
651                break;
652            }
653        }
654        let name = &self.src[start..self.pos];
655
656        match name {
657            "INF" => return Ok(Kind::Number(Number::unitless(f64::INFINITY))),
658            "NaN" => return Ok(Kind::Number(Number::unitless(f64::NAN))),
659            "NA" => return Ok(Kind::NA),
660            _ => {}
661        }
662
663        // XStr: Type("value")
664        if self.peek() == Some('(') {
665            self.pos += 1; // skip (
666            self.skip_spaces();
667            let val = self.read_str()?;
668            self.skip_spaces();
669            if self.peek() == Some(')') {
670                self.pos += 1;
671            }
672            return Ok(Kind::XStr(XStr::new(name, val)));
673        }
674
675        Err(self.err(format!("unknown keyword '{name}'")))
676    }
677
678    /// Read an identifier (alphanumeric + underscore).
679    pub fn read_id(&mut self) -> String {
680        let start = self.pos;
681        while let Some(ch) = self.peek() {
682            if ch.is_alphanumeric() || ch == '_' {
683                self.pos += ch.len_utf8();
684            } else {
685                break;
686            }
687        }
688        self.src[start..self.pos].to_string()
689    }
690}
691
692fn is_ref_char(ch: char) -> bool {
693    ch.is_alphanumeric() || ch == '_' || ch == ':' || ch == '-' || ch == '.' || ch == '~'
694}
695
696/// Decode a single Zinc scalar value from a string.
697pub fn decode_scalar(input: &str) -> Result<Kind, CodecError> {
698    let mut parser = ZincParser::new(input.trim());
699    parser.parse_scalar()
700}
701
702// ── Grid decoding ──
703
704/// Decode a Zinc-formatted string into an HGrid.
705pub fn decode_grid(input: &str) -> Result<HGrid, CodecError> {
706    let lines: Vec<&str> = input
707        .lines()
708        .map(|l| l.trim())
709        .filter(|l| !l.is_empty() && !l.starts_with("//"))
710        .collect();
711
712    if lines.is_empty() {
713        return Ok(HGrid::new());
714    }
715
716    let mut line_idx = 0;
717
718    // Line 1: ver + grid meta
719    let ver_line = lines[line_idx];
720    line_idx += 1;
721
722    if !ver_line.starts_with("ver:") {
723        return Err(CodecError::Parse {
724            pos: 0,
725            message: format!("expected 'ver:' header, got: {ver_line:?}"),
726        });
727    }
728
729    // Skip past ver:"X.X"
730    let meta = parse_ver_line_meta(ver_line)?;
731
732    // Line 2: columns
733    if line_idx >= lines.len() {
734        return Ok(HGrid::from_parts(meta, vec![], vec![]));
735    }
736
737    let col_line = lines[line_idx];
738    line_idx += 1;
739
740    let cols = if col_line == "empty" {
741        vec![]
742    } else {
743        parse_cols(col_line)?
744    };
745
746    // Remaining lines: rows
747    let mut rows = Vec::new();
748    while line_idx < lines.len() {
749        let row_line = lines[line_idx];
750        line_idx += 1;
751        if row_line.is_empty() {
752            continue;
753        }
754        let row = parse_row(row_line, &cols)?;
755        rows.push(row);
756    }
757
758    Ok(HGrid::from_parts(meta, cols, rows))
759}
760
761fn parse_ver_line_meta(ver_line: &str) -> Result<HDict, CodecError> {
762    // Skip past ver:"X.X"
763    let mut parser = ZincParser::new(ver_line);
764    // Consume ver:"3.0" — find the first space
765    while !parser.at_end() && parser.peek() != Some(' ') {
766        parser.consume();
767    }
768    parser.skip_spaces();
769    if parser.at_end() {
770        return Ok(HDict::new());
771    }
772    parse_inline_meta(&mut parser)
773}
774
775fn parse_cols(line: &str) -> Result<Vec<HCol>, CodecError> {
776    let parts = split_csv_aware(line);
777    let mut cols = Vec::new();
778    for part in parts {
779        let part = part.trim();
780        if part.is_empty() {
781            continue;
782        }
783        let mut parser = ZincParser::new(part);
784        let name = read_col_name(&mut parser);
785        parser.skip_spaces();
786        let meta = if !parser.at_end() {
787            parse_inline_meta(&mut parser)?
788        } else {
789            HDict::new()
790        };
791        cols.push(HCol::with_meta(name, meta));
792    }
793    Ok(cols)
794}
795
796fn parse_row(line: &str, cols: &[HCol]) -> Result<HDict, CodecError> {
797    let parts = split_csv_aware(line);
798    let mut dict = HDict::new();
799    for (i, col) in cols.iter().enumerate() {
800        if i < parts.len() {
801            let cell = parts[i].trim();
802            if !cell.is_empty() && cell != "N" {
803                let mut parser = ZincParser::new(cell);
804                let val = parser.read_val()?;
805                dict.set(&col.name, val);
806            }
807        }
808    }
809    Ok(dict)
810}
811
812fn parse_inline_meta(parser: &mut ZincParser<'_>) -> Result<HDict, CodecError> {
813    let mut dict = HDict::new();
814    while !parser.at_end() {
815        parser.skip_spaces();
816        if parser.at_end() {
817            break;
818        }
819        let name = read_col_name(parser);
820        if name.is_empty() {
821            break;
822        }
823        parser.skip_spaces();
824        if parser.peek() == Some(':') {
825            parser.consume();
826            parser.skip_spaces();
827            let val = parser.read_val()?;
828            dict.set(name, val);
829        } else {
830            dict.set(name, Kind::Marker);
831        }
832        parser.skip_spaces();
833    }
834    Ok(dict)
835}
836
837fn read_col_name(parser: &mut ZincParser<'_>) -> String {
838    parser.read_id()
839}
840
841/// Split a line by commas, respecting quoted strings and nested structures.
842fn split_csv_aware(line: &str) -> Vec<String> {
843    let mut parts = Vec::new();
844    let mut current = String::new();
845    let mut depth = 0i32;
846    let mut in_str = false;
847    let mut escaped = false;
848
849    for ch in line.chars() {
850        if escaped {
851            current.push(ch);
852            escaped = false;
853            continue;
854        }
855        if ch == '\\' {
856            current.push(ch);
857            escaped = true;
858            continue;
859        }
860        if ch == '"' && depth == 0 {
861            in_str = !in_str;
862            current.push(ch);
863            continue;
864        }
865        if in_str {
866            current.push(ch);
867            continue;
868        }
869        match ch {
870            '(' | '[' | '{' => {
871                depth += 1;
872                current.push(ch);
873            }
874            ')' | ']' | '}' => {
875                depth -= 1;
876                current.push(ch);
877            }
878            ',' if depth == 0 => {
879                parts.push(std::mem::take(&mut current));
880            }
881            _ => {
882                current.push(ch);
883            }
884        }
885    }
886    parts.push(current);
887    parts
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893    use crate::data::{HDict, HGrid};
894    use chrono::{Datelike, FixedOffset, NaiveDate, NaiveTime, TimeZone};
895
896    // ── Scalar round-trip tests ──
897
898    fn round_trip(kind: &Kind) -> Kind {
899        let encoded = crate::codecs::zinc::encode_scalar(kind).unwrap();
900        decode_scalar(&encoded).unwrap()
901    }
902
903    #[test]
904    fn parse_null() {
905        assert_eq!(decode_scalar("N").unwrap(), Kind::Null);
906    }
907
908    #[test]
909    fn parse_true() {
910        assert_eq!(decode_scalar("T").unwrap(), Kind::Bool(true));
911    }
912
913    #[test]
914    fn parse_false() {
915        assert_eq!(decode_scalar("F").unwrap(), Kind::Bool(false));
916    }
917
918    #[test]
919    fn parse_marker() {
920        assert_eq!(decode_scalar("M").unwrap(), Kind::Marker);
921    }
922
923    #[test]
924    fn parse_na() {
925        assert_eq!(decode_scalar("NA").unwrap(), Kind::NA);
926    }
927
928    #[test]
929    fn parse_remove() {
930        assert_eq!(decode_scalar("R").unwrap(), Kind::Remove);
931    }
932
933    #[test]
934    fn roundtrip_null() {
935        assert_eq!(round_trip(&Kind::Null), Kind::Null);
936    }
937
938    #[test]
939    fn roundtrip_bool_true() {
940        assert_eq!(round_trip(&Kind::Bool(true)), Kind::Bool(true));
941    }
942
943    #[test]
944    fn roundtrip_bool_false() {
945        assert_eq!(round_trip(&Kind::Bool(false)), Kind::Bool(false));
946    }
947
948    #[test]
949    fn roundtrip_marker() {
950        assert_eq!(round_trip(&Kind::Marker), Kind::Marker);
951    }
952
953    #[test]
954    fn roundtrip_na() {
955        assert_eq!(round_trip(&Kind::NA), Kind::NA);
956    }
957
958    #[test]
959    fn roundtrip_remove() {
960        assert_eq!(round_trip(&Kind::Remove), Kind::Remove);
961    }
962
963    // ── Numbers ──
964
965    #[test]
966    fn parse_number_zero() {
967        assert_eq!(
968            decode_scalar("0").unwrap(),
969            Kind::Number(Number::unitless(0.0))
970        );
971    }
972
973    #[test]
974    fn parse_number_integer() {
975        assert_eq!(
976            decode_scalar("42").unwrap(),
977            Kind::Number(Number::unitless(42.0))
978        );
979    }
980
981    #[test]
982    fn parse_number_float() {
983        assert_eq!(
984            decode_scalar("72.5").unwrap(),
985            Kind::Number(Number::unitless(72.5))
986        );
987    }
988
989    #[test]
990    fn parse_number_negative() {
991        assert_eq!(
992            decode_scalar("-23.45").unwrap(),
993            Kind::Number(Number::unitless(-23.45))
994        );
995    }
996
997    #[test]
998    fn parse_number_scientific() {
999        let k = decode_scalar("5.4e8").unwrap();
1000        if let Kind::Number(n) = &k {
1001            assert!((n.val - 5.4e8).abs() < 1.0);
1002        } else {
1003            panic!("expected Number, got {k:?}");
1004        }
1005    }
1006
1007    #[test]
1008    fn parse_number_inf() {
1009        let k = decode_scalar("INF").unwrap();
1010        if let Kind::Number(n) = &k {
1011            assert!(n.val.is_infinite() && n.val > 0.0);
1012        } else {
1013            panic!("expected Number(INF)");
1014        }
1015    }
1016
1017    #[test]
1018    fn parse_number_neg_inf() {
1019        let k = decode_scalar("-INF").unwrap();
1020        if let Kind::Number(n) = &k {
1021            assert!(n.val.is_infinite() && n.val < 0.0);
1022        } else {
1023            panic!("expected Number(-INF)");
1024        }
1025    }
1026
1027    #[test]
1028    fn parse_number_nan() {
1029        let k = decode_scalar("NaN").unwrap();
1030        if let Kind::Number(n) = &k {
1031            assert!(n.val.is_nan());
1032        } else {
1033            panic!("expected Number(NaN)");
1034        }
1035    }
1036
1037    #[test]
1038    fn parse_number_with_unit() {
1039        let k = decode_scalar("72.5\u{00B0}F").unwrap();
1040        if let Kind::Number(n) = &k {
1041            assert_eq!(n.val, 72.5);
1042            assert_eq!(n.unit.as_deref(), Some("\u{00B0}F"));
1043        } else {
1044            panic!("expected Number with unit");
1045        }
1046    }
1047
1048    #[test]
1049    fn roundtrip_number_zero() {
1050        assert_eq!(
1051            round_trip(&Kind::Number(Number::unitless(0.0))),
1052            Kind::Number(Number::unitless(0.0))
1053        );
1054    }
1055
1056    #[test]
1057    fn roundtrip_number_integer() {
1058        assert_eq!(
1059            round_trip(&Kind::Number(Number::unitless(42.0))),
1060            Kind::Number(Number::unitless(42.0))
1061        );
1062    }
1063
1064    #[test]
1065    fn roundtrip_number_float() {
1066        assert_eq!(
1067            round_trip(&Kind::Number(Number::unitless(72.5))),
1068            Kind::Number(Number::unitless(72.5))
1069        );
1070    }
1071
1072    #[test]
1073    fn roundtrip_number_negative() {
1074        assert_eq!(
1075            round_trip(&Kind::Number(Number::unitless(-23.45))),
1076            Kind::Number(Number::unitless(-23.45))
1077        );
1078    }
1079
1080    #[test]
1081    fn roundtrip_number_with_unit() {
1082        let k = Kind::Number(Number::new(72.5, Some("\u{00B0}F".into())));
1083        let rt = round_trip(&k);
1084        if let Kind::Number(n) = &rt {
1085            assert_eq!(n.val, 72.5);
1086            assert_eq!(n.unit.as_deref(), Some("\u{00B0}F"));
1087        } else {
1088            panic!("expected Number");
1089        }
1090    }
1091
1092    #[test]
1093    fn roundtrip_inf() {
1094        let k = Kind::Number(Number::unitless(f64::INFINITY));
1095        let rt = round_trip(&k);
1096        if let Kind::Number(n) = &rt {
1097            assert!(n.val.is_infinite() && n.val > 0.0);
1098        } else {
1099            panic!("expected Number(INF)");
1100        }
1101    }
1102
1103    #[test]
1104    fn roundtrip_neg_inf() {
1105        let k = Kind::Number(Number::unitless(f64::NEG_INFINITY));
1106        let rt = round_trip(&k);
1107        if let Kind::Number(n) = &rt {
1108            assert!(n.val.is_infinite() && n.val < 0.0);
1109        } else {
1110            panic!("expected Number(-INF)");
1111        }
1112    }
1113
1114    #[test]
1115    fn roundtrip_nan() {
1116        let k = Kind::Number(Number::unitless(f64::NAN));
1117        let rt = round_trip(&k);
1118        if let Kind::Number(n) = &rt {
1119            assert!(n.val.is_nan());
1120        } else {
1121            panic!("expected Number(NaN)");
1122        }
1123    }
1124
1125    // ── Strings ──
1126
1127    #[test]
1128    fn parse_string_empty() {
1129        assert_eq!(decode_scalar("\"\"").unwrap(), Kind::Str(String::new()));
1130    }
1131
1132    #[test]
1133    fn parse_string_simple() {
1134        assert_eq!(
1135            decode_scalar("\"hello\"").unwrap(),
1136            Kind::Str("hello".into())
1137        );
1138    }
1139
1140    #[test]
1141    fn parse_string_escapes() {
1142        assert_eq!(
1143            decode_scalar("\"line1\\nline2\"").unwrap(),
1144            Kind::Str("line1\nline2".into())
1145        );
1146        assert_eq!(
1147            decode_scalar("\"tab\\there\"").unwrap(),
1148            Kind::Str("tab\there".into())
1149        );
1150        assert_eq!(
1151            decode_scalar("\"back\\\\slash\"").unwrap(),
1152            Kind::Str("back\\slash".into())
1153        );
1154        assert_eq!(
1155            decode_scalar("\"q\\\"uote\"").unwrap(),
1156            Kind::Str("q\"uote".into())
1157        );
1158        assert_eq!(
1159            decode_scalar("\"dollar\\$sign\"").unwrap(),
1160            Kind::Str("dollar$sign".into())
1161        );
1162    }
1163
1164    #[test]
1165    fn parse_string_unicode_escape() {
1166        assert_eq!(decode_scalar("\"\\u0041\"").unwrap(), Kind::Str("A".into()));
1167    }
1168
1169    #[test]
1170    fn roundtrip_string_empty() {
1171        assert_eq!(
1172            round_trip(&Kind::Str(String::new())),
1173            Kind::Str(String::new())
1174        );
1175    }
1176
1177    #[test]
1178    fn roundtrip_string_escapes() {
1179        let s = "line1\nline2\ttab\\slash\"quote$dollar";
1180        assert_eq!(round_trip(&Kind::Str(s.into())), Kind::Str(s.into()));
1181    }
1182
1183    // ── Refs ──
1184
1185    #[test]
1186    fn parse_ref_simple() {
1187        let k = decode_scalar("@site-1").unwrap();
1188        if let Kind::Ref(r) = &k {
1189            assert_eq!(r.val, "site-1");
1190            assert_eq!(r.dis, None);
1191        } else {
1192            panic!("expected Ref");
1193        }
1194    }
1195
1196    #[test]
1197    fn parse_ref_with_dis() {
1198        let k = decode_scalar("@site-1 \"Main Site\"").unwrap();
1199        if let Kind::Ref(r) = &k {
1200            assert_eq!(r.val, "site-1");
1201            assert_eq!(r.dis, Some("Main Site".into()));
1202        } else {
1203            panic!("expected Ref");
1204        }
1205    }
1206
1207    #[test]
1208    fn roundtrip_ref_simple() {
1209        let k = Kind::Ref(HRef::from_val("site-1"));
1210        let rt = round_trip(&k);
1211        if let Kind::Ref(r) = &rt {
1212            assert_eq!(r.val, "site-1");
1213        } else {
1214            panic!("expected Ref");
1215        }
1216    }
1217
1218    #[test]
1219    fn roundtrip_ref_with_dis() {
1220        let k = Kind::Ref(HRef::new("site-1", Some("Main Site".into())));
1221        let rt = round_trip(&k);
1222        if let Kind::Ref(r) = &rt {
1223            assert_eq!(r.val, "site-1");
1224            assert_eq!(r.dis, Some("Main Site".into()));
1225        } else {
1226            panic!("expected Ref");
1227        }
1228    }
1229
1230    // ── URIs ──
1231
1232    #[test]
1233    fn parse_uri_simple() {
1234        let k = decode_scalar("`http://example.com`").unwrap();
1235        assert_eq!(k, Kind::Uri(Uri::new("http://example.com")));
1236    }
1237
1238    #[test]
1239    fn parse_uri_with_special() {
1240        let k = decode_scalar("`http://ex.com/path?q=1&b=2`").unwrap();
1241        assert_eq!(k, Kind::Uri(Uri::new("http://ex.com/path?q=1&b=2")));
1242    }
1243
1244    #[test]
1245    fn roundtrip_uri() {
1246        let k = Kind::Uri(Uri::new("http://example.com/path"));
1247        assert_eq!(round_trip(&k), k);
1248    }
1249
1250    // ── Symbols ──
1251
1252    #[test]
1253    fn parse_symbol_simple() {
1254        let k = decode_scalar("^site").unwrap();
1255        assert_eq!(k, Kind::Symbol(Symbol::new("site")));
1256    }
1257
1258    #[test]
1259    fn parse_symbol_compound() {
1260        let k = decode_scalar("^hot-water").unwrap();
1261        assert_eq!(k, Kind::Symbol(Symbol::new("hot-water")));
1262    }
1263
1264    #[test]
1265    fn roundtrip_symbol() {
1266        let k = Kind::Symbol(Symbol::new("hot-water"));
1267        assert_eq!(round_trip(&k), k);
1268    }
1269
1270    // ── Dates ──
1271
1272    #[test]
1273    fn parse_date() {
1274        let k = decode_scalar("2024-03-13").unwrap();
1275        assert_eq!(k, Kind::Date(NaiveDate::from_ymd_opt(2024, 3, 13).unwrap()));
1276    }
1277
1278    #[test]
1279    fn roundtrip_date() {
1280        let k = Kind::Date(NaiveDate::from_ymd_opt(2024, 3, 13).unwrap());
1281        assert_eq!(round_trip(&k), k);
1282    }
1283
1284    // ── Times ──
1285
1286    #[test]
1287    fn parse_time() {
1288        let k = decode_scalar("08:12:05").unwrap();
1289        assert_eq!(k, Kind::Time(NaiveTime::from_hms_opt(8, 12, 5).unwrap()));
1290    }
1291
1292    #[test]
1293    fn parse_time_with_frac() {
1294        let k = decode_scalar("14:30:00.123").unwrap();
1295        assert_eq!(
1296            k,
1297            Kind::Time(NaiveTime::from_hms_milli_opt(14, 30, 0, 123).unwrap())
1298        );
1299    }
1300
1301    #[test]
1302    fn roundtrip_time() {
1303        let k = Kind::Time(NaiveTime::from_hms_opt(8, 12, 5).unwrap());
1304        assert_eq!(round_trip(&k), k);
1305    }
1306
1307    #[test]
1308    fn roundtrip_time_frac() {
1309        let k = Kind::Time(NaiveTime::from_hms_milli_opt(14, 30, 0, 123).unwrap());
1310        assert_eq!(round_trip(&k), k);
1311    }
1312
1313    // ── DateTimes ──
1314
1315    #[test]
1316    fn parse_datetime() {
1317        let k = decode_scalar("2024-01-01T08:12:05-05:00 New_York").unwrap();
1318        if let Kind::DateTime(hdt) = &k {
1319            assert_eq!(hdt.tz_name, "New_York");
1320            assert_eq!(hdt.dt.year(), 2024);
1321        } else {
1322            panic!("expected DateTime");
1323        }
1324    }
1325
1326    #[test]
1327    fn parse_datetime_utc() {
1328        let k = decode_scalar("2024-06-15T12:00:00+00:00 UTC").unwrap();
1329        if let Kind::DateTime(hdt) = &k {
1330            assert_eq!(hdt.tz_name, "UTC");
1331        } else {
1332            panic!("expected DateTime");
1333        }
1334    }
1335
1336    #[test]
1337    fn parse_datetime_z() {
1338        let k = decode_scalar("2024-06-15T12:00:00Z UTC").unwrap();
1339        if let Kind::DateTime(hdt) = &k {
1340            assert_eq!(hdt.tz_name, "UTC");
1341            assert_eq!(hdt.dt.offset(), &FixedOffset::east_opt(0).unwrap());
1342        } else {
1343            panic!("expected DateTime");
1344        }
1345    }
1346
1347    #[test]
1348    fn roundtrip_datetime() {
1349        let offset = FixedOffset::west_opt(5 * 3600).unwrap();
1350        let dt = offset.with_ymd_and_hms(2024, 1, 1, 8, 12, 5).unwrap();
1351        let k = Kind::DateTime(HDateTime::new(dt, "New_York"));
1352        let rt = round_trip(&k);
1353        if let Kind::DateTime(hdt) = &rt {
1354            assert_eq!(hdt.tz_name, "New_York");
1355            assert_eq!(hdt.dt, dt);
1356        } else {
1357            panic!("expected DateTime");
1358        }
1359    }
1360
1361    #[test]
1362    fn roundtrip_datetime_utc() {
1363        let offset = FixedOffset::east_opt(0).unwrap();
1364        let dt = offset.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
1365        let k = Kind::DateTime(HDateTime::new(dt, "UTC"));
1366        let rt = round_trip(&k);
1367        if let Kind::DateTime(hdt) = &rt {
1368            assert_eq!(hdt.tz_name, "UTC");
1369            assert_eq!(hdt.dt, dt);
1370        } else {
1371            panic!("expected DateTime");
1372        }
1373    }
1374
1375    // ── Coords ──
1376
1377    #[test]
1378    fn parse_coord() {
1379        let k = decode_scalar("C(37.5458266,-77.4491888)").unwrap();
1380        assert_eq!(k, Kind::Coord(Coord::new(37.5458266, -77.4491888)));
1381    }
1382
1383    #[test]
1384    fn parse_coord_negative() {
1385        let k = decode_scalar("C(-33.8688,151.2093)").unwrap();
1386        assert_eq!(k, Kind::Coord(Coord::new(-33.8688, 151.2093)));
1387    }
1388
1389    #[test]
1390    fn roundtrip_coord() {
1391        let k = Kind::Coord(Coord::new(37.5458266, -77.4491888));
1392        assert_eq!(round_trip(&k), k);
1393    }
1394
1395    // ── XStr ──
1396
1397    #[test]
1398    fn parse_xstr() {
1399        let k = decode_scalar("Color(\"red\")").unwrap();
1400        assert_eq!(k, Kind::XStr(XStr::new("Color", "red")));
1401    }
1402
1403    #[test]
1404    fn roundtrip_xstr() {
1405        let k = Kind::XStr(XStr::new("Color", "red"));
1406        assert_eq!(round_trip(&k), k);
1407    }
1408
1409    // ── Lists ──
1410
1411    #[test]
1412    fn parse_list_empty() {
1413        assert_eq!(decode_scalar("[]").unwrap(), Kind::List(vec![]));
1414    }
1415
1416    #[test]
1417    fn parse_list_mixed() {
1418        let k = decode_scalar("[1, \"two\", M]").unwrap();
1419        assert_eq!(
1420            k,
1421            Kind::List(vec![
1422                Kind::Number(Number::unitless(1.0)),
1423                Kind::Str("two".into()),
1424                Kind::Marker,
1425            ])
1426        );
1427    }
1428
1429    #[test]
1430    fn parse_list_nested() {
1431        let k = decode_scalar("[[1, 2], [3, 4]]").unwrap();
1432        assert_eq!(
1433            k,
1434            Kind::List(vec![
1435                Kind::List(vec![
1436                    Kind::Number(Number::unitless(1.0)),
1437                    Kind::Number(Number::unitless(2.0)),
1438                ]),
1439                Kind::List(vec![
1440                    Kind::Number(Number::unitless(3.0)),
1441                    Kind::Number(Number::unitless(4.0)),
1442                ]),
1443            ])
1444        );
1445    }
1446
1447    #[test]
1448    fn roundtrip_list_empty() {
1449        assert_eq!(round_trip(&Kind::List(vec![])), Kind::List(vec![]));
1450    }
1451
1452    #[test]
1453    fn roundtrip_list_mixed() {
1454        let k = Kind::List(vec![
1455            Kind::Number(Number::unitless(1.0)),
1456            Kind::Str("two".into()),
1457            Kind::Marker,
1458        ]);
1459        assert_eq!(round_trip(&k), k);
1460    }
1461
1462    // ── Dicts ──
1463
1464    #[test]
1465    fn parse_dict_empty() {
1466        let k = decode_scalar("{}").unwrap();
1467        assert_eq!(k, Kind::Dict(Box::new(HDict::new())));
1468    }
1469
1470    #[test]
1471    fn parse_dict_with_marker() {
1472        let k = decode_scalar("{site}").unwrap();
1473        if let Kind::Dict(d) = &k {
1474            assert_eq!(d.get("site"), Some(&Kind::Marker));
1475        } else {
1476            panic!("expected Dict");
1477        }
1478    }
1479
1480    #[test]
1481    fn parse_dict_with_values() {
1482        let k = decode_scalar("{dis:\"Main\" area:42}").unwrap();
1483        if let Kind::Dict(d) = &k {
1484            assert_eq!(d.get("dis"), Some(&Kind::Str("Main".into())));
1485            assert_eq!(d.get("area"), Some(&Kind::Number(Number::unitless(42.0))));
1486        } else {
1487            panic!("expected Dict");
1488        }
1489    }
1490
1491    #[test]
1492    fn parse_dict_mixed() {
1493        let k = decode_scalar("{site dis:\"Main\" area:42}").unwrap();
1494        if let Kind::Dict(d) = &k {
1495            assert_eq!(d.get("site"), Some(&Kind::Marker));
1496            assert_eq!(d.get("dis"), Some(&Kind::Str("Main".into())));
1497            assert_eq!(d.get("area"), Some(&Kind::Number(Number::unitless(42.0))));
1498        } else {
1499            panic!("expected Dict");
1500        }
1501    }
1502
1503    #[test]
1504    fn roundtrip_dict_empty() {
1505        let k = Kind::Dict(Box::new(HDict::new()));
1506        assert_eq!(round_trip(&k), k);
1507    }
1508
1509    #[test]
1510    fn roundtrip_dict_with_values() {
1511        let mut d = HDict::new();
1512        d.set("dis", Kind::Str("Main".into()));
1513        d.set("site", Kind::Marker);
1514        let k = Kind::Dict(Box::new(d));
1515        let rt = round_trip(&k);
1516        if let Kind::Dict(d) = &rt {
1517            assert_eq!(d.get("dis"), Some(&Kind::Str("Main".into())));
1518            assert_eq!(d.get("site"), Some(&Kind::Marker));
1519        } else {
1520            panic!("expected Dict");
1521        }
1522    }
1523
1524    // ── Grid decoding tests ──
1525
1526    #[test]
1527    fn decode_grid_empty() {
1528        let zinc = "ver:\"3.0\"\nempty\n";
1529        let g = decode_grid(zinc).unwrap();
1530        assert!(g.cols.is_empty());
1531        assert!(g.rows.is_empty());
1532    }
1533
1534    #[test]
1535    fn decode_grid_simple() {
1536        let zinc = "ver:\"3.0\"\ndis,area\n\"Site One\",4500\n\"Site Two\",3200\n";
1537        let g = decode_grid(zinc).unwrap();
1538        assert_eq!(g.num_cols(), 2);
1539        assert_eq!(g.cols[0].name, "dis");
1540        assert_eq!(g.cols[1].name, "area");
1541        assert_eq!(g.len(), 2);
1542
1543        let r0 = g.row(0).unwrap();
1544        assert_eq!(r0.get("dis"), Some(&Kind::Str("Site One".into())));
1545        assert_eq!(
1546            r0.get("area"),
1547            Some(&Kind::Number(Number::unitless(4500.0)))
1548        );
1549
1550        let r1 = g.row(1).unwrap();
1551        assert_eq!(r1.get("dis"), Some(&Kind::Str("Site Two".into())));
1552        assert_eq!(
1553            r1.get("area"),
1554            Some(&Kind::Number(Number::unitless(3200.0)))
1555        );
1556    }
1557
1558    #[test]
1559    fn decode_grid_with_meta() {
1560        let zinc = "ver:\"3.0\" err dis:\"some error\"\nempty\n";
1561        let g = decode_grid(zinc).unwrap();
1562        assert!(g.is_err());
1563        assert_eq!(g.meta.get("dis"), Some(&Kind::Str("some error".into())));
1564    }
1565
1566    #[test]
1567    fn decode_grid_with_col_meta() {
1568        let zinc = "ver:\"3.0\"\nname,power unit:\"kW\"\n\"AHU-1\",75\n";
1569        let g = decode_grid(zinc).unwrap();
1570        assert_eq!(g.num_cols(), 2);
1571        assert_eq!(g.cols[0].name, "name");
1572        assert_eq!(g.cols[1].name, "power");
1573        assert_eq!(g.cols[1].meta.get("unit"), Some(&Kind::Str("kW".into())));
1574    }
1575
1576    #[test]
1577    fn decode_grid_with_null_cells() {
1578        let zinc = "ver:\"3.0\"\na,b\n1,N\nN,2\n";
1579        let g = decode_grid(zinc).unwrap();
1580        assert_eq!(g.len(), 2);
1581        let r0 = g.row(0).unwrap();
1582        assert_eq!(r0.get("a"), Some(&Kind::Number(Number::unitless(1.0))));
1583        assert!(r0.missing("b"));
1584
1585        let r1 = g.row(1).unwrap();
1586        assert!(r1.missing("a"));
1587        assert_eq!(r1.get("b"), Some(&Kind::Number(Number::unitless(2.0))));
1588    }
1589
1590    #[test]
1591    fn decode_grid_with_comments() {
1592        let zinc = "// comment\nver:\"3.0\"\nempty\n";
1593        let g = decode_grid(zinc).unwrap();
1594        assert!(g.cols.is_empty());
1595    }
1596
1597    // ── Grid round-trip tests ──
1598
1599    #[test]
1600    fn grid_roundtrip_empty() {
1601        let g = HGrid::new();
1602        let encoded = crate::codecs::zinc::encode_grid(&g).unwrap();
1603        let decoded = decode_grid(&encoded).unwrap();
1604        assert!(decoded.cols.is_empty());
1605        assert!(decoded.rows.is_empty());
1606    }
1607
1608    #[test]
1609    fn grid_roundtrip_with_data() {
1610        let cols = vec![HCol::new("dis"), HCol::new("area")];
1611        let mut row1 = HDict::new();
1612        row1.set("dis", Kind::Str("Site One".into()));
1613        row1.set("area", Kind::Number(Number::unitless(4500.0)));
1614        let mut row2 = HDict::new();
1615        row2.set("dis", Kind::Str("Site Two".into()));
1616        row2.set("area", Kind::Number(Number::unitless(3200.0)));
1617        let g = HGrid::from_parts(HDict::new(), cols, vec![row1, row2]);
1618
1619        let encoded = crate::codecs::zinc::encode_grid(&g).unwrap();
1620        let decoded = decode_grid(&encoded).unwrap();
1621        assert_eq!(decoded.num_cols(), 2);
1622        assert_eq!(decoded.len(), 2);
1623        assert_eq!(
1624            decoded.row(0).unwrap().get("dis"),
1625            Some(&Kind::Str("Site One".into()))
1626        );
1627        assert_eq!(
1628            decoded.row(0).unwrap().get("area"),
1629            Some(&Kind::Number(Number::unitless(4500.0)))
1630        );
1631    }
1632
1633    #[test]
1634    fn grid_roundtrip_with_meta() {
1635        let mut meta = HDict::new();
1636        meta.set("err", Kind::Marker);
1637        meta.set("dis", Kind::Str("some error".into()));
1638        let g = HGrid::from_parts(meta, vec![], vec![]);
1639
1640        let encoded = crate::codecs::zinc::encode_grid(&g).unwrap();
1641        let decoded = decode_grid(&encoded).unwrap();
1642        assert!(decoded.is_err());
1643        assert_eq!(
1644            decoded.meta.get("dis"),
1645            Some(&Kind::Str("some error".into()))
1646        );
1647    }
1648
1649    #[test]
1650    fn grid_roundtrip_error_grid() {
1651        let mut meta = HDict::new();
1652        meta.set("err", Kind::Marker);
1653        meta.set("dis", Kind::Str("Error occurred".into()));
1654        meta.set("errTrace", Kind::Str("stack trace here".into()));
1655        let g = HGrid::from_parts(meta, vec![], vec![]);
1656
1657        let encoded = crate::codecs::zinc::encode_grid(&g).unwrap();
1658        let decoded = decode_grid(&encoded).unwrap();
1659        assert!(decoded.is_err());
1660        assert_eq!(
1661            decoded.meta.get("errTrace"),
1662            Some(&Kind::Str("stack trace here".into()))
1663        );
1664    }
1665
1666    // ── CSV-aware splitting ──
1667
1668    #[test]
1669    fn split_csv_simple() {
1670        let parts = split_csv_aware("a,b,c");
1671        assert_eq!(parts, vec!["a", "b", "c"]);
1672    }
1673
1674    #[test]
1675    fn split_csv_with_quotes() {
1676        let parts = split_csv_aware("\"a,b\",c");
1677        assert_eq!(parts, vec!["\"a,b\"", "c"]);
1678    }
1679
1680    #[test]
1681    fn split_csv_with_nested() {
1682        let parts = split_csv_aware("[1,2],3");
1683        assert_eq!(parts, vec!["[1,2]", "3"]);
1684    }
1685
1686    // ── -INF as negative number start ──
1687
1688    #[test]
1689    fn parse_neg_inf_standalone() {
1690        let k = decode_scalar("-INF").unwrap();
1691        if let Kind::Number(n) = &k {
1692            assert!(n.val.is_infinite() && n.val < 0.0);
1693        } else {
1694            panic!("expected Number(-INF)");
1695        }
1696    }
1697
1698    // ── Codec trait tests ──
1699
1700    #[test]
1701    fn zinc_codec_trait() {
1702        use crate::codecs::Codec;
1703        let codec = crate::codecs::zinc::ZincCodec;
1704        assert_eq!(codec.mime_type(), "text/zinc");
1705
1706        let encoded = codec.encode_scalar(&Kind::Bool(true)).unwrap();
1707        assert_eq!(encoded, "T");
1708
1709        let decoded = codec.decode_scalar("T").unwrap();
1710        assert_eq!(decoded, Kind::Bool(true));
1711
1712        let g = HGrid::new();
1713        let grid_str = codec.encode_grid(&g).unwrap();
1714        let decoded_grid = codec.decode_grid(&grid_str).unwrap();
1715        assert!(decoded_grid.cols.is_empty());
1716    }
1717
1718    // ── Bug fix: trailing input rejection ──
1719
1720    #[test]
1721    fn parse_scalar_rejects_trailing_input() {
1722        assert!(decode_scalar("T extra garbage").is_err());
1723        assert!(decode_scalar("42 xyz").is_err());
1724        assert!(decode_scalar("\"hello\" world").is_err());
1725        assert!(decode_scalar("M extra").is_err());
1726    }
1727
1728    #[test]
1729    fn parse_scalar_allows_trailing_whitespace() {
1730        assert_eq!(decode_scalar("T  ").unwrap(), Kind::Bool(true));
1731        assert_eq!(decode_scalar("M ").unwrap(), Kind::Marker);
1732        assert_eq!(
1733            decode_scalar("42 ").unwrap(),
1734            Kind::Number(Number::unitless(42.0))
1735        );
1736    }
1737
1738    // ── Bug fix: unknown escape sequences ──
1739
1740    #[test]
1741    fn parse_string_rejects_unknown_escapes() {
1742        assert!(decode_scalar("\"bad\\x\"").is_err());
1743        assert!(decode_scalar("\"bad\\a\"").is_err());
1744        assert!(decode_scalar("\"bad\\z\"").is_err());
1745    }
1746
1747    #[test]
1748    fn parse_string_accepts_valid_escapes() {
1749        assert!(decode_scalar("\"\\n\"").is_ok());
1750        assert!(decode_scalar("\"\\r\"").is_ok());
1751        assert!(decode_scalar("\"\\t\"").is_ok());
1752        assert!(decode_scalar("\"\\\\\"").is_ok());
1753        assert!(decode_scalar("\"\\\"\"").is_ok());
1754        assert!(decode_scalar("\"\\$\"").is_ok());
1755        assert!(decode_scalar("\"\\b\"").is_ok());
1756        assert!(decode_scalar("\"\\f\"").is_ok());
1757        assert!(decode_scalar("\"\\u0041\"").is_ok());
1758    }
1759}