Skip to main content

agm_core/parser/
structured.rs

1//! Structured field parsers: converts typed sub-fields into model types.
2//!
3//! All parsers follow the same convention:
4//!   - Accept `&[Line]`, `&mut usize` (pos), `&mut Vec<AgmError>`
5//!   - On entry, `*pos` points to the first line AFTER the `FieldStart` line
6//!   - Field content ends when a line's indent < base_indent (non-blank),
7//!     or a `NodeDeclaration` is encountered, or EOF
8
9use 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
21// ---------------------------------------------------------------------------
22// Internal SubFieldValue enum
23// ---------------------------------------------------------------------------
24
25/// Internal representation of a sub-field value before it is converted to
26/// a typed model value.
27enum SubFieldValue {
28    Scalar(String),
29    List(Vec<String>),
30    PipeBody(String),
31}
32
33// ---------------------------------------------------------------------------
34// Indentation helpers
35// ---------------------------------------------------------------------------
36
37/// Scans forward from `pos` (skipping blanks) and returns the indent of the
38/// first non-blank line that is not a `NodeDeclaration`.
39///
40/// Returns `None` if no such line exists.
41fn 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
55/// Returns `true` if the line at `pos` is still within the structured field
56/// body (i.e., its indent is >= `base_indent`, or it is blank).
57fn 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
68/// Advances `*pos` past any blank lines.
69fn skip_blanks(lines: &[Line], pos: &mut usize) {
70    while *pos < lines.len() && matches!(&lines[*pos].kind, LineKind::Blank) {
71        *pos += 1;
72    }
73}
74
75/// Parses a key: value pair from a raw string (e.g. `"  action: create"`).
76/// Returns `(key, value)` or `None` if the string is not a valid kv pair.
77fn 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
88// ---------------------------------------------------------------------------
89// collect_pipe_body
90// ---------------------------------------------------------------------------
91
92/// Collects body text after a `BodyMarker` line (`body: |`).
93///
94/// Strips the base indent from each line, preserving relative indentation.
95/// Trailing blank lines are removed.
96fn collect_pipe_body(lines: &[Line], pos: &mut usize) -> String {
97    // Detect body indent from the first non-blank line.
98    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                // Peek ahead: keep blank if more body follows.
110                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    // Trim trailing blank entries.
142    while parts.last().is_some_and(|s: &String| s.is_empty()) {
143        parts.pop();
144    }
145    parts.join("\n")
146}
147
148// ---------------------------------------------------------------------------
149// collect_sub_fields
150// ---------------------------------------------------------------------------
151
152/// Reads the sub-fields of a single structured object entry.
153///
154/// On entry, `*pos` should point to the first line of the sub-field block
155/// (i.e., the line immediately after a list-item intro or after a
156/// `FieldStart` for a single-object field). `base_indent` is the expected
157/// minimum indent for lines inside this object.
158///
159/// Returns the collected sub-fields as `Vec<(key, SubFieldValue)>`.
160fn 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                // Peek: stay in block only if indented content follows.
171                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                // body: | — collect pipe body.
197                *pos += 1; // advance past BodyMarker
198                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; // advance past FieldStart
205
206                // Determine what follows: list of objects, list of scalars, or block.
207                skip_blanks(lines, pos);
208
209                if *pos >= lines.len() {
210                    // Empty field.
211                    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                        // Collect list items.
220                        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                        // Nested scalar block or nested objects.
250                        // Read lines as a block string.
251                        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                        // Nothing follows at the right indent.
295                        fields.push((key, SubFieldValue::Scalar(String::new())));
296                    }
297                }
298            }
299            LineKind::ListItem(_) | LineKind::IndentedLine(_) => {
300                // Unexpected at this level — stop.
301                break;
302            }
303            _ => {
304                // Anything else stops the sub-field collection.
305                break;
306            }
307        }
308    }
309
310    fields
311}
312
313// ---------------------------------------------------------------------------
314// Helper: extract scalar from sub-field map
315// ---------------------------------------------------------------------------
316
317fn 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
342// ---------------------------------------------------------------------------
343// build_code_block_from_fields
344// ---------------------------------------------------------------------------
345
346fn 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 // fallback
359    } 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
405// ---------------------------------------------------------------------------
406// Phase 1: parse_code_block
407// ---------------------------------------------------------------------------
408
409/// Parses a single `code:` block into a `CodeBlock`.
410///
411/// On entry, `*pos` points to the first line after the `FieldStart("code")`.
412pub(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
451// ---------------------------------------------------------------------------
452// Phase 2: parse_code_blocks
453// ---------------------------------------------------------------------------
454
455/// Parses a `code_blocks:` list into a `Vec<CodeBlock>`.
456///
457/// On entry, `*pos` points to the first line after the `FieldStart("code_blocks")`.
458pub(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                // The list item text may contain inline kv pairs like "action: create"
483                // but in practice list-item objects have sub-fields on subsequent lines.
484                let mut fields: Vec<(String, SubFieldValue)> = Vec::new();
485
486                // Parse inline kv if any on the dash line.
487                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                // Determine indent of sub-fields (they should be indented more than the dash).
494                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
511// ---------------------------------------------------------------------------
512// Phase 2: parse_verify
513// ---------------------------------------------------------------------------
514
515/// Parses a `verify:` list into a `Vec<VerifyCheck>`.
516///
517/// On entry, `*pos` points to the first line after the `FieldStart("verify")`.
518pub(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                // Parse inline kv on the dash line.
545                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                // Sub-fields on subsequent lines.
552                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
707// ---------------------------------------------------------------------------
708// Phase 3: parse_file_range helper
709// ---------------------------------------------------------------------------
710
711/// Parses a file range string such as `"full"`, `"1-50"`, or `"function:name"`.
712fn 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    // Try "start-end" numeric range.
720    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    // Fallback.
731    FileRange::Full
732}
733
734// ---------------------------------------------------------------------------
735// Phase 3: parse_load_files_list helper
736// ---------------------------------------------------------------------------
737
738/// Parses a list of load_files entries from collected sub-fields.
739fn 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
800// ---------------------------------------------------------------------------
801// Phase 3: parse_agent_context
802// ---------------------------------------------------------------------------
803
804/// Parses an `agent_context:` block into an `AgentContext`.
805///
806/// On entry, `*pos` points to the first line after the `FieldStart("agent_context")`.
807pub(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                        // Indented list of node IDs.
870                        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                        // Unknown field — skip its body.
924                        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
954// ---------------------------------------------------------------------------
955// Phase 3: parse_parallel_groups
956// ---------------------------------------------------------------------------
957
958/// Parses a `parallel_groups:` list into a `Vec<ParallelGroup>`.
959///
960/// On entry, `*pos` points to the first line after the `FieldStart("parallel_groups")`.
961pub(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
1047// ---------------------------------------------------------------------------
1048// Phase 4: parse_load_profiles
1049// ---------------------------------------------------------------------------
1050
1051/// Parses a `load_profiles:` block into a `BTreeMap<String, LoadProfile>`.
1052///
1053/// On entry, `*pos` points to the first line after the `FieldStart("load_profiles")`.
1054pub(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        // Each profile starts with a `FieldStart(name)` at base_indent.
1073        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
1115// ---------------------------------------------------------------------------
1116// Phase 4: parse_memory
1117// ---------------------------------------------------------------------------
1118
1119/// Parses a `memory:` list into a `Vec<MemoryEntry>`.
1120///
1121/// On entry, `*pos` points to the first line after the `FieldStart("memory")`.
1122pub(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// ---------------------------------------------------------------------------
1234// Tests
1235// ---------------------------------------------------------------------------
1236
1237#[cfg(test)]
1238mod tests {
1239    use super::*;
1240    use crate::parser::lexer::lex;
1241
1242    /// Test utility: lex input and call the given parser starting at pos=0.
1243    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    // -----------------------------------------------------------------------
1255    // A: parse_code_block tests (A1-A7)
1256    // -----------------------------------------------------------------------
1257
1258    #[test]
1259    fn test_parse_code_block_minimal_action_and_body_returns_code_block() {
1260        // A1
1261        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        // A2
1273        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        // A3
1286        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        // A4
1299        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        // A5
1310        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        // A6 - body: some text (not pipe)
1322        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        // A7 - `old:` is a scalar field (only `body: |` triggers BodyMarker)
1331        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    // -----------------------------------------------------------------------
1339    // B: parse_code_blocks tests (B8-B12)
1340    // -----------------------------------------------------------------------
1341
1342    #[test]
1343    fn test_parse_code_blocks_single_item_returns_one_block() {
1344        // B8
1345        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        // B9
1355        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        // B10
1366        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        // B11
1375        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        // B12
1386        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    // -----------------------------------------------------------------------
1392    // C: parse_verify tests (C13-C20)
1393    // -----------------------------------------------------------------------
1394
1395    #[test]
1396    fn test_parse_verify_command_inline_returns_command_check() {
1397        // C13
1398        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        // C14
1408        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        // C15
1422        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        // C16
1431        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        // C17
1440        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        // C18
1449        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        // C19
1463        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        // C20
1475        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    // -----------------------------------------------------------------------
1482    // D: parse_agent_context tests (D21-D25)
1483    // -----------------------------------------------------------------------
1484
1485    #[test]
1486    fn test_parse_agent_context_system_hint_only() {
1487        // D21
1488        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        // D22
1497        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        // D23
1507        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        // D24
1516        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        // D25
1527        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    // -----------------------------------------------------------------------
1537    // E: parse_parallel_groups tests (E26-E29)
1538    // -----------------------------------------------------------------------
1539
1540    #[test]
1541    fn test_parse_parallel_groups_single_group_returns_one_group() {
1542        // E26
1543        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        // E27
1555        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        // E28
1564        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        // E29
1575        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    // -----------------------------------------------------------------------
1583    // F: parse_load_profiles tests (F30-F33)
1584    // -----------------------------------------------------------------------
1585
1586    #[test]
1587    fn test_parse_load_profiles_single_profile_returns_one_entry() {
1588        // F30
1589        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        // F31
1599        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        // F32
1610        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        // F33
1622        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    // -----------------------------------------------------------------------
1631    // G: parse_memory tests (G34-G38)
1632    // -----------------------------------------------------------------------
1633
1634    #[test]
1635    fn test_parse_memory_upsert_entry_returns_memory_entry() {
1636        // G34
1637        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        // G35
1648        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        // G36
1657        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        // G37
1669        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        // G38
1681        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    // -----------------------------------------------------------------------
1689    // H: edge case / error-path coverage
1690    // -----------------------------------------------------------------------
1691
1692    // --- H1. parse_kv_from_text via parse_code_blocks inline-kv path ------
1693    #[test]
1694    fn test_parse_code_blocks_dash_with_inline_action_kv() {
1695        // List items whose dash line carries the first kv pair, body on
1696        // continuation lines. Covers the `parse_kv_from_text` + inline-kv
1697        // merge path inside `parse_code_blocks`.
1698        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    // --- H2. parse_code_block on empty input (detect_base_indent → None) --
1706    #[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    // --- H3. parse_code_blocks on empty input (detect_base_indent → None) -
1720    #[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    // --- H4. parse_verify on empty input (detect_base_indent → None) ------
1729    #[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    // --- H5. parse_verify command missing `run` emits V009 ----------------
1738    #[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    // --- H6. parse_verify file_exists missing `file` emits V009 -----------
1747    #[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    // --- H7. parse_verify file_contains missing `file` or `pattern` -------
1756    #[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    // --- H8. parse_verify file_not_contains missing fields ---------------
1773    #[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    // --- H9. parse_verify node_status missing fields ---------------------
1788    #[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    // --- H10. parse_verify unknown type emits P003 -----------------------
1803    #[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    // --- H11. parse_agent_context load_files (block list) ----------------
1812    #[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    // --- H12. parse_agent_context load_files missing path emits P003 -----
1825    #[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    // --- H13. parse_agent_context load_nodes as block list ---------------
1833    #[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    // --- H14. parse_agent_context load_memory as block list --------------
1843    #[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    // --- H15. parse_agent_context unknown indented field is skipped -----
1853    #[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    // --- H16. parse_agent_context empty input returns all-None ----------
1863    #[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    // --- H17. parse_parallel_groups invalid strategy emits P003 ---------
1876    #[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        // Group still builds with fallback strategy.
1882        assert_eq!(groups.len(), 1);
1883        assert_eq!(groups[0].strategy, Strategy::Sequential);
1884    }
1885
1886    // --- H18. parse_parallel_groups empty input returns empty -----------
1887    #[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    // --- H19. parse_load_profiles empty input returns empty -------------
1896    #[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    // --- H20. parse_memory empty input returns empty --------------------
1905    #[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    // --- H21. parse_memory missing topic emits P003 --------------------
1914    #[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    // --- H22. parse_memory entry with query + max_results + value -------
1923    #[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    // --- H23. parse_memory with ttl duration and session scope ----------
1934    #[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    // --- H24. parse_file_range all branches (via parse_load_files_list) -
1944    #[test]
1945    fn test_parse_file_range_helper_all_branches() {
1946        // Exercise the parse_file_range helper end-to-end through load_files:
1947        // "full", numeric range "10-20", "function: name", invalid "abc".
1948        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        // Invalid falls back to Full.
1956        assert_eq!(files[3].range, FileRange::Full);
1957    }
1958
1959    // --- H25. parse_code_block with pipe body that has blank line -------
1960    #[test]
1961    fn test_parse_code_block_pipe_body_preserves_internal_blank_line() {
1962        // A blank line in the middle of a pipe body should be preserved if
1963        // more body follows (collect_pipe_body lookahead path).
1964        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}