Skip to main content

serializer/llm/
human_parser.rs

1//! Human format parser
2//!
3//! Parses beautiful human-readable format back to DxDocument.
4//!
5//! ## Human Format Syntax (v2 with Leaf Inlining)
6//!
7//! ```text
8//! # Scalars with dot paths (leaf values)
9//! name                = dx
10//! version             = 0.0.1
11//! forge.repository    = https://dx.vercel.app/user/repo
12//! style.path          = @/style
13//! js.dependencies.react = 19.0.1
14//!
15//! # Arrays with count: key[n]:
16//! workspace.paths[2]:
17//! - @/www
18//! - @/backend
19//!
20//! editors.items[7]:
21//! - neovim
22//! - zed
23//! - vscode
24//! - cursor
25//! - antigravity
26//! - replit
27//! - firebase-studio
28//!
29//! # No [section] headers needed - dot notation handles nesting
30//! ```
31
32use crate::llm::abbrev::AbbrevDict;
33use crate::llm::types::{DxDocument, DxLlmValue, DxSection};
34use indexmap::IndexMap;
35use thiserror::Error;
36
37/// Parse errors for Human format
38#[derive(Debug, Error)]
39pub enum HumanParseError {
40    /// A legacy section header could not be parsed.
41    #[error("Invalid section header: {msg}")]
42    InvalidSectionHeader {
43        /// Human-readable parse failure.
44        msg: String,
45    },
46
47    /// A key-value line was malformed.
48    #[error("Invalid key-value pair: {msg}")]
49    InvalidKeyValue {
50        /// Human-readable parse failure.
51        msg: String,
52    },
53
54    /// Table syntax could not be parsed at the reported line.
55    #[error("Invalid table format at line {line}: {msg}")]
56    InvalidTable {
57        /// One-based line number where the table failed.
58        line: usize,
59        /// Human-readable parse failure.
60        msg: String,
61    },
62
63    /// Parser found content that did not match any supported human-format form.
64    #[error("Unexpected content: {msg}")]
65    UnexpectedContent {
66        /// Human-readable parse failure.
67        msg: String,
68    },
69
70    /// Input exceeded the configured serializer safety limit.
71    #[error("Input too large: {size} bytes exceeds maximum of {max} bytes")]
72    InputTooLarge {
73        /// Actual input size in bytes.
74        size: usize,
75        /// Maximum accepted input size in bytes.
76        max: usize,
77    },
78
79    /// Table row count exceeded the configured serializer safety limit.
80    #[error("Table too large: {rows} rows exceeds maximum of {max} rows")]
81    TableTooLarge {
82        /// Actual parsed row count.
83        rows: usize,
84        /// Maximum accepted row count.
85        max: usize,
86    },
87}
88
89/// Parse human-readable format back to DxDocument
90pub struct HumanParser {
91    abbrev: AbbrevDict,
92}
93
94impl HumanParser {
95    #[allow(dead_code)] // Methods reserved for future table parsing features
96    /// Create a new parser
97    pub fn new() -> Self {
98        Self {
99            abbrev: AbbrevDict::new(),
100        }
101    }
102
103    /// Parse human format string into DxDocument
104    ///
105    /// Supports v2 format with:
106    /// - Dot notation for nested paths: `forge.repository = value`
107    /// - Array syntax: `key[n]:` followed by `- item` lines
108    /// - Leaf inlining: dots in keys are preserved as-is
109    ///
110    /// # Errors
111    ///
112    /// Returns `HumanParseError::InputTooLarge` if input exceeds `MAX_INPUT_SIZE` (100 MB).
113    pub fn parse(&self, input: &str) -> Result<DxDocument, HumanParseError> {
114        // Security: Check input size before parsing
115        if input.len() > crate::error::MAX_INPUT_SIZE {
116            return Err(HumanParseError::InputTooLarge {
117                size: input.len(),
118                max: crate::error::MAX_INPUT_SIZE,
119            });
120        }
121
122        let mut doc = DxDocument::new();
123        let lines: Vec<&str> = input.lines().collect();
124        let mut i = 0;
125
126        while i < lines.len() {
127            let line = lines[i].trim();
128
129            // Skip empty lines and comment headers (═══)
130            if line.is_empty() || line.starts_with("# ═") || line.starts_with('#') {
131                i += 1;
132                continue;
133            }
134
135            // Track what was added before this iteration
136            let context_keys_before: Vec<String> = doc.context.keys().cloned().collect();
137            let section_ids_before: Vec<char> = doc.sections.keys().copied().collect();
138
139            // Check for array syntax: key[n]: or key.path[n]:
140            if let Some(caps) = self.parse_array_header(line) {
141                let (key, _count) = caps;
142                i += 1;
143
144                // Collect array items
145                let mut items = Vec::new();
146                while i < lines.len() {
147                    let item_line = lines[i].trim();
148                    if item_line.starts_with("- ") {
149                        let item_value = item_line.strip_prefix("- ").unwrap_or("").trim();
150                        items.push(self.parse_config_value(item_value)?);
151                        i += 1;
152                    } else if item_line.starts_with('-') && item_line.len() > 1 {
153                        let item_value = item_line.strip_prefix('-').unwrap_or("").trim();
154                        items.push(self.parse_config_value(item_value)?);
155                        i += 1;
156                    } else {
157                        break;
158                    }
159                }
160
161                if !items.is_empty() {
162                    doc.context.insert(key.clone(), DxLlmValue::Arr(items));
163                }
164
165                // Track new entries
166                self.track_new_entries(&mut doc, &context_keys_before, &section_ids_before);
167                continue;
168            }
169
170            // Check for section header (legacy support): [section] or [section:number]
171            if let Some(section_name) = self.parse_section_header(line) {
172                i += 1;
173
174                // Check if this is a numbered section like [dependencies:1]
175                if let Some(colon_pos) = section_name.rfind(':') {
176                    let base_name = &section_name[..colon_pos];
177
178                    // Parse this numbered section
179                    let (context, consumed) = self.parse_config_section(&lines[i..])?;
180                    i += consumed;
181
182                    // Check if we already have a section for this base name
183                    if let Some(DxLlmValue::Obj(existing)) = doc.context.get_mut(base_name) {
184                        // This is a subsequent numbered section - convert to table
185                        // First, get the schema from the first object
186                        let mut schema: Vec<String> = existing.keys().cloned().collect();
187                        schema.sort();
188
189                        // Create a table section
190                        let mut section = DxSection::new(schema.clone());
191
192                        // Add first row from existing object
193                        let mut first_row = Vec::new();
194                        for col in &schema {
195                            first_row.push(existing.get(col).cloned().unwrap_or(DxLlmValue::Null));
196                        }
197                        section.rows.push(first_row);
198
199                        // Add second row from current context
200                        let mut second_row = Vec::new();
201                        for col in &schema {
202                            second_row.push(context.get(col).cloned().unwrap_or(DxLlmValue::Null));
203                        }
204                        section.rows.push(second_row);
205
206                        // Remove the object and add as section with full name
207                        doc.context.shift_remove(base_name);
208                        let section_id = base_name.chars().next().unwrap_or('d');
209                        doc.section_names.insert(section_id, base_name.to_string());
210                        doc.sections.insert(section_id, section);
211                    } else if let Some(section) = doc.sections.values_mut().find(|s| {
212                        // Find existing table section for this base name
213                        s.schema.iter().all(|col| context.contains_key(col))
214                    }) {
215                        // Add row to existing table
216                        let mut row = Vec::new();
217                        for col in &section.schema.clone() {
218                            row.push(context.get(col).cloned().unwrap_or(DxLlmValue::Null));
219                        }
220                        section.rows.push(row);
221                    } else {
222                        // First numbered section - store as object for now
223                        if !context.is_empty() {
224                            doc.context
225                                .insert(base_name.to_string(), DxLlmValue::Obj(context));
226                        }
227                    }
228
229                    // Track new entries
230                    self.track_new_entries(&mut doc, &context_keys_before, &section_ids_before);
231                    continue;
232                }
233
234                match section_name.to_lowercase().as_str() {
235                    "config" | "configuration" => {
236                        let (context, consumed) = self.parse_config_section(&lines[i..])?;
237                        for (k, v) in context {
238                            doc.context.insert(k, v);
239                        }
240                        i += consumed;
241                    }
242                    "references" | "refs" => {
243                        let (refs, consumed) = self.parse_references_section(&lines[i..])?;
244                        doc.refs = refs;
245                        i += consumed;
246                    }
247                    _ => {
248                        // Parse as config-style section (key-value pairs)
249                        let (context, consumed) = self.parse_config_section(&lines[i..])?;
250
251                        // Add section data to context as nested object
252                        if !context.is_empty() {
253                            // Keep section name as-is (no compression)
254                            doc.context
255                                .insert(section_name.clone(), DxLlmValue::Obj(context));
256                        }
257                        i += consumed;
258                    }
259                }
260
261                // Track new entries
262                self.track_new_entries(&mut doc, &context_keys_before, &section_ids_before);
263                continue;
264            }
265
266            // Key-value pair: key = value or key.path = value
267            if line.contains('=') && !line.starts_with('[') {
268                if let Some((key, value)) = self.parse_key_value(line)? {
269                    // Keep the key as-is (with dots) for leaf inlining
270                    doc.context.insert(key, value);
271                }
272                i += 1;
273
274                // Track new entries
275                self.track_new_entries(&mut doc, &context_keys_before, &section_ids_before);
276                continue;
277            }
278
279            // Legacy array syntax: key: followed by - items
280            if line.ends_with(':') && !line.contains('=') && !line.contains('[') {
281                let key = line.trim_end_matches(':').trim().to_string();
282                i += 1;
283
284                // Collect array items
285                let mut items = Vec::new();
286                while i < lines.len() {
287                    let item_line = lines[i].trim();
288                    if item_line.starts_with("- ") {
289                        let item_value = item_line.strip_prefix("- ").unwrap_or("").trim();
290                        items.push(self.parse_config_value(item_value)?);
291                        i += 1;
292                    } else if item_line.starts_with('-') && item_line.len() > 1 {
293                        let item_value = item_line.strip_prefix('-').unwrap_or("").trim();
294                        items.push(self.parse_config_value(item_value)?);
295                        i += 1;
296                    } else {
297                        break;
298                    }
299                }
300
301                if !items.is_empty() {
302                    doc.context.insert(key, DxLlmValue::Arr(items));
303                }
304                continue;
305            }
306
307            i += 1;
308        }
309
310        Ok(doc)
311    }
312
313    /// Track new entries added since the last snapshot
314    fn track_new_entries(
315        &self,
316        doc: &mut DxDocument,
317        context_keys_before: &[String],
318        section_ids_before: &[char],
319    ) {
320        // Find new context keys
321        for key in doc.context.keys() {
322            if !context_keys_before.contains(key) {
323                let entry_ref = crate::llm::types::EntryRef::Context(key.clone());
324                if !doc.entry_order.contains(&entry_ref) {
325                    doc.entry_order.push(entry_ref);
326                }
327            }
328        }
329
330        // Find new section IDs
331        for id in doc.sections.keys() {
332            if !section_ids_before.contains(id) {
333                let entry_ref = crate::llm::types::EntryRef::Section(*id);
334                if !doc.entry_order.contains(&entry_ref) {
335                    doc.entry_order.push(entry_ref);
336                }
337            }
338        }
339    }
340
341    /// Parse array header: key[n]: or key.path[n]:
342    /// Returns (key, count) if matched
343    fn parse_array_header(&self, line: &str) -> Option<(String, usize)> {
344        let line = line.trim();
345
346        // Match pattern: key[n]: or key.path[n]:
347        if !line.ends_with(':') {
348            return None;
349        }
350
351        let without_colon = &line[..line.len() - 1];
352
353        // Find the [n] part
354        if let Some(bracket_start) = without_colon.rfind('[') {
355            if let Some(bracket_end) = without_colon.rfind(']') {
356                if bracket_end > bracket_start {
357                    let key = without_colon[..bracket_start].trim();
358                    let count_str = &without_colon[bracket_start + 1..bracket_end];
359
360                    if let Ok(count) = count_str.parse::<usize>() {
361                        return Some((key.to_string(), count));
362                    }
363                }
364            }
365        }
366
367        None
368    }
369
370    /// Parse section header: [section_name]
371    fn parse_section_header(&self, line: &str) -> Option<String> {
372        let line = line.trim();
373        if line.starts_with('[') && line.ends_with(']') {
374            let name = line[1..line.len() - 1].trim().to_string();
375            if !name.is_empty() {
376                return Some(name);
377            }
378        }
379        None
380    }
381
382    /// Parse config section with key-value pairs and arrays
383    fn parse_config_section(
384        &self,
385        lines: &[&str],
386    ) -> Result<(IndexMap<String, DxLlmValue>, usize), HumanParseError> {
387        let mut context = IndexMap::new();
388        let mut consumed = 0;
389        let mut i = 0;
390
391        while i < lines.len() {
392            let line = lines[i].trim();
393
394            // Stop at next section or empty line followed by section
395            if line.starts_with('[') || line.starts_with("# ═") {
396                break;
397            }
398
399            consumed += 1;
400            i += 1;
401
402            if line.is_empty() || line.starts_with('#') {
403                continue;
404            }
405
406            // Check for array syntax: key: or key[n]:
407            if line.ends_with(':') && !line.contains('=') {
408                let key = line.trim_end_matches(':').trim();
409
410                // Check if it's array with count: key[n]:
411                let array_key = if let Some(bracket_pos) = key.rfind('[') {
412                    if let Some(bracket_end) = key.rfind(']') {
413                        if bracket_end > bracket_pos {
414                            key[..bracket_pos].trim().to_string()
415                        } else {
416                            key.to_string()
417                        }
418                    } else {
419                        key.to_string()
420                    }
421                } else {
422                    key.to_string()
423                };
424
425                // Collect array items
426                let mut items = Vec::new();
427                while i < lines.len() {
428                    let item_line = lines[i].trim();
429                    if item_line.starts_with("- ") {
430                        let item_value = item_line.strip_prefix("- ").unwrap_or("").trim();
431                        items.push(self.parse_config_value(item_value)?);
432                        consumed += 1;
433                        i += 1;
434                    } else if item_line.starts_with('-') && item_line.len() > 1 {
435                        let item_value = item_line.strip_prefix('-').unwrap_or("").trim();
436                        items.push(self.parse_config_value(item_value)?);
437                        consumed += 1;
438                        i += 1;
439                    } else {
440                        break;
441                    }
442                }
443
444                if !items.is_empty() {
445                    context.insert(array_key, DxLlmValue::Arr(items));
446                }
447                continue;
448            }
449
450            // Parse key = value
451            if let Some((key, value)) = self.parse_key_value(line)? {
452                // Keep key as-is (no compression)
453                context.insert(key, value);
454            }
455        }
456
457        Ok((context, consumed))
458    }
459
460    /// Parse references section
461    fn parse_references_section(
462        &self,
463        lines: &[&str],
464    ) -> Result<(IndexMap<String, String>, usize), HumanParseError> {
465        let mut refs = IndexMap::new();
466        let mut consumed = 0;
467
468        for line in lines {
469            let line = line.trim();
470
471            // Stop at next section
472            if line.starts_with('[') || line.starts_with("# ═") {
473                break;
474            }
475
476            consumed += 1;
477
478            if line.is_empty() || line.starts_with('#') {
479                continue;
480            }
481
482            // Parse key = "value"
483            if let Some((key, DxLlmValue::Str(s))) = self.parse_key_value(line)? {
484                refs.insert(key, s);
485            }
486        }
487
488        Ok((refs, consumed))
489    }
490
491    /// Parse key-value pair: key = "value" or key = value
492    /// Preserves dots in keys for leaf inlining support
493    fn parse_key_value(&self, line: &str) -> Result<Option<(String, DxLlmValue)>, HumanParseError> {
494        let line = line.trim();
495
496        // Skip comments
497        if line.starts_with('#') {
498            return Ok(None);
499        }
500
501        // Find the = separator
502        let eq_pos = match line.find('=') {
503            Some(pos) => pos,
504            None => return Ok(None),
505        };
506
507        let key = line[..eq_pos].trim().to_string();
508        let mut value_str = line[eq_pos + 1..].trim();
509
510        // Remove trailing comment (# ref: ...)
511        if let Some(comment_pos) = value_str.find("  #") {
512            value_str = value_str[..comment_pos].trim();
513        }
514
515        let value = self.parse_config_value(value_str)?;
516        // Keep key as-is (with dots) - no compression for leaf inlining
517        Ok(Some((key, value)))
518    }
519
520    /// Parse a config value (string, number, bool, null, array)
521    /// V2: Also supports comma-separated arrays without brackets
522    fn parse_config_value(&self, s: &str) -> Result<DxLlmValue, HumanParseError> {
523        let s = s.trim();
524
525        // Quoted string
526        if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
527            return Ok(DxLlmValue::Str(s[1..s.len() - 1].to_string()));
528        }
529
530        // Boolean (support true/false, yes/no, +/-)
531        if s == "true" || s == "yes" || s == "+" {
532            return Ok(DxLlmValue::Bool(true));
533        }
534        if s == "false" || s == "no" || s == "-" {
535            return Ok(DxLlmValue::Bool(false));
536        }
537
538        // Null (support both "null" and "none" for compatibility)
539        if s == "null" || s == "none" {
540            return Ok(DxLlmValue::Null);
541        }
542
543        // Array with brackets
544        if s.starts_with('[') && s.ends_with(']') {
545            let inner = s[1..s.len() - 1].trim();
546            if inner.is_empty() {
547                return Ok(DxLlmValue::Arr(vec![]));
548            }
549            let items: Result<Vec<DxLlmValue>, _> = inner
550                .split(',')
551                .map(|item| self.parse_config_value(item.trim()))
552                .collect();
553            return Ok(DxLlmValue::Arr(items?));
554        }
555
556        // Number
557        if let Ok(n) = s.parse::<f64>() {
558            return Ok(DxLlmValue::Num(n));
559        }
560
561        // V2: Comma-separated array without brackets (e.g., "frontend/www, frontend/mobile")
562        if s.contains(", ") && !s.starts_with('"') {
563            let items: Vec<DxLlmValue> = s
564                .split(", ")
565                .map(|item| DxLlmValue::Str(item.trim().to_string()))
566                .collect();
567            if items.len() > 1 {
568                return Ok(DxLlmValue::Arr(items));
569            }
570        }
571
572        // Default to string (unquoted)
573        Ok(DxLlmValue::Str(s.to_string()))
574    }
575
576    /// Parse data section with table
577    #[allow(dead_code)] // Reserved for future table parsing features
578    fn parse_data_section(
579        &self,
580        lines: &[&str],
581        section_name: &str,
582    ) -> Result<(DxSection, usize), HumanParseError> {
583        let mut consumed = 0;
584        let mut table_lines: Vec<&str> = Vec::new();
585        let mut in_table = false;
586
587        for line in lines {
588            let trimmed = line.trim();
589
590            // Stop at next section
591            if trimmed.starts_with('[') && !in_table {
592                break;
593            }
594
595            // Stop at section header
596            if trimmed.starts_with("# ═") {
597                break;
598            }
599
600            consumed += 1;
601
602            // Skip empty lines and summary lines before table
603            if trimmed.is_empty() {
604                if in_table {
605                    // End of table
606                    break;
607                }
608                continue;
609            }
610
611            // Skip summary lines
612            if trimmed.starts_with("Total:") {
613                continue;
614            }
615
616            // Detect table start
617            if trimmed.starts_with('┌') || trimmed.starts_with('+') || trimmed.starts_with('|') {
618                in_table = true;
619            }
620
621            if in_table {
622                table_lines.push(trimmed);
623                // Detect table end
624                if trimmed.starts_with('└') || (trimmed.starts_with('+') && table_lines.len() > 2)
625                {
626                    break;
627                }
628            }
629        }
630
631        let section = self.parse_table(&table_lines, section_name)?;
632        Ok((section, consumed))
633    }
634
635    /// Parse Unicode or ASCII table
636    #[allow(dead_code)] // Reserved for future table parsing features
637    fn parse_table(&self, lines: &[&str], context: &str) -> Result<DxSection, HumanParseError> {
638        if lines.is_empty() {
639            return Ok(DxSection::new(vec![]));
640        }
641
642        // Detect table style
643        let is_unicode = lines[0].contains('┌') || lines[0].contains('│');
644        let is_markdown = lines[0].starts_with('|') && !lines[0].contains('┌');
645
646        let (schema, rows) = if is_unicode {
647            self.parse_unicode_table(lines, context)?
648        } else if is_markdown {
649            self.parse_markdown_table(lines, context)?
650        } else {
651            self.parse_ascii_table(lines, context)?
652        };
653
654        let mut section = DxSection::new(schema);
655        for row in rows {
656            section.rows.push(row);
657        }
658
659        // Security: Check table row count limit
660        if section.rows.len() > crate::error::MAX_TABLE_ROWS {
661            return Err(HumanParseError::TableTooLarge {
662                rows: section.rows.len(),
663                max: crate::error::MAX_TABLE_ROWS,
664            });
665        }
666
667        Ok(section)
668    }
669
670    /// Parse Unicode box-drawn table
671    /// V2: Also handles wrapped rows with continuation indicators
672    #[allow(dead_code)] // Reserved for future table parsing features
673    fn parse_unicode_table(
674        &self,
675        lines: &[&str],
676        _context: &str,
677    ) -> Result<(Vec<String>, Vec<Vec<DxLlmValue>>), HumanParseError> {
678        let mut schema = Vec::new();
679        let mut rows = Vec::new();
680        let mut header_found = false;
681        let mut separator_found = false;
682        let mut current_row_cells: Option<Vec<String>> = None;
683
684        for line in lines {
685            let line = line.trim();
686
687            // Skip top border and separator lines
688            if line.starts_with('┌') || line.starts_with('├') || line.starts_with('└') {
689                if line.starts_with('├') {
690                    separator_found = true;
691                }
692                continue;
693            }
694
695            // Parse row with │ separators
696            if line.starts_with('│') && line.ends_with('│') {
697                let cells: Vec<&str> = line[3..line.len() - 3]
698                    .split('│')
699                    .map(|s| s.trim())
700                    .collect();
701
702                if !header_found {
703                    // This is the header row - keep column names as-is
704                    schema = cells.iter().map(|col| col.to_string()).collect();
705                    header_found = true;
706                } else if separator_found {
707                    // Check if this is a continuation row (first cell is empty or has ↓)
708                    let is_continuation = cells
709                        .first()
710                        .map(|c| c.is_empty() || *c == "↓" || c.trim().is_empty())
711                        .unwrap_or(false);
712
713                    if is_continuation {
714                        if let Some(ref mut current) = current_row_cells {
715                            // Append to current row cells
716                            for (i, cell) in cells.iter().enumerate() {
717                                if i < current.len() && !cell.is_empty() && *cell != "↓" {
718                                    if !current[i].is_empty() {
719                                        current[i].push(' ');
720                                    }
721                                    current[i].push_str(cell);
722                                }
723                            }
724                        }
725                    } else {
726                        // Finalize previous row if exists
727                        if let Some(prev_cells) = current_row_cells.take() {
728                            let row: Vec<DxLlmValue> = prev_cells
729                                .iter()
730                                .map(|cell| self.parse_cell_value(cell))
731                                .collect();
732                            rows.push(row);
733                        }
734                        // Start new row
735                        current_row_cells = Some(cells.iter().map(|s| s.to_string()).collect());
736                    }
737                }
738            }
739        }
740
741        // Finalize last row
742        if let Some(last_cells) = current_row_cells {
743            let row: Vec<DxLlmValue> = last_cells
744                .iter()
745                .map(|cell| self.parse_cell_value(cell))
746                .collect();
747            rows.push(row);
748        }
749
750        Ok((schema, rows))
751    }
752
753    /// Parse ASCII table
754    #[allow(dead_code)] // Reserved for future table parsing features
755    fn parse_ascii_table(
756        &self,
757        lines: &[&str],
758        _context: &str,
759    ) -> Result<(Vec<String>, Vec<Vec<DxLlmValue>>), HumanParseError> {
760        let mut schema = Vec::new();
761        let mut rows = Vec::new();
762        let mut header_found = false;
763        let mut separator_count = 0;
764
765        for line in lines {
766            let line = line.trim();
767
768            // Skip border lines
769            if line.starts_with('+') {
770                separator_count += 1;
771                continue;
772            }
773
774            // Parse row with | separators
775            if line.starts_with('|') && line.ends_with('|') {
776                let cells: Vec<&str> = line[1..line.len() - 1]
777                    .split('|')
778                    .map(|s| s.trim())
779                    .collect();
780
781                if !header_found {
782                    schema = cells.iter().map(|col| col.to_string()).collect();
783                    header_found = true;
784                } else if separator_count >= 2 {
785                    let row: Vec<DxLlmValue> = cells
786                        .iter()
787                        .map(|cell| self.parse_cell_value(cell))
788                        .collect();
789                    rows.push(row);
790                }
791            }
792        }
793
794        Ok((schema, rows))
795    }
796
797    /// Parse Markdown table
798    #[allow(dead_code)] // Reserved for future table parsing features
799    fn parse_markdown_table(
800        &self,
801        lines: &[&str],
802        _context: &str,
803    ) -> Result<(Vec<String>, Vec<Vec<DxLlmValue>>), HumanParseError> {
804        let mut schema = Vec::new();
805        let mut rows = Vec::new();
806        let mut header_found = false;
807        let mut separator_found = false;
808
809        for line in lines {
810            let line = line.trim();
811
812            // Skip separator line (| --- | --- |)
813            if line.contains("---") {
814                separator_found = true;
815                continue;
816            }
817
818            // Parse row with | separators
819            if line.starts_with('|') && line.ends_with('|') {
820                let cells: Vec<&str> = line[1..line.len() - 1]
821                    .split('|')
822                    .map(|s| s.trim())
823                    .collect();
824
825                if !header_found {
826                    schema = cells.iter().map(|col| col.to_string()).collect();
827                    header_found = true;
828                } else if separator_found {
829                    let row: Vec<DxLlmValue> = cells
830                        .iter()
831                        .map(|cell| self.parse_cell_value(cell))
832                        .collect();
833                    rows.push(row);
834                }
835            }
836        }
837
838        Ok((schema, rows))
839    }
840
841    /// Parse table cell value
842    ///
843    /// Recognizes special symbols:
844    /// - ✓ → boolean true
845    /// - ✗ → boolean false
846    /// - — → null
847    #[allow(dead_code)] // Reserved for future table parsing features
848    fn parse_cell_value(&self, s: &str) -> DxLlmValue {
849        let s = s.trim();
850
851        // Boolean true
852        if s == "✓" || s == "true" {
853            return DxLlmValue::Bool(true);
854        }
855
856        // Boolean false
857        if s == "✗" || s == "false" {
858            return DxLlmValue::Bool(false);
859        }
860
861        // Null
862        if s == "—" || s == "null" || s == "-" && s.len() == 1 {
863            return DxLlmValue::Null;
864        }
865
866        // Array
867        if s.starts_with('[') && s.ends_with(']') {
868            let inner = s[1..s.len() - 1].trim();
869            if inner.is_empty() {
870                return DxLlmValue::Arr(vec![]);
871            }
872            let items: Vec<DxLlmValue> = inner
873                .split(',')
874                .map(|item| self.parse_cell_value(item.trim()))
875                .collect();
876            return DxLlmValue::Arr(items);
877        }
878
879        // Number
880        if let Ok(n) = s.parse::<f64>() {
881            return DxLlmValue::Num(n);
882        }
883
884        // Default to string
885        DxLlmValue::Str(s.to_string())
886    }
887
888    /// Convert section name to single-character ID
889    /// Supports both V1 short names and V2 full names
890    #[allow(dead_code)] // Reserved for future serialization features
891    fn section_name_to_id(&self, name: &str) -> char {
892        match name.to_lowercase().as_str() {
893            // V2 full names
894            "assets" => 'a',
895            "builds" => 'b',
896            "config" | "configuration" => 'c',
897            "data" => 'd',
898            "events" => 'e',
899            "forge" => 'f',
900            "groups" => 'g',
901            "hikes" => 'h',
902            "items" => 'i',
903            "jobs" => 'j',
904            "keys" => 'k',
905            "logs" => 'l',
906            "metrics" => 'm',
907            "nodes" => 'n',
908            "orders" => 'o',
909            "products" => 'p',
910            "queries" => 'q',
911            "resources" => 'r',
912            "services" => 's',
913            "tasks" => 't',
914            "users" => 'u',
915            "versions" => 'v',
916            "workflows" => 'w',
917            "extensions" => 'x',
918            "yields" => 'y',
919            "zones" => 'z',
920            // Default: use first character
921            _ => name.chars().next().unwrap_or('x').to_ascii_lowercase(),
922        }
923    }
924
925    /// Get the abbreviation dictionary
926    pub fn abbrev(&self) -> &AbbrevDict {
927        &self.abbrev
928    }
929}
930
931impl Default for HumanParser {
932    fn default() -> Self {
933        Self::new()
934    }
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940
941    #[test]
942    fn test_parse_empty() {
943        let parser = HumanParser::new();
944        let doc = parser.parse("").unwrap();
945        assert!(doc.is_empty());
946    }
947
948    #[test]
949    fn test_parse_section_header() {
950        let parser = HumanParser::new();
951        assert_eq!(
952            parser.parse_section_header("[config]"),
953            Some("config".to_string())
954        );
955        assert_eq!(
956            parser.parse_section_header("[data]"),
957            Some("data".to_string())
958        );
959        assert_eq!(parser.parse_section_header("not a header"), None);
960    }
961
962    #[test]
963    fn test_parse_simple_key_value() {
964        let parser = HumanParser::new();
965        let input = r#"
966name = dx
967version = 0.0.1
968"#;
969        let doc = parser.parse(input).unwrap();
970        assert_eq!(doc.context.get("name").unwrap().as_str(), Some("dx"));
971        assert_eq!(doc.context.get("version").unwrap().as_str(), Some("0.0.1"));
972    }
973
974    #[test]
975    fn test_parse_dotted_keys() {
976        // Leaf inlining: dots in keys are preserved
977        let parser = HumanParser::new();
978        let input = r#"
979forge.repository = https://example.com
980style.path = @/style
981js.dependencies.react = 19.0.1
982"#;
983        let doc = parser.parse(input).unwrap();
984        assert_eq!(
985            doc.context.get("forge.repository").unwrap().as_str(),
986            Some("https://example.com")
987        );
988        assert_eq!(
989            doc.context.get("style.path").unwrap().as_str(),
990            Some("@/style")
991        );
992        assert_eq!(
993            doc.context.get("js.dependencies.react").unwrap().as_str(),
994            Some("19.0.1")
995        );
996    }
997
998    #[test]
999    fn test_parse_array_with_count() {
1000        let parser = HumanParser::new();
1001        let input = r#"
1002editors.items[3]:
1003- neovim
1004- zed
1005- vscode
1006"#;
1007        let doc = parser.parse(input).unwrap();
1008        let items = doc.context.get("editors.items").unwrap();
1009        if let DxLlmValue::Arr(arr) = items {
1010            assert_eq!(arr.len(), 3);
1011            assert_eq!(arr[0].as_str(), Some("neovim"));
1012            assert_eq!(arr[1].as_str(), Some("zed"));
1013            assert_eq!(arr[2].as_str(), Some("vscode"));
1014        } else {
1015            panic!("Expected array");
1016        }
1017    }
1018
1019    #[test]
1020    fn test_parse_array_header() {
1021        let parser = HumanParser::new();
1022
1023        assert_eq!(
1024            parser.parse_array_header("editors.items[3]:"),
1025            Some(("editors.items".to_string(), 3))
1026        );
1027        assert_eq!(
1028            parser.parse_array_header("workspace.paths[2]:"),
1029            Some(("workspace.paths".to_string(), 2))
1030        );
1031        assert_eq!(parser.parse_array_header("key:"), None);
1032        assert_eq!(parser.parse_array_header("key = value"), None);
1033    }
1034
1035    #[test]
1036    fn test_parse_mixed_format() {
1037        let parser = HumanParser::new();
1038        let input = r#"
1039name = dx
1040version = 0.0.1
1041forge.repository = https://example.com
1042
1043workspace.paths[2]:
1044- @/www
1045- @/backend
1046
1047editors.default = neovim
1048"#;
1049        let doc = parser.parse(input).unwrap();
1050
1051        assert_eq!(doc.context.get("name").unwrap().as_str(), Some("dx"));
1052        assert_eq!(doc.context.get("version").unwrap().as_str(), Some("0.0.1"));
1053        assert_eq!(
1054            doc.context.get("forge.repository").unwrap().as_str(),
1055            Some("https://example.com")
1056        );
1057        assert_eq!(
1058            doc.context.get("editors.default").unwrap().as_str(),
1059            Some("neovim")
1060        );
1061
1062        let paths = doc.context.get("workspace.paths").unwrap();
1063        if let DxLlmValue::Arr(arr) = paths {
1064            assert_eq!(arr.len(), 2);
1065            assert_eq!(arr[0].as_str(), Some("@/www"));
1066            assert_eq!(arr[1].as_str(), Some("@/backend"));
1067        } else {
1068            panic!("Expected array");
1069        }
1070    }
1071
1072    #[test]
1073    fn test_parse_cell_values() {
1074        let parser = HumanParser::new();
1075
1076        assert_eq!(parser.parse_cell_value("✓"), DxLlmValue::Bool(true));
1077        assert_eq!(parser.parse_cell_value("✗"), DxLlmValue::Bool(false));
1078        assert_eq!(parser.parse_cell_value("—"), DxLlmValue::Null);
1079        assert_eq!(parser.parse_cell_value("42"), DxLlmValue::Num(42.0));
1080        assert_eq!(
1081            parser.parse_cell_value("hello"),
1082            DxLlmValue::Str("hello".to_string())
1083        );
1084    }
1085
1086    #[test]
1087    fn test_section_name_to_id() {
1088        let parser = HumanParser::new();
1089        assert_eq!(parser.section_name_to_id("data"), 'd');
1090        assert_eq!(parser.section_name_to_id("forge"), 'f');
1091        assert_eq!(parser.section_name_to_id("assets"), 'a');
1092        assert_eq!(parser.section_name_to_id("unknown"), 'u');
1093    }
1094}