1use crate::llm::abbrev::AbbrevDict;
33use crate::llm::types::{DxDocument, DxLlmValue, DxSection};
34use indexmap::IndexMap;
35use thiserror::Error;
36
37#[derive(Debug, Error)]
39pub enum HumanParseError {
40 #[error("Invalid section header: {msg}")]
42 InvalidSectionHeader {
43 msg: String,
45 },
46
47 #[error("Invalid key-value pair: {msg}")]
49 InvalidKeyValue {
50 msg: String,
52 },
53
54 #[error("Invalid table format at line {line}: {msg}")]
56 InvalidTable {
57 line: usize,
59 msg: String,
61 },
62
63 #[error("Unexpected content: {msg}")]
65 UnexpectedContent {
66 msg: String,
68 },
69
70 #[error("Input too large: {size} bytes exceeds maximum of {max} bytes")]
72 InputTooLarge {
73 size: usize,
75 max: usize,
77 },
78
79 #[error("Table too large: {rows} rows exceeds maximum of {max} rows")]
81 TableTooLarge {
82 rows: usize,
84 max: usize,
86 },
87}
88
89pub struct HumanParser {
91 abbrev: AbbrevDict,
92}
93
94impl HumanParser {
95 #[allow(dead_code)] pub fn new() -> Self {
98 Self {
99 abbrev: AbbrevDict::new(),
100 }
101 }
102
103 pub fn parse(&self, input: &str) -> Result<DxDocument, HumanParseError> {
114 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 if line.is_empty() || line.starts_with("# ═") || line.starts_with('#') {
131 i += 1;
132 continue;
133 }
134
135 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 if let Some(caps) = self.parse_array_header(line) {
141 let (key, _count) = caps;
142 i += 1;
143
144 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 self.track_new_entries(&mut doc, &context_keys_before, §ion_ids_before);
167 continue;
168 }
169
170 if let Some(section_name) = self.parse_section_header(line) {
172 i += 1;
173
174 if let Some(colon_pos) = section_name.rfind(':') {
176 let base_name = §ion_name[..colon_pos];
177
178 let (context, consumed) = self.parse_config_section(&lines[i..])?;
180 i += consumed;
181
182 if let Some(DxLlmValue::Obj(existing)) = doc.context.get_mut(base_name) {
184 let mut schema: Vec<String> = existing.keys().cloned().collect();
187 schema.sort();
188
189 let mut section = DxSection::new(schema.clone());
191
192 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 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 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 s.schema.iter().all(|col| context.contains_key(col))
214 }) {
215 let mut row = Vec::new();
217 for col in §ion.schema.clone() {
218 row.push(context.get(col).cloned().unwrap_or(DxLlmValue::Null));
219 }
220 section.rows.push(row);
221 } else {
222 if !context.is_empty() {
224 doc.context
225 .insert(base_name.to_string(), DxLlmValue::Obj(context));
226 }
227 }
228
229 self.track_new_entries(&mut doc, &context_keys_before, §ion_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 let (context, consumed) = self.parse_config_section(&lines[i..])?;
250
251 if !context.is_empty() {
253 doc.context
255 .insert(section_name.clone(), DxLlmValue::Obj(context));
256 }
257 i += consumed;
258 }
259 }
260
261 self.track_new_entries(&mut doc, &context_keys_before, §ion_ids_before);
263 continue;
264 }
265
266 if line.contains('=') && !line.starts_with('[') {
268 if let Some((key, value)) = self.parse_key_value(line)? {
269 doc.context.insert(key, value);
271 }
272 i += 1;
273
274 self.track_new_entries(&mut doc, &context_keys_before, §ion_ids_before);
276 continue;
277 }
278
279 if line.ends_with(':') && !line.contains('=') && !line.contains('[') {
281 let key = line.trim_end_matches(':').trim().to_string();
282 i += 1;
283
284 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 fn track_new_entries(
315 &self,
316 doc: &mut DxDocument,
317 context_keys_before: &[String],
318 section_ids_before: &[char],
319 ) {
320 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 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 fn parse_array_header(&self, line: &str) -> Option<(String, usize)> {
344 let line = line.trim();
345
346 if !line.ends_with(':') {
348 return None;
349 }
350
351 let without_colon = &line[..line.len() - 1];
352
353 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 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 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 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 if line.ends_with(':') && !line.contains('=') {
408 let key = line.trim_end_matches(':').trim();
409
410 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 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 if let Some((key, value)) = self.parse_key_value(line)? {
452 context.insert(key, value);
454 }
455 }
456
457 Ok((context, consumed))
458 }
459
460 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 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 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 fn parse_key_value(&self, line: &str) -> Result<Option<(String, DxLlmValue)>, HumanParseError> {
494 let line = line.trim();
495
496 if line.starts_with('#') {
498 return Ok(None);
499 }
500
501 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 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 Ok(Some((key, value)))
518 }
519
520 fn parse_config_value(&self, s: &str) -> Result<DxLlmValue, HumanParseError> {
523 let s = s.trim();
524
525 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 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 if s == "null" || s == "none" {
540 return Ok(DxLlmValue::Null);
541 }
542
543 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 if let Ok(n) = s.parse::<f64>() {
558 return Ok(DxLlmValue::Num(n));
559 }
560
561 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 Ok(DxLlmValue::Str(s.to_string()))
574 }
575
576 #[allow(dead_code)] 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 if trimmed.starts_with('[') && !in_table {
592 break;
593 }
594
595 if trimmed.starts_with("# ═") {
597 break;
598 }
599
600 consumed += 1;
601
602 if trimmed.is_empty() {
604 if in_table {
605 break;
607 }
608 continue;
609 }
610
611 if trimmed.starts_with("Total:") {
613 continue;
614 }
615
616 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 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 #[allow(dead_code)] 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 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 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 #[allow(dead_code)] 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 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 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 schema = cells.iter().map(|col| col.to_string()).collect();
705 header_found = true;
706 } else if separator_found {
707 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 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 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 current_row_cells = Some(cells.iter().map(|s| s.to_string()).collect());
736 }
737 }
738 }
739 }
740
741 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 #[allow(dead_code)] 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 if line.starts_with('+') {
770 separator_count += 1;
771 continue;
772 }
773
774 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 #[allow(dead_code)] 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 if line.contains("---") {
814 separator_found = true;
815 continue;
816 }
817
818 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 #[allow(dead_code)] fn parse_cell_value(&self, s: &str) -> DxLlmValue {
849 let s = s.trim();
850
851 if s == "✓" || s == "true" {
853 return DxLlmValue::Bool(true);
854 }
855
856 if s == "✗" || s == "false" {
858 return DxLlmValue::Bool(false);
859 }
860
861 if s == "—" || s == "null" || s == "-" && s.len() == 1 {
863 return DxLlmValue::Null;
864 }
865
866 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 if let Ok(n) = s.parse::<f64>() {
881 return DxLlmValue::Num(n);
882 }
883
884 DxLlmValue::Str(s.to_string())
886 }
887
888 #[allow(dead_code)] fn section_name_to_id(&self, name: &str) -> char {
892 match name.to_lowercase().as_str() {
893 "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 _ => name.chars().next().unwrap_or('x').to_ascii_lowercase(),
922 }
923 }
924
925 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 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}