1use std::collections::BTreeMap;
10
11use crate::error::{AgmError, ErrorCode, ErrorLocation};
12use crate::model::code::{CodeAction, CodeBlock};
13use crate::model::context::{AgentContext, FileRange, LoadFile};
14use crate::model::file::{LoadProfile, TokenEstimate};
15use crate::model::memory::{MemoryAction, MemoryEntry, MemoryScope, MemoryTtl};
16use crate::model::orchestration::{ParallelGroup, Strategy};
17use crate::model::verify::VerifyCheck;
18
19use super::lexer::{Line, LineKind};
20
21enum SubFieldValue {
28 Scalar(String),
29 List(Vec<String>),
30 PipeBody(String),
31}
32
33fn detect_base_indent(lines: &[Line], pos: usize) -> Option<usize> {
42 let mut i = pos;
43 while i < lines.len() {
44 match &lines[i].kind {
45 LineKind::Blank => {
46 i += 1;
47 }
48 LineKind::NodeDeclaration(_) => return None,
49 _ => return Some(lines[i].indent),
50 }
51 }
52 None
53}
54
55fn is_within_field(lines: &[Line], pos: usize, base_indent: usize) -> bool {
58 if pos >= lines.len() {
59 return false;
60 }
61 match &lines[pos].kind {
62 LineKind::Blank => true,
63 LineKind::NodeDeclaration(_) => false,
64 _ => lines[pos].indent >= base_indent,
65 }
66}
67
68fn skip_blanks(lines: &[Line], pos: &mut usize) {
70 while *pos < lines.len() && matches!(&lines[*pos].kind, LineKind::Blank) {
71 *pos += 1;
72 }
73}
74
75fn parse_kv_from_text(text: &str) -> Option<(String, String)> {
78 let trimmed = text.trim();
79 let colon_pos = trimmed.find(':')?;
80 let key = trimmed[..colon_pos].trim();
81 let value = trimmed[colon_pos + 1..].trim();
82 if key.is_empty() {
83 return None;
84 }
85 Some((key.to_owned(), value.to_owned()))
86}
87
88fn collect_pipe_body(lines: &[Line], pos: &mut usize) -> String {
97 let body_indent = match detect_base_indent(lines, *pos) {
99 Some(i) => i,
100 None => return String::new(),
101 };
102
103 let mut parts: Vec<String> = Vec::new();
104
105 while *pos < lines.len() {
106 match &lines[*pos].kind {
107 LineKind::NodeDeclaration(_) => break,
108 LineKind::Blank => {
109 let mut lookahead = *pos + 1;
111 while lookahead < lines.len() && matches!(&lines[lookahead].kind, LineKind::Blank) {
112 lookahead += 1;
113 }
114 let more_body = lookahead < lines.len()
115 && !matches!(&lines[lookahead].kind, LineKind::NodeDeclaration(_))
116 && lines[lookahead].indent >= body_indent;
117
118 if more_body {
119 parts.push(String::new());
120 *pos += 1;
121 } else {
122 break;
123 }
124 }
125 _ => {
126 if lines[*pos].indent < body_indent {
127 break;
128 }
129 let raw = &lines[*pos].raw;
130 let stripped = if raw.len() >= body_indent {
131 raw[body_indent..].to_owned()
132 } else {
133 raw.trim_start().to_owned()
134 };
135 parts.push(stripped);
136 *pos += 1;
137 }
138 }
139 }
140
141 while parts.last().is_some_and(|s: &String| s.is_empty()) {
143 parts.pop();
144 }
145 parts.join("\n")
146}
147
148fn collect_sub_fields(
161 lines: &[Line],
162 pos: &mut usize,
163 base_indent: usize,
164) -> Vec<(String, SubFieldValue)> {
165 let mut fields: Vec<(String, SubFieldValue)> = Vec::new();
166
167 while *pos < lines.len() {
168 match &lines[*pos].kind {
169 LineKind::Blank => {
170 let mut lookahead = *pos + 1;
172 while lookahead < lines.len() && matches!(&lines[lookahead].kind, LineKind::Blank) {
173 lookahead += 1;
174 }
175 let continues = lookahead < lines.len()
176 && !matches!(&lines[lookahead].kind, LineKind::NodeDeclaration(_))
177 && lines[lookahead].indent >= base_indent;
178
179 if continues {
180 *pos += 1;
181 } else {
182 break;
183 }
184 }
185 LineKind::NodeDeclaration(_) => break,
186 _ if lines[*pos].indent < base_indent => break,
187 LineKind::ScalarField(key, value) => {
188 fields.push((key.clone(), SubFieldValue::Scalar(value.clone())));
189 *pos += 1;
190 }
191 LineKind::InlineListField(key, items) => {
192 fields.push((key.clone(), SubFieldValue::List(items.clone())));
193 *pos += 1;
194 }
195 LineKind::BodyMarker => {
196 *pos += 1; let body = collect_pipe_body(lines, pos);
199 fields.push(("body".to_owned(), SubFieldValue::PipeBody(body)));
200 }
201 LineKind::FieldStart(key) => {
202 let key = key.clone();
203 let field_line_indent = lines[*pos].indent;
204 *pos += 1; skip_blanks(lines, pos);
208
209 if *pos >= lines.len() {
210 fields.push((key, SubFieldValue::Scalar(String::new())));
212 continue;
213 }
214
215 let next_indent = lines[*pos].indent;
216
217 match &lines[*pos].kind {
218 LineKind::ListItem(_) => {
219 let mut items = Vec::new();
221 while *pos < lines.len() {
222 match &lines[*pos].kind {
223 LineKind::ListItem(v) => {
224 items.push(v.clone());
225 *pos += 1;
226 }
227 LineKind::Blank => {
228 let mut la = *pos + 1;
229 while la < lines.len()
230 && matches!(&lines[la].kind, LineKind::Blank)
231 {
232 la += 1;
233 }
234 if la < lines.len()
235 && matches!(&lines[la].kind, LineKind::ListItem(_))
236 && lines[la].indent > field_line_indent
237 {
238 *pos += 1;
239 } else {
240 break;
241 }
242 }
243 _ => break,
244 }
245 }
246 fields.push((key, SubFieldValue::List(items)));
247 }
248 _ if next_indent > field_line_indent => {
249 let body_indent = next_indent;
252 let mut body_parts: Vec<String> = Vec::new();
253 while *pos < lines.len() {
254 match &lines[*pos].kind {
255 LineKind::Blank => {
256 let mut la = *pos + 1;
257 while la < lines.len()
258 && matches!(&lines[la].kind, LineKind::Blank)
259 {
260 la += 1;
261 }
262 let more = la < lines.len()
263 && !matches!(&lines[la].kind, LineKind::NodeDeclaration(_))
264 && lines[la].indent >= body_indent;
265 if more {
266 body_parts.push(String::new());
267 *pos += 1;
268 } else {
269 break;
270 }
271 }
272 LineKind::NodeDeclaration(_) => break,
273 _ => {
274 if lines[*pos].indent < body_indent {
275 break;
276 }
277 let raw = &lines[*pos].raw;
278 let stripped = if raw.len() >= body_indent {
279 raw[body_indent..].to_owned()
280 } else {
281 raw.trim_start().to_owned()
282 };
283 body_parts.push(stripped);
284 *pos += 1;
285 }
286 }
287 }
288 while body_parts.last().is_some_and(|s: &String| s.is_empty()) {
289 body_parts.pop();
290 }
291 fields.push((key, SubFieldValue::Scalar(body_parts.join("\n"))));
292 }
293 _ => {
294 fields.push((key, SubFieldValue::Scalar(String::new())));
296 }
297 }
298 }
299 LineKind::ListItem(_) | LineKind::IndentedLine(_) => {
300 break;
302 }
303 _ => {
304 break;
306 }
307 }
308 }
309
310 fields
311}
312
313fn get_scalar<'a>(fields: &'a [(String, SubFieldValue)], key: &str) -> Option<&'a str> {
318 for (k, v) in fields {
319 if k == key {
320 if let SubFieldValue::Scalar(s) = v {
321 return Some(s.as_str());
322 }
323 if let SubFieldValue::PipeBody(s) = v {
324 return Some(s.as_str());
325 }
326 }
327 }
328 None
329}
330
331fn get_list<'a>(fields: &'a [(String, SubFieldValue)], key: &str) -> Option<&'a [String]> {
332 for (k, v) in fields {
333 if k == key {
334 if let SubFieldValue::List(items) = v {
335 return Some(items.as_slice());
336 }
337 }
338 }
339 None
340}
341
342fn build_code_block_from_fields(
347 fields: &[(String, SubFieldValue)],
348 errors: &mut Vec<AgmError>,
349 line_number: usize,
350) -> CodeBlock {
351 let action_str = get_scalar(fields, "action").unwrap_or("");
352 let action = if action_str.is_empty() {
353 errors.push(AgmError::new(
354 ErrorCode::V008,
355 "Code block missing required field: `action`",
356 ErrorLocation::new(None, Some(line_number), None),
357 ));
358 CodeAction::Full } else {
360 match action_str.parse::<CodeAction>() {
361 Ok(a) => a,
362 Err(_) => {
363 errors.push(AgmError::new(
364 ErrorCode::P003,
365 format!("Invalid `action` value in code block: {action_str:?}"),
366 ErrorLocation::new(None, Some(line_number), None),
367 ));
368 CodeAction::Full
369 }
370 }
371 };
372
373 let body = get_scalar(fields, "body").unwrap_or("").to_owned();
374 if body.is_empty() {
375 errors.push(AgmError::new(
376 ErrorCode::V008,
377 "Code block missing required field: `body`",
378 ErrorLocation::new(None, Some(line_number), None),
379 ));
380 }
381
382 let lang = get_scalar(fields, "lang")
383 .filter(|s| !s.is_empty())
384 .map(|s| s.to_owned());
385 let target = get_scalar(fields, "target")
386 .filter(|s| !s.is_empty())
387 .map(|s| s.to_owned());
388 let anchor = get_scalar(fields, "anchor")
389 .filter(|s| !s.is_empty())
390 .map(|s| s.to_owned());
391 let old = get_scalar(fields, "old")
392 .filter(|s| !s.is_empty())
393 .map(|s| s.to_owned());
394
395 CodeBlock {
396 lang,
397 target,
398 action,
399 body,
400 anchor,
401 old,
402 }
403}
404
405pub(crate) fn parse_code_block(
413 lines: &[Line],
414 pos: &mut usize,
415 errors: &mut Vec<AgmError>,
416) -> CodeBlock {
417 let line_number = if *pos > 0 {
418 lines.get(*pos - 1).map_or(0, |l| l.number)
419 } else {
420 0
421 };
422
423 let base_indent = match detect_base_indent(lines, *pos) {
424 Some(i) => i,
425 None => {
426 errors.push(AgmError::new(
427 ErrorCode::V008,
428 "Code block missing required field: `action`",
429 ErrorLocation::new(None, Some(line_number), None),
430 ));
431 errors.push(AgmError::new(
432 ErrorCode::V008,
433 "Code block missing required field: `body`",
434 ErrorLocation::new(None, Some(line_number), None),
435 ));
436 return CodeBlock {
437 lang: None,
438 target: None,
439 action: CodeAction::Full,
440 body: String::new(),
441 anchor: None,
442 old: None,
443 };
444 }
445 };
446
447 let fields = collect_sub_fields(lines, pos, base_indent);
448 build_code_block_from_fields(&fields, errors, line_number)
449}
450
451pub(crate) fn parse_code_blocks(
459 lines: &[Line],
460 pos: &mut usize,
461 errors: &mut Vec<AgmError>,
462) -> Vec<CodeBlock> {
463 let base_indent = match detect_base_indent(lines, *pos) {
464 Some(i) => i,
465 None => return Vec::new(),
466 };
467
468 let mut blocks = Vec::new();
469
470 while is_within_field(lines, *pos, base_indent) {
471 skip_blanks(lines, pos);
472 if !is_within_field(lines, *pos, base_indent) {
473 break;
474 }
475
476 match &lines[*pos].kind {
477 LineKind::ListItem(text) => {
478 let line_number = lines[*pos].number;
479 let text = text.clone();
480 *pos += 1;
481
482 let mut fields: Vec<(String, SubFieldValue)> = Vec::new();
485
486 if !text.is_empty() {
488 if let Some((k, v)) = parse_kv_from_text(&text) {
489 fields.push((k, SubFieldValue::Scalar(v)));
490 }
491 }
492
493 let sub_indent = detect_base_indent(lines, *pos);
495 if let Some(si) = sub_indent {
496 if si > base_indent {
497 let mut sub = collect_sub_fields(lines, pos, si);
498 fields.append(&mut sub);
499 }
500 }
501
502 blocks.push(build_code_block_from_fields(&fields, errors, line_number));
503 }
504 _ => break,
505 }
506 }
507
508 blocks
509}
510
511pub(crate) fn parse_verify(
519 lines: &[Line],
520 pos: &mut usize,
521 errors: &mut Vec<AgmError>,
522) -> Vec<VerifyCheck> {
523 let base_indent = match detect_base_indent(lines, *pos) {
524 Some(i) => i,
525 None => return Vec::new(),
526 };
527
528 let mut checks = Vec::new();
529
530 while is_within_field(lines, *pos, base_indent) {
531 skip_blanks(lines, pos);
532 if !is_within_field(lines, *pos, base_indent) {
533 break;
534 }
535
536 match &lines[*pos].kind {
537 LineKind::ListItem(text) => {
538 let line_number = lines[*pos].number;
539 let text = text.clone();
540 *pos += 1;
541
542 let mut fields: Vec<(String, SubFieldValue)> = Vec::new();
543
544 if !text.is_empty() {
546 if let Some((k, v)) = parse_kv_from_text(&text) {
547 fields.push((k, SubFieldValue::Scalar(v)));
548 }
549 }
550
551 let sub_indent = detect_base_indent(lines, *pos);
553 if let Some(si) = sub_indent {
554 if si > base_indent {
555 let mut sub = collect_sub_fields(lines, pos, si);
556 fields.append(&mut sub);
557 }
558 }
559
560 let check = build_verify_check_from_fields(&fields, errors, line_number);
561 if let Some(c) = check {
562 checks.push(c);
563 }
564 }
565 _ => break,
566 }
567 }
568
569 checks
570}
571
572fn build_verify_check_from_fields(
573 fields: &[(String, SubFieldValue)],
574 errors: &mut Vec<AgmError>,
575 line_number: usize,
576) -> Option<VerifyCheck> {
577 let type_val = match get_scalar(fields, "type") {
578 Some(t) if !t.is_empty() => t,
579 _ => {
580 errors.push(AgmError::new(
581 ErrorCode::V009,
582 "Verify entry missing required field: `type`",
583 ErrorLocation::new(None, Some(line_number), None),
584 ));
585 return None;
586 }
587 };
588
589 match type_val {
590 "command" => {
591 let run = match get_scalar(fields, "run") {
592 Some(r) if !r.is_empty() => r.to_owned(),
593 _ => {
594 errors.push(AgmError::new(
595 ErrorCode::V009,
596 "Verify entry missing required field: `run`",
597 ErrorLocation::new(None, Some(line_number), None),
598 ));
599 return None;
600 }
601 };
602 let expect = get_scalar(fields, "expect")
603 .filter(|s| !s.is_empty())
604 .map(|s| s.to_owned());
605 Some(VerifyCheck::Command { run, expect })
606 }
607 "file_exists" => {
608 let file = match get_scalar(fields, "file") {
609 Some(f) if !f.is_empty() => f.to_owned(),
610 _ => {
611 errors.push(AgmError::new(
612 ErrorCode::V009,
613 "Verify entry missing required field: `file`",
614 ErrorLocation::new(None, Some(line_number), None),
615 ));
616 return None;
617 }
618 };
619 Some(VerifyCheck::FileExists { file })
620 }
621 "file_contains" => {
622 let file = match get_scalar(fields, "file") {
623 Some(f) if !f.is_empty() => f.to_owned(),
624 _ => {
625 errors.push(AgmError::new(
626 ErrorCode::V009,
627 "Verify entry missing required field: `file`",
628 ErrorLocation::new(None, Some(line_number), None),
629 ));
630 return None;
631 }
632 };
633 let pattern = match get_scalar(fields, "pattern") {
634 Some(p) if !p.is_empty() => p.to_owned(),
635 _ => {
636 errors.push(AgmError::new(
637 ErrorCode::V009,
638 "Verify entry missing required field: `pattern`",
639 ErrorLocation::new(None, Some(line_number), None),
640 ));
641 return None;
642 }
643 };
644 Some(VerifyCheck::FileContains { file, pattern })
645 }
646 "file_not_contains" => {
647 let file = match get_scalar(fields, "file") {
648 Some(f) if !f.is_empty() => f.to_owned(),
649 _ => {
650 errors.push(AgmError::new(
651 ErrorCode::V009,
652 "Verify entry missing required field: `file`",
653 ErrorLocation::new(None, Some(line_number), None),
654 ));
655 return None;
656 }
657 };
658 let pattern = match get_scalar(fields, "pattern") {
659 Some(p) if !p.is_empty() => p.to_owned(),
660 _ => {
661 errors.push(AgmError::new(
662 ErrorCode::V009,
663 "Verify entry missing required field: `pattern`",
664 ErrorLocation::new(None, Some(line_number), None),
665 ));
666 return None;
667 }
668 };
669 Some(VerifyCheck::FileNotContains { file, pattern })
670 }
671 "node_status" => {
672 let node = match get_scalar(fields, "node") {
673 Some(n) if !n.is_empty() => n.to_owned(),
674 _ => {
675 errors.push(AgmError::new(
676 ErrorCode::V009,
677 "Verify entry missing required field: `node`",
678 ErrorLocation::new(None, Some(line_number), None),
679 ));
680 return None;
681 }
682 };
683 let status = match get_scalar(fields, "status") {
684 Some(s) if !s.is_empty() => s.to_owned(),
685 _ => {
686 errors.push(AgmError::new(
687 ErrorCode::V009,
688 "Verify entry missing required field: `status`",
689 ErrorLocation::new(None, Some(line_number), None),
690 ));
691 return None;
692 }
693 };
694 Some(VerifyCheck::NodeStatus { node, status })
695 }
696 unknown => {
697 errors.push(AgmError::new(
698 ErrorCode::P003,
699 format!("Unknown verify type: {unknown:?}"),
700 ErrorLocation::new(None, Some(line_number), None),
701 ));
702 None
703 }
704 }
705}
706
707fn parse_file_range(s: &str) -> FileRange {
713 if s == "full" {
714 return FileRange::Full;
715 }
716 if let Some(name) = s.strip_prefix("function:") {
717 return FileRange::Function(name.trim().to_owned());
718 }
719 if let Some(dash_pos) = s.find('-') {
721 let start_str = &s[..dash_pos];
722 let end_str = &s[dash_pos + 1..];
723 if let (Ok(start), Ok(end)) = (
724 start_str.trim().parse::<u64>(),
725 end_str.trim().parse::<u64>(),
726 ) {
727 return FileRange::Lines(start, end);
728 }
729 }
730 FileRange::Full
732}
733
734fn parse_load_files_list(
740 lines: &[Line],
741 pos: &mut usize,
742 base_indent: usize,
743 errors: &mut Vec<AgmError>,
744) -> Vec<LoadFile> {
745 let mut files = Vec::new();
746
747 while is_within_field(lines, *pos, base_indent) {
748 skip_blanks(lines, pos);
749 if !is_within_field(lines, *pos, base_indent) {
750 break;
751 }
752
753 match &lines[*pos].kind {
754 LineKind::ListItem(text) => {
755 let line_number = lines[*pos].number;
756 let text = text.clone();
757 *pos += 1;
758
759 let mut fields: Vec<(String, SubFieldValue)> = Vec::new();
760
761 if !text.is_empty() {
762 if let Some((k, v)) = parse_kv_from_text(&text) {
763 fields.push((k, SubFieldValue::Scalar(v)));
764 }
765 }
766
767 let sub_indent = detect_base_indent(lines, *pos);
768 if let Some(si) = sub_indent {
769 if si > base_indent {
770 let mut sub = collect_sub_fields(lines, pos, si);
771 fields.append(&mut sub);
772 }
773 }
774
775 let path = match get_scalar(&fields, "path") {
776 Some(p) if !p.is_empty() => p.to_owned(),
777 _ => {
778 errors.push(AgmError::new(
779 ErrorCode::P003,
780 "load_files entry missing required field: `path`",
781 ErrorLocation::new(None, Some(line_number), None),
782 ));
783 continue;
784 }
785 };
786
787 let range = get_scalar(&fields, "range")
788 .map(parse_file_range)
789 .unwrap_or(FileRange::Full);
790
791 files.push(LoadFile { path, range });
792 }
793 _ => break,
794 }
795 }
796
797 files
798}
799
800pub(crate) fn parse_agent_context(
808 lines: &[Line],
809 pos: &mut usize,
810 errors: &mut Vec<AgmError>,
811) -> AgentContext {
812 let base_indent = match detect_base_indent(lines, *pos) {
813 Some(i) => i,
814 None => {
815 return AgentContext {
816 load_nodes: None,
817 load_files: None,
818 system_hint: None,
819 max_tokens: None,
820 load_memory: None,
821 };
822 }
823 };
824
825 let mut load_nodes: Option<Vec<String>> = None;
826 let mut load_files: Option<Vec<LoadFile>> = None;
827 let mut system_hint: Option<String> = None;
828 let mut max_tokens: Option<u64> = None;
829 let mut load_memory: Option<Vec<String>> = None;
830
831 while is_within_field(lines, *pos, base_indent) {
832 skip_blanks(lines, pos);
833 if !is_within_field(lines, *pos, base_indent) {
834 break;
835 }
836
837 match &lines[*pos].kind.clone() {
838 LineKind::ScalarField(key, value) if lines[*pos].indent == base_indent => {
839 match key.as_str() {
840 "system_hint" => system_hint = Some(value.clone()),
841 "max_tokens" => {
842 if let Ok(n) = value.parse::<u64>() {
843 max_tokens = Some(n);
844 } else {
845 errors.push(AgmError::new(
846 ErrorCode::P003,
847 format!("Invalid `max_tokens` value: {value:?}"),
848 ErrorLocation::new(None, Some(lines[*pos].number), None),
849 ));
850 }
851 }
852 _ => {}
853 }
854 *pos += 1;
855 }
856 LineKind::InlineListField(key, items) if lines[*pos].indent == base_indent => {
857 match key.as_str() {
858 "load_nodes" => load_nodes = Some(items.clone()),
859 "load_memory" => load_memory = Some(items.clone()),
860 _ => {}
861 }
862 *pos += 1;
863 }
864 LineKind::FieldStart(key) if lines[*pos].indent == base_indent => {
865 let key = key.clone();
866 *pos += 1;
867 match key.as_str() {
868 "load_nodes" => {
869 let sub_indent = detect_base_indent(lines, *pos);
871 if let Some(si) = sub_indent {
872 if si > base_indent {
873 let mut items = Vec::new();
874 while is_within_field(lines, *pos, si) {
875 skip_blanks(lines, pos);
876 if !is_within_field(lines, *pos, si) {
877 break;
878 }
879 if let LineKind::ListItem(v) = &lines[*pos].kind {
880 items.push(v.clone());
881 *pos += 1;
882 } else {
883 break;
884 }
885 }
886 load_nodes = Some(items);
887 }
888 }
889 }
890 "load_memory" => {
891 let sub_indent = detect_base_indent(lines, *pos);
892 if let Some(si) = sub_indent {
893 if si > base_indent {
894 let mut items = Vec::new();
895 while is_within_field(lines, *pos, si) {
896 skip_blanks(lines, pos);
897 if !is_within_field(lines, *pos, si) {
898 break;
899 }
900 if let LineKind::ListItem(v) = &lines[*pos].kind {
901 items.push(v.clone());
902 *pos += 1;
903 } else {
904 break;
905 }
906 }
907 load_memory = Some(items);
908 }
909 }
910 }
911 "load_files" => {
912 let sub_indent = detect_base_indent(lines, *pos);
913 if let Some(si) = sub_indent {
914 if si > base_indent {
915 let fl = parse_load_files_list(lines, pos, si, errors);
916 if !fl.is_empty() {
917 load_files = Some(fl);
918 }
919 }
920 }
921 }
922 _ => {
923 let sub_indent = detect_base_indent(lines, *pos);
925 if let Some(si) = sub_indent {
926 if si > base_indent {
927 while is_within_field(lines, *pos, si) {
928 skip_blanks(lines, pos);
929 if !is_within_field(lines, *pos, si) {
930 break;
931 }
932 *pos += 1;
933 }
934 }
935 }
936 }
937 }
938 }
939 _ => {
940 *pos += 1;
941 }
942 }
943 }
944
945 AgentContext {
946 load_nodes,
947 load_files,
948 system_hint,
949 max_tokens,
950 load_memory,
951 }
952}
953
954pub(crate) fn parse_parallel_groups(
962 lines: &[Line],
963 pos: &mut usize,
964 errors: &mut Vec<AgmError>,
965) -> Vec<ParallelGroup> {
966 let base_indent = match detect_base_indent(lines, *pos) {
967 Some(i) => i,
968 None => return Vec::new(),
969 };
970
971 let mut groups = Vec::new();
972
973 while is_within_field(lines, *pos, base_indent) {
974 skip_blanks(lines, pos);
975 if !is_within_field(lines, *pos, base_indent) {
976 break;
977 }
978
979 match &lines[*pos].kind {
980 LineKind::ListItem(text) => {
981 let line_number = lines[*pos].number;
982 let text = text.clone();
983 *pos += 1;
984
985 let mut fields: Vec<(String, SubFieldValue)> = Vec::new();
986
987 if !text.is_empty() {
988 if let Some((k, v)) = parse_kv_from_text(&text) {
989 fields.push((k, SubFieldValue::Scalar(v)));
990 }
991 }
992
993 let sub_indent = detect_base_indent(lines, *pos);
994 if let Some(si) = sub_indent {
995 if si > base_indent {
996 let mut sub = collect_sub_fields(lines, pos, si);
997 fields.append(&mut sub);
998 }
999 }
1000
1001 let group = match get_scalar(&fields, "group") {
1002 Some(g) if !g.is_empty() => g.to_owned(),
1003 _ => {
1004 errors.push(AgmError::new(
1005 ErrorCode::P003,
1006 "parallel_groups entry missing required field: `group`",
1007 ErrorLocation::new(None, Some(line_number), None),
1008 ));
1009 continue;
1010 }
1011 };
1012
1013 let nodes = get_list(&fields, "nodes")
1014 .map(|s| s.to_vec())
1015 .unwrap_or_default();
1016
1017 let strategy_str = get_scalar(&fields, "strategy").unwrap_or("sequential");
1018 let strategy = strategy_str.parse::<Strategy>().unwrap_or_else(|_| {
1019 errors.push(AgmError::new(
1020 ErrorCode::P003,
1021 format!("Invalid `strategy` value: {strategy_str:?}"),
1022 ErrorLocation::new(None, Some(line_number), None),
1023 ));
1024 Strategy::Sequential
1025 });
1026
1027 let requires = get_list(&fields, "requires").map(|s| s.to_vec());
1028
1029 let max_concurrency =
1030 get_scalar(&fields, "max_concurrency").and_then(|s| s.parse::<u32>().ok());
1031
1032 groups.push(ParallelGroup {
1033 group,
1034 nodes,
1035 strategy,
1036 requires,
1037 max_concurrency,
1038 });
1039 }
1040 _ => break,
1041 }
1042 }
1043
1044 groups
1045}
1046
1047pub(crate) fn parse_load_profiles(
1055 lines: &[Line],
1056 pos: &mut usize,
1057 errors: &mut Vec<AgmError>,
1058) -> BTreeMap<String, LoadProfile> {
1059 let base_indent = match detect_base_indent(lines, *pos) {
1060 Some(i) => i,
1061 None => return BTreeMap::new(),
1062 };
1063
1064 let mut profiles = BTreeMap::new();
1065
1066 while is_within_field(lines, *pos, base_indent) {
1067 skip_blanks(lines, pos);
1068 if !is_within_field(lines, *pos, base_indent) {
1069 break;
1070 }
1071
1072 match &lines[*pos].kind.clone() {
1074 LineKind::FieldStart(name) if lines[*pos].indent == base_indent => {
1075 let name = name.clone();
1076 *pos += 1;
1077
1078 let sub_indent = match detect_base_indent(lines, *pos) {
1079 Some(si) if si > base_indent => si,
1080 _ => continue,
1081 };
1082
1083 let sub_fields = collect_sub_fields(lines, pos, sub_indent);
1084
1085 let filter = get_scalar(&sub_fields, "filter").unwrap_or("").to_owned();
1086
1087 if filter.is_empty() {
1088 errors.push(AgmError::new(
1089 ErrorCode::P003,
1090 format!("load_profile {name:?} missing required field: `filter`"),
1091 ErrorLocation::new(None, None, None),
1092 ));
1093 }
1094
1095 let estimated_tokens = get_scalar(&sub_fields, "estimated_tokens")
1096 .and_then(|s| s.parse::<TokenEstimate>().ok());
1097
1098 profiles.insert(
1099 name,
1100 LoadProfile {
1101 filter,
1102 estimated_tokens,
1103 },
1104 );
1105 }
1106 _ => {
1107 *pos += 1;
1108 }
1109 }
1110 }
1111
1112 profiles
1113}
1114
1115pub(crate) fn parse_memory(
1123 lines: &[Line],
1124 pos: &mut usize,
1125 errors: &mut Vec<AgmError>,
1126) -> Vec<MemoryEntry> {
1127 let base_indent = match detect_base_indent(lines, *pos) {
1128 Some(i) => i,
1129 None => return Vec::new(),
1130 };
1131
1132 let mut entries = Vec::new();
1133
1134 while is_within_field(lines, *pos, base_indent) {
1135 skip_blanks(lines, pos);
1136 if !is_within_field(lines, *pos, base_indent) {
1137 break;
1138 }
1139
1140 match &lines[*pos].kind {
1141 LineKind::ListItem(text) => {
1142 let line_number = lines[*pos].number;
1143 let text = text.clone();
1144 *pos += 1;
1145
1146 let mut fields: Vec<(String, SubFieldValue)> = Vec::new();
1147
1148 if !text.is_empty() {
1149 if let Some((k, v)) = parse_kv_from_text(&text) {
1150 fields.push((k, SubFieldValue::Scalar(v)));
1151 }
1152 }
1153
1154 let sub_indent = detect_base_indent(lines, *pos);
1155 if let Some(si) = sub_indent {
1156 if si > base_indent {
1157 let mut sub = collect_sub_fields(lines, pos, si);
1158 fields.append(&mut sub);
1159 }
1160 }
1161
1162 let key = match get_scalar(&fields, "key") {
1163 Some(k) if !k.is_empty() => k.to_owned(),
1164 _ => {
1165 errors.push(AgmError::new(
1166 ErrorCode::P003,
1167 "memory entry missing required field: `key`",
1168 ErrorLocation::new(None, Some(line_number), None),
1169 ));
1170 continue;
1171 }
1172 };
1173
1174 let topic = match get_scalar(&fields, "topic") {
1175 Some(t) if !t.is_empty() => t.to_owned(),
1176 _ => {
1177 errors.push(AgmError::new(
1178 ErrorCode::P003,
1179 "memory entry missing required field: `topic`",
1180 ErrorLocation::new(None, Some(line_number), None),
1181 ));
1182 continue;
1183 }
1184 };
1185
1186 let action_str = get_scalar(&fields, "action").unwrap_or("");
1187 let action = match action_str.parse::<MemoryAction>() {
1188 Ok(a) => a,
1189 Err(_) => {
1190 errors.push(AgmError::new(
1191 ErrorCode::P003,
1192 format!("memory entry missing or invalid `action`: {action_str:?}"),
1193 ErrorLocation::new(None, Some(line_number), None),
1194 ));
1195 continue;
1196 }
1197 };
1198
1199 let value = get_scalar(&fields, "value")
1200 .filter(|s| !s.is_empty())
1201 .map(|s| s.to_owned());
1202
1203 let scope =
1204 get_scalar(&fields, "scope").and_then(|s| s.parse::<MemoryScope>().ok());
1205
1206 let ttl = get_scalar(&fields, "ttl").and_then(|s| s.parse::<MemoryTtl>().ok());
1207
1208 let query = get_scalar(&fields, "query")
1209 .filter(|s| !s.is_empty())
1210 .map(|s| s.to_owned());
1211
1212 let max_results =
1213 get_scalar(&fields, "max_results").and_then(|s| s.parse::<u32>().ok());
1214
1215 entries.push(MemoryEntry {
1216 key,
1217 topic,
1218 action,
1219 value,
1220 scope,
1221 ttl,
1222 query,
1223 max_results,
1224 });
1225 }
1226 _ => break,
1227 }
1228 }
1229
1230 entries
1231}
1232
1233#[cfg(test)]
1238mod tests {
1239 use super::*;
1240 use crate::parser::lexer::lex;
1241
1242 fn parse_structured<F, T>(input: &str, parser: F) -> (T, Vec<AgmError>)
1244 where
1245 F: FnOnce(&[Line], &mut usize, &mut Vec<AgmError>) -> T,
1246 {
1247 let lines = lex(input).expect("lex failed");
1248 let mut pos = 0;
1249 let mut errors = Vec::new();
1250 let result = parser(&lines, &mut pos, &mut errors);
1251 (result, errors)
1252 }
1253
1254 #[test]
1259 fn test_parse_code_block_minimal_action_and_body_returns_code_block() {
1260 let input = " action: create\n body: |
1262 fn main() {}
1263";
1264 let (cb, errors) = parse_structured(input, parse_code_block);
1265 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1266 assert_eq!(cb.action, CodeAction::Create);
1267 assert_eq!(cb.body, "fn main() {}");
1268 }
1269
1270 #[test]
1271 fn test_parse_code_block_all_fields_returns_full_code_block() {
1272 let input = " lang: rust\n target: src/main.rs\n action: append\n body: |\n fn foo() {}\n anchor: // anchor\n";
1274 let (cb, errors) = parse_structured(input, parse_code_block);
1275 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1276 assert_eq!(cb.lang.as_deref(), Some("rust"));
1277 assert_eq!(cb.target.as_deref(), Some("src/main.rs"));
1278 assert_eq!(cb.action, CodeAction::Append);
1279 assert_eq!(cb.body, "fn foo() {}");
1280 assert_eq!(cb.anchor.as_deref(), Some("// anchor"));
1281 }
1282
1283 #[test]
1284 fn test_parse_code_block_missing_action_emits_v008_and_uses_fallback() {
1285 let input = " body: |\n some code\n";
1287 let (cb, errors) = parse_structured(input, parse_code_block);
1288 assert!(
1289 errors.iter().any(|e| e.code == ErrorCode::V008),
1290 "expected V008"
1291 );
1292 assert_eq!(cb.action, CodeAction::Full);
1293 assert_eq!(cb.body, "some code");
1294 }
1295
1296 #[test]
1297 fn test_parse_code_block_missing_body_emits_v008() {
1298 let input = " action: create\n";
1300 let (_cb, errors) = parse_structured(input, parse_code_block);
1301 assert!(
1302 errors.iter().any(|e| e.code == ErrorCode::V008),
1303 "expected V008 for missing body"
1304 );
1305 }
1306
1307 #[test]
1308 fn test_parse_code_block_invalid_action_emits_p003_and_uses_fallback() {
1309 let input = " action: overwrite\n body: |\n code\n";
1311 let (cb, errors) = parse_structured(input, parse_code_block);
1312 assert!(
1313 errors.iter().any(|e| e.code == ErrorCode::P003),
1314 "expected P003"
1315 );
1316 assert_eq!(cb.action, CodeAction::Full);
1317 }
1318
1319 #[test]
1320 fn test_parse_code_block_body_scalar_value_parsed_correctly() {
1321 let input = " action: full\n body: inline body text\n";
1323 let (cb, errors) = parse_structured(input, parse_code_block);
1324 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1325 assert_eq!(cb.body, "inline body text");
1326 }
1327
1328 #[test]
1329 fn test_parse_code_block_with_old_field_returns_old() {
1330 let input = " action: replace\n body: |\n new code\n old: fn old_impl() {}\n";
1332 let (cb, errors) = parse_structured(input, parse_code_block);
1333 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1334 assert_eq!(cb.action, CodeAction::Replace);
1335 assert_eq!(cb.old.as_deref(), Some("fn old_impl() {}"));
1336 }
1337
1338 #[test]
1343 fn test_parse_code_blocks_single_item_returns_one_block() {
1344 let input = " - action: create\n body: |\n fn main() {}\n";
1346 let (blocks, errors) = parse_structured(input, parse_code_blocks);
1347 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1348 assert_eq!(blocks.len(), 1);
1349 assert_eq!(blocks[0].action, CodeAction::Create);
1350 }
1351
1352 #[test]
1353 fn test_parse_code_blocks_multiple_items_returns_all() {
1354 let input = " - action: create\n body: |\n fn a() {}\n - action: append\n body: |\n fn b() {}\n";
1356 let (blocks, errors) = parse_structured(input, parse_code_blocks);
1357 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1358 assert_eq!(blocks.len(), 2);
1359 assert_eq!(blocks[0].action, CodeAction::Create);
1360 assert_eq!(blocks[1].action, CodeAction::Append);
1361 }
1362
1363 #[test]
1364 fn test_parse_code_blocks_empty_returns_empty_vec() {
1365 let input = "";
1367 let (blocks, errors) = parse_structured(input, parse_code_blocks);
1368 assert!(errors.is_empty());
1369 assert_eq!(blocks.len(), 0);
1370 }
1371
1372 #[test]
1373 fn test_parse_code_blocks_item_missing_action_emits_v008() {
1374 let input = " - body: |\n some code\n";
1376 let (_blocks, errors) = parse_structured(input, parse_code_blocks);
1377 assert!(
1378 errors.iter().any(|e| e.code == ErrorCode::V008),
1379 "expected V008"
1380 );
1381 }
1382
1383 #[test]
1384 fn test_parse_code_blocks_stops_at_unindented_content() {
1385 let input = " - action: create\n body: inline\nsummary: something\n";
1387 let (blocks, _errors) = parse_structured(input, parse_code_blocks);
1388 assert_eq!(blocks.len(), 1);
1389 }
1390
1391 #[test]
1396 fn test_parse_verify_command_inline_returns_command_check() {
1397 let input = " - type: command\n run: cargo check\n";
1399 let (checks, errors) = parse_structured(input, parse_verify);
1400 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1401 assert_eq!(checks.len(), 1);
1402 assert!(matches!(checks[0], VerifyCheck::Command { .. }));
1403 }
1404
1405 #[test]
1406 fn test_parse_verify_command_with_expect_returns_check() {
1407 let input = " - type: command\n run: cargo test\n expect: exit_code_0\n";
1409 let (checks, errors) = parse_structured(input, parse_verify);
1410 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1411 if let VerifyCheck::Command { run, expect } = &checks[0] {
1412 assert_eq!(run, "cargo test");
1413 assert_eq!(expect.as_deref(), Some("exit_code_0"));
1414 } else {
1415 panic!("expected Command check");
1416 }
1417 }
1418
1419 #[test]
1420 fn test_parse_verify_file_exists_returns_file_exists_check() {
1421 let input = " - type: file_exists\n file: src/main.rs\n";
1423 let (checks, errors) = parse_structured(input, parse_verify);
1424 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1425 assert!(matches!(checks[0], VerifyCheck::FileExists { .. }));
1426 }
1427
1428 #[test]
1429 fn test_parse_verify_file_contains_returns_file_contains_check() {
1430 let input = " - type: file_contains\n file: src/lib.rs\n pattern: fn main\n";
1432 let (checks, errors) = parse_structured(input, parse_verify);
1433 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1434 assert!(matches!(checks[0], VerifyCheck::FileContains { .. }));
1435 }
1436
1437 #[test]
1438 fn test_parse_verify_file_not_contains_returns_correct_check() {
1439 let input = " - type: file_not_contains\n file: src/lib.rs\n pattern: unsafe\n";
1441 let (checks, errors) = parse_structured(input, parse_verify);
1442 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1443 assert!(matches!(checks[0], VerifyCheck::FileNotContains { .. }));
1444 }
1445
1446 #[test]
1447 fn test_parse_verify_node_status_returns_node_status_check() {
1448 let input = " - type: node_status\n node: auth.login\n status: completed\n";
1450 let (checks, errors) = parse_structured(input, parse_verify);
1451 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1452 if let VerifyCheck::NodeStatus { node, status } = &checks[0] {
1453 assert_eq!(node, "auth.login");
1454 assert_eq!(status, "completed");
1455 } else {
1456 panic!("expected NodeStatus check");
1457 }
1458 }
1459
1460 #[test]
1461 fn test_parse_verify_missing_type_emits_v009_and_skips_entry() {
1462 let input = " - run: cargo check\n";
1464 let (checks, errors) = parse_structured(input, parse_verify);
1465 assert!(
1466 errors.iter().any(|e| e.code == ErrorCode::V009),
1467 "expected V009"
1468 );
1469 assert_eq!(checks.len(), 0);
1470 }
1471
1472 #[test]
1473 fn test_parse_verify_multiple_checks_returns_all() {
1474 let input = " - type: command\n run: cargo check\n - type: file_exists\n file: Cargo.toml\n";
1476 let (checks, errors) = parse_structured(input, parse_verify);
1477 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1478 assert_eq!(checks.len(), 2);
1479 }
1480
1481 #[test]
1486 fn test_parse_agent_context_system_hint_only() {
1487 let input = " system_hint: Rust project\n";
1489 let (ctx, errors) = parse_structured(input, parse_agent_context);
1490 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1491 assert_eq!(ctx.system_hint.as_deref(), Some("Rust project"));
1492 }
1493
1494 #[test]
1495 fn test_parse_agent_context_load_nodes_inline_list() {
1496 let input = " load_nodes: [auth.login, auth.session]\n";
1498 let (ctx, errors) = parse_structured(input, parse_agent_context);
1499 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1500 let nodes = ctx.load_nodes.as_deref().unwrap();
1501 assert_eq!(nodes, &["auth.login", "auth.session"]);
1502 }
1503
1504 #[test]
1505 fn test_parse_agent_context_max_tokens_parsed_as_u64() {
1506 let input = " max_tokens: 4000\n";
1508 let (ctx, errors) = parse_structured(input, parse_agent_context);
1509 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1510 assert_eq!(ctx.max_tokens, Some(4000));
1511 }
1512
1513 #[test]
1514 fn test_parse_agent_context_invalid_max_tokens_emits_p003() {
1515 let input = " max_tokens: not_a_number\n";
1517 let (_ctx, errors) = parse_structured(input, parse_agent_context);
1518 assert!(
1519 errors.iter().any(|e| e.code == ErrorCode::P003),
1520 "expected P003"
1521 );
1522 }
1523
1524 #[test]
1525 fn test_parse_agent_context_full_returns_all_fields() {
1526 let input = " system_hint: Rust project\n max_tokens: 4000\n load_nodes: [auth.login]\n load_memory: [rust.repo]\n";
1528 let (ctx, errors) = parse_structured(input, parse_agent_context);
1529 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1530 assert_eq!(ctx.system_hint.as_deref(), Some("Rust project"));
1531 assert_eq!(ctx.max_tokens, Some(4000));
1532 assert!(ctx.load_nodes.is_some());
1533 assert!(ctx.load_memory.is_some());
1534 }
1535
1536 #[test]
1541 fn test_parse_parallel_groups_single_group_returns_one_group() {
1542 let input =
1544 " - group: 1-schema\n nodes: [migration.schema]\n strategy: sequential\n";
1545 let (groups, errors) = parse_structured(input, parse_parallel_groups);
1546 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1547 assert_eq!(groups.len(), 1);
1548 assert_eq!(groups[0].group, "1-schema");
1549 assert_eq!(groups[0].strategy, Strategy::Sequential);
1550 }
1551
1552 #[test]
1553 fn test_parse_parallel_groups_multiple_groups_returns_all() {
1554 let input = " - group: g1\n nodes: [n.one]\n strategy: sequential\n - group: g2\n nodes: [n.two]\n strategy: parallel\n";
1556 let (groups, errors) = parse_structured(input, parse_parallel_groups);
1557 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1558 assert_eq!(groups.len(), 2);
1559 }
1560
1561 #[test]
1562 fn test_parse_parallel_groups_missing_group_field_emits_p003() {
1563 let input = " - nodes: [n.one]\n strategy: sequential\n";
1565 let (_groups, errors) = parse_structured(input, parse_parallel_groups);
1566 assert!(
1567 errors.iter().any(|e| e.code == ErrorCode::P003),
1568 "expected P003"
1569 );
1570 }
1571
1572 #[test]
1573 fn test_parse_parallel_groups_with_requires_and_max_concurrency() {
1574 let input = " - group: g1\n nodes: [n.one, n.two]\n strategy: parallel\n requires: [g0]\n max_concurrency: 4\n";
1576 let (groups, errors) = parse_structured(input, parse_parallel_groups);
1577 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1578 assert_eq!(groups[0].requires.as_deref(), Some(&["g0".to_owned()][..]));
1579 assert_eq!(groups[0].max_concurrency, Some(4));
1580 }
1581
1582 #[test]
1587 fn test_parse_load_profiles_single_profile_returns_one_entry() {
1588 let input = " summary:\n filter: type in [facts]\n";
1590 let (profiles, errors) = parse_structured(input, parse_load_profiles);
1591 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1592 assert!(profiles.contains_key("summary"));
1593 assert_eq!(profiles["summary"].filter, "type in [facts]");
1594 }
1595
1596 #[test]
1597 fn test_parse_load_profiles_multiple_profiles_returns_all() {
1598 let input = " summary:\n filter: type in [facts]\n operational:\n filter: priority in [critical]\n";
1600 let (profiles, errors) = parse_structured(input, parse_load_profiles);
1601 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1602 assert_eq!(profiles.len(), 2);
1603 assert!(profiles.contains_key("summary"));
1604 assert!(profiles.contains_key("operational"));
1605 }
1606
1607 #[test]
1608 fn test_parse_load_profiles_with_estimated_tokens() {
1609 let input = " summary:\n filter: type in [facts]\n estimated_tokens: 1200\n";
1611 let (profiles, errors) = parse_structured(input, parse_load_profiles);
1612 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1613 assert_eq!(
1614 profiles["summary"].estimated_tokens,
1615 Some(TokenEstimate::Count(1200))
1616 );
1617 }
1618
1619 #[test]
1620 fn test_parse_load_profiles_missing_filter_emits_p003() {
1621 let input = " summary:\n estimated_tokens: 1200\n";
1623 let (_profiles, errors) = parse_structured(input, parse_load_profiles);
1624 assert!(
1625 errors.iter().any(|e| e.code == ErrorCode::P003),
1626 "expected P003 for missing filter"
1627 );
1628 }
1629
1630 #[test]
1635 fn test_parse_memory_upsert_entry_returns_memory_entry() {
1636 let input = " - key: repo.pattern\n topic: rust.repository\n action: upsert\n value: row_to_column uses get()\n";
1638 let (entries, errors) = parse_structured(input, parse_memory);
1639 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1640 assert_eq!(entries.len(), 1);
1641 assert_eq!(entries[0].key, "repo.pattern");
1642 assert_eq!(entries[0].action, MemoryAction::Upsert);
1643 }
1644
1645 #[test]
1646 fn test_parse_memory_multiple_entries_returns_all() {
1647 let input = " - key: k1\n topic: t1\n action: get\n - key: k2\n topic: t2\n action: list\n";
1649 let (entries, errors) = parse_structured(input, parse_memory);
1650 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1651 assert_eq!(entries.len(), 2);
1652 }
1653
1654 #[test]
1655 fn test_parse_memory_missing_key_emits_p003_and_skips_entry() {
1656 let input = " - topic: t1\n action: get\n";
1658 let (entries, errors) = parse_structured(input, parse_memory);
1659 assert!(
1660 errors.iter().any(|e| e.code == ErrorCode::P003),
1661 "expected P003"
1662 );
1663 assert_eq!(entries.len(), 0);
1664 }
1665
1666 #[test]
1667 fn test_parse_memory_invalid_action_emits_p003_and_skips_entry() {
1668 let input = " - key: k1\n topic: t1\n action: invalid_action\n";
1670 let (entries, errors) = parse_structured(input, parse_memory);
1671 assert!(
1672 errors.iter().any(|e| e.code == ErrorCode::P003),
1673 "expected P003"
1674 );
1675 assert_eq!(entries.len(), 0);
1676 }
1677
1678 #[test]
1679 fn test_parse_memory_with_scope_and_ttl() {
1680 let input = " - key: k1\n topic: t1\n action: upsert\n scope: project\n ttl: permanent\n";
1682 let (entries, errors) = parse_structured(input, parse_memory);
1683 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1684 assert_eq!(entries[0].scope, Some(MemoryScope::Project));
1685 assert_eq!(entries[0].ttl, Some(MemoryTtl::Permanent));
1686 }
1687
1688 #[test]
1694 fn test_parse_code_blocks_dash_with_inline_action_kv() {
1695 let input = " - action: create\n body: |\n fn a() {}\n";
1699 let (blocks, errors) = parse_structured(input, parse_code_blocks);
1700 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1701 assert_eq!(blocks.len(), 1);
1702 assert_eq!(blocks[0].action, CodeAction::Create);
1703 }
1704
1705 #[test]
1707 fn test_parse_code_block_empty_input_emits_two_v008_errors() {
1708 let input = "";
1709 let (cb, errors) = parse_structured(input, parse_code_block);
1710 let v008_count = errors.iter().filter(|e| e.code == ErrorCode::V008).count();
1711 assert_eq!(
1712 v008_count, 2,
1713 "expected 2 V008 errors (missing action + body), got {v008_count}"
1714 );
1715 assert_eq!(cb.action, CodeAction::Full);
1716 assert!(cb.body.is_empty());
1717 }
1718
1719 #[test]
1721 fn test_parse_code_blocks_no_content_returns_empty_vec() {
1722 let input = "";
1723 let (blocks, errors) = parse_structured(input, parse_code_blocks);
1724 assert!(errors.is_empty());
1725 assert!(blocks.is_empty());
1726 }
1727
1728 #[test]
1730 fn test_parse_verify_no_content_returns_empty_vec() {
1731 let input = "";
1732 let (checks, errors) = parse_structured(input, parse_verify);
1733 assert!(errors.is_empty());
1734 assert!(checks.is_empty());
1735 }
1736
1737 #[test]
1739 fn test_parse_verify_command_missing_run_emits_v009() {
1740 let input = " - type: command\n";
1741 let (checks, errors) = parse_structured(input, parse_verify);
1742 assert!(errors.iter().any(|e| e.code == ErrorCode::V009));
1743 assert!(checks.is_empty());
1744 }
1745
1746 #[test]
1748 fn test_parse_verify_file_exists_missing_file_emits_v009() {
1749 let input = " - type: file_exists\n";
1750 let (checks, errors) = parse_structured(input, parse_verify);
1751 assert!(errors.iter().any(|e| e.code == ErrorCode::V009));
1752 assert!(checks.is_empty());
1753 }
1754
1755 #[test]
1757 fn test_parse_verify_file_contains_missing_file_emits_v009() {
1758 let input = " - type: file_contains\n pattern: foo\n";
1759 let (checks, errors) = parse_structured(input, parse_verify);
1760 assert!(errors.iter().any(|e| e.code == ErrorCode::V009));
1761 assert!(checks.is_empty());
1762 }
1763
1764 #[test]
1765 fn test_parse_verify_file_contains_missing_pattern_emits_v009() {
1766 let input = " - type: file_contains\n file: src/lib.rs\n";
1767 let (checks, errors) = parse_structured(input, parse_verify);
1768 assert!(errors.iter().any(|e| e.code == ErrorCode::V009));
1769 assert!(checks.is_empty());
1770 }
1771
1772 #[test]
1774 fn test_parse_verify_file_not_contains_missing_file_emits_v009() {
1775 let input = " - type: file_not_contains\n pattern: unsafe\n";
1776 let (_checks, errors) = parse_structured(input, parse_verify);
1777 assert!(errors.iter().any(|e| e.code == ErrorCode::V009));
1778 }
1779
1780 #[test]
1781 fn test_parse_verify_file_not_contains_missing_pattern_emits_v009() {
1782 let input = " - type: file_not_contains\n file: src/lib.rs\n";
1783 let (_checks, errors) = parse_structured(input, parse_verify);
1784 assert!(errors.iter().any(|e| e.code == ErrorCode::V009));
1785 }
1786
1787 #[test]
1789 fn test_parse_verify_node_status_missing_node_emits_v009() {
1790 let input = " - type: node_status\n status: completed\n";
1791 let (_checks, errors) = parse_structured(input, parse_verify);
1792 assert!(errors.iter().any(|e| e.code == ErrorCode::V009));
1793 }
1794
1795 #[test]
1796 fn test_parse_verify_node_status_missing_status_emits_v009() {
1797 let input = " - type: node_status\n node: auth.login\n";
1798 let (_checks, errors) = parse_structured(input, parse_verify);
1799 assert!(errors.iter().any(|e| e.code == ErrorCode::V009));
1800 }
1801
1802 #[test]
1804 fn test_parse_verify_unknown_type_emits_p003() {
1805 let input = " - type: magic\n foo: bar\n";
1806 let (checks, errors) = parse_structured(input, parse_verify);
1807 assert!(errors.iter().any(|e| e.code == ErrorCode::P003));
1808 assert!(checks.is_empty());
1809 }
1810
1811 #[test]
1813 fn test_parse_agent_context_load_files_block_list_all_range_variants() {
1814 let input = " load_files:\n - path: src/main.rs\n range: full\n - path: src/util.rs\n range: 1-50\n - path: src/other.rs\n range: function: do_work\n";
1815 let (ctx, errors) = parse_structured(input, parse_agent_context);
1816 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1817 let files = ctx.load_files.as_deref().unwrap();
1818 assert_eq!(files.len(), 3);
1819 assert_eq!(files[0].range, FileRange::Full);
1820 assert_eq!(files[1].range, FileRange::Lines(1, 50));
1821 assert_eq!(files[2].range, FileRange::Function("do_work".to_owned()));
1822 }
1823
1824 #[test]
1826 fn test_parse_agent_context_load_files_missing_path_emits_p003() {
1827 let input = " load_files:\n - range: full\n";
1828 let (_ctx, errors) = parse_structured(input, parse_agent_context);
1829 assert!(errors.iter().any(|e| e.code == ErrorCode::P003));
1830 }
1831
1832 #[test]
1834 fn test_parse_agent_context_load_nodes_block_list() {
1835 let input = " load_nodes:\n - auth.login\n - auth.session\n";
1836 let (ctx, errors) = parse_structured(input, parse_agent_context);
1837 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1838 let nodes = ctx.load_nodes.as_deref().unwrap();
1839 assert_eq!(nodes, &["auth.login", "auth.session"]);
1840 }
1841
1842 #[test]
1844 fn test_parse_agent_context_load_memory_block_list() {
1845 let input = " load_memory:\n - topic.one\n - topic.two\n";
1846 let (ctx, errors) = parse_structured(input, parse_agent_context);
1847 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1848 let mem = ctx.load_memory.as_deref().unwrap();
1849 assert_eq!(mem, &["topic.one", "topic.two"]);
1850 }
1851
1852 #[test]
1854 fn test_parse_agent_context_unknown_field_skipped_without_error() {
1855 let input = " system_hint: hello\n unknown_extension:\n - some\n - thing\n max_tokens: 100\n";
1856 let (ctx, errors) = parse_structured(input, parse_agent_context);
1857 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1858 assert_eq!(ctx.system_hint.as_deref(), Some("hello"));
1859 assert_eq!(ctx.max_tokens, Some(100));
1860 }
1861
1862 #[test]
1864 fn test_parse_agent_context_empty_input_returns_all_none() {
1865 let input = "";
1866 let (ctx, errors) = parse_structured(input, parse_agent_context);
1867 assert!(errors.is_empty());
1868 assert!(ctx.system_hint.is_none());
1869 assert!(ctx.max_tokens.is_none());
1870 assert!(ctx.load_nodes.is_none());
1871 assert!(ctx.load_files.is_none());
1872 assert!(ctx.load_memory.is_none());
1873 }
1874
1875 #[test]
1877 fn test_parse_parallel_groups_invalid_strategy_emits_p003() {
1878 let input = " - group: g1\n nodes: [n.a]\n strategy: bogus\n";
1879 let (groups, errors) = parse_structured(input, parse_parallel_groups);
1880 assert!(errors.iter().any(|e| e.code == ErrorCode::P003));
1881 assert_eq!(groups.len(), 1);
1883 assert_eq!(groups[0].strategy, Strategy::Sequential);
1884 }
1885
1886 #[test]
1888 fn test_parse_parallel_groups_empty_input_returns_empty() {
1889 let input = "";
1890 let (groups, errors) = parse_structured(input, parse_parallel_groups);
1891 assert!(errors.is_empty());
1892 assert!(groups.is_empty());
1893 }
1894
1895 #[test]
1897 fn test_parse_load_profiles_empty_input_returns_empty() {
1898 let input = "";
1899 let (profiles, errors) = parse_structured(input, parse_load_profiles);
1900 assert!(errors.is_empty());
1901 assert!(profiles.is_empty());
1902 }
1903
1904 #[test]
1906 fn test_parse_memory_empty_input_returns_empty() {
1907 let input = "";
1908 let (entries, errors) = parse_structured(input, parse_memory);
1909 assert!(errors.is_empty());
1910 assert!(entries.is_empty());
1911 }
1912
1913 #[test]
1915 fn test_parse_memory_missing_topic_emits_p003_and_skips() {
1916 let input = " - key: k1\n action: get\n";
1917 let (entries, errors) = parse_structured(input, parse_memory);
1918 assert!(errors.iter().any(|e| e.code == ErrorCode::P003));
1919 assert!(entries.is_empty());
1920 }
1921
1922 #[test]
1924 fn test_parse_memory_with_query_max_results_and_value() {
1925 let input = " - key: k1\n topic: t1\n action: search\n value: some value\n query: pattern\n max_results: 5\n";
1926 let (entries, errors) = parse_structured(input, parse_memory);
1927 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1928 assert_eq!(entries[0].value.as_deref(), Some("some value"));
1929 assert_eq!(entries[0].query.as_deref(), Some("pattern"));
1930 assert_eq!(entries[0].max_results, Some(5));
1931 }
1932
1933 #[test]
1935 fn test_parse_memory_session_scope_and_duration_ttl() {
1936 let input = " - key: sess.k\n topic: t\n action: upsert\n scope: session\n ttl: duration:P1D\n";
1937 let (entries, errors) = parse_structured(input, parse_memory);
1938 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1939 assert_eq!(entries[0].scope, Some(MemoryScope::Session));
1940 assert_eq!(entries[0].ttl, Some(MemoryTtl::Duration("P1D".to_owned())));
1941 }
1942
1943 #[test]
1945 fn test_parse_file_range_helper_all_branches() {
1946 let input = " load_files:\n - path: a.rs\n range: full\n - path: b.rs\n range: 10-20\n - path: c.rs\n range: function: work\n - path: d.rs\n range: abc\n";
1949 let (ctx, errors) = parse_structured(input, parse_agent_context);
1950 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1951 let files = ctx.load_files.as_deref().unwrap();
1952 assert_eq!(files[0].range, FileRange::Full);
1953 assert_eq!(files[1].range, FileRange::Lines(10, 20));
1954 assert_eq!(files[2].range, FileRange::Function("work".to_owned()));
1955 assert_eq!(files[3].range, FileRange::Full);
1957 }
1958
1959 #[test]
1961 fn test_parse_code_block_pipe_body_preserves_internal_blank_line() {
1962 let input = " action: create\n body: |\n line one\n\n line three\n";
1965 let (cb, errors) = parse_structured(input, parse_code_block);
1966 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1967 assert_eq!(cb.body, "line one\n\nline three");
1968 }
1969}