Skip to main content

agm_core/parser/
node.rs

1//! Node parser: reads a single AGM node declaration and its fields.
2
3use std::sync::LazyLock;
4
5use regex::Regex;
6
7use crate::error::{AgmError, ErrorCode, ErrorLocation};
8use crate::model::execution::ExecutionStatus;
9use crate::model::fields::{
10    Confidence, FieldValue, NodeStatus, Priority, SddPhase, Span, Stability, TicketAction,
11};
12use crate::model::node::Node;
13
14use super::fields::{
15    FieldTracker, collect_structured_raw, is_structured_field, parse_block, parse_indented_list,
16    skip_field_body,
17};
18use super::lexer::{Line, LineKind};
19use super::structured::{
20    parse_agent_context, parse_code_block, parse_code_blocks, parse_memory, parse_parallel_groups,
21    parse_verify,
22};
23
24// ---------------------------------------------------------------------------
25// Node ID validation
26// ---------------------------------------------------------------------------
27
28static NODE_ID_RE: LazyLock<Regex> =
29    LazyLock::new(|| Regex::new(r"^[a-z][a-z0-9_]*([.\-][a-z][a-z0-9_]*)*$").unwrap());
30
31// ---------------------------------------------------------------------------
32// default_node
33// ---------------------------------------------------------------------------
34
35/// Creates a `Node` with all fields at their defaults.
36///
37/// `node_type` is set to `NodeType::Facts` as a placeholder;
38/// `summary` is set to an empty string. The validator will catch missing fields.
39fn default_node(id: String, start_line: usize) -> Node {
40    Node {
41        id,
42        span: Span::new(start_line, start_line),
43        ..Default::default()
44    }
45}
46
47// ---------------------------------------------------------------------------
48// parse_node
49// ---------------------------------------------------------------------------
50
51/// Parses a single node starting at the `NodeDeclaration` line at `*pos`.
52///
53/// Advances `pos` past all lines belonging to this node.
54pub fn parse_node(lines: &[Line], pos: &mut usize, errors: &mut Vec<AgmError>) -> Node {
55    // Extract ID from NodeDeclaration.
56    let (id, declaration_line) = match &lines[*pos].kind {
57        LineKind::NodeDeclaration(id) => (id.clone(), lines[*pos].number),
58        _ => {
59            // Should never happen — caller guarantees this.
60            return default_node(String::new(), lines[*pos].number);
61        }
62    };
63
64    // Validate node ID.
65    if id.is_empty() {
66        errors.push(AgmError::new(
67            ErrorCode::P002,
68            "Node declaration missing ID",
69            ErrorLocation::new(None, Some(declaration_line), None),
70        ));
71    } else if !NODE_ID_RE.is_match(&id) {
72        errors.push(AgmError::new(
73            ErrorCode::P002,
74            format!("Invalid node ID: {id:?} (must match [a-z][a-z0-9]*([.\\-][a-z][a-z0-9]*)*)"),
75            ErrorLocation::new(None, Some(declaration_line), None),
76        ));
77    }
78
79    let mut node = default_node(id, declaration_line);
80    *pos += 1; // advance past NodeDeclaration
81
82    let mut tracker = FieldTracker::new();
83    let mut last_line = declaration_line;
84
85    while *pos < lines.len() {
86        match &lines[*pos].kind.clone() {
87            LineKind::NodeDeclaration(_) => break,
88
89            LineKind::Blank | LineKind::Comment | LineKind::TestExpectHeader(_) => {
90                last_line = lines[*pos].number;
91                *pos += 1;
92            }
93
94            LineKind::ScalarField(key, value) => {
95                let key = key.clone();
96                let value = value.clone();
97                let line_number = lines[*pos].number;
98                last_line = line_number;
99
100                if tracker.track(&key) {
101                    errors.push(AgmError::new(
102                        ErrorCode::P006,
103                        format!("Duplicate field `{key}` in node `{}`", node.id),
104                        ErrorLocation::new(None, Some(line_number), Some(node.id.clone())),
105                    ));
106                    *pos += 1;
107                    continue;
108                }
109
110                assign_scalar_field(&mut node, &key, &value, line_number, errors);
111                *pos += 1;
112            }
113
114            LineKind::InlineListField(key, items) => {
115                let key = key.clone();
116                let items = items.clone();
117                let line_number = lines[*pos].number;
118                last_line = line_number;
119
120                if tracker.track(&key) {
121                    errors.push(AgmError::new(
122                        ErrorCode::P006,
123                        format!("Duplicate field `{key}` in node `{}`", node.id),
124                        ErrorLocation::new(None, Some(line_number), Some(node.id.clone())),
125                    ));
126                    *pos += 1;
127                    continue;
128                }
129
130                assign_list_field(&mut node, &key, items);
131                *pos += 1;
132            }
133
134            LineKind::FieldStart(key) => {
135                let key = key.clone();
136                let line_number = lines[*pos].number;
137                last_line = line_number;
138
139                if tracker.track(&key) {
140                    errors.push(AgmError::new(
141                        ErrorCode::P006,
142                        format!("Duplicate field `{key}` in node `{}`", node.id),
143                        ErrorLocation::new(None, Some(line_number), Some(node.id.clone())),
144                    ));
145                    *pos += 1;
146                    skip_field_body(lines, pos);
147                    continue;
148                }
149
150                *pos += 1; // advance past FieldStart
151
152                if is_structured_field(&key) {
153                    match key.as_str() {
154                        "code" => {
155                            node.code = Some(parse_code_block(lines, pos, errors));
156                        }
157                        "code_blocks" => {
158                            node.code_blocks = Some(parse_code_blocks(lines, pos, errors));
159                        }
160                        "verify" => {
161                            node.verify = Some(parse_verify(lines, pos, errors));
162                        }
163                        "agent_context" => {
164                            node.agent_context = Some(parse_agent_context(lines, pos, errors));
165                        }
166                        "parallel_groups" => {
167                            node.parallel_groups = Some(parse_parallel_groups(lines, pos, errors));
168                        }
169                        "memory" => {
170                            node.memory = Some(parse_memory(lines, pos, errors));
171                        }
172                        _ => {
173                            // Unknown structured field — collect raw for forward compatibility.
174                            let raw = collect_structured_raw(lines, pos);
175                            node.extra_fields.insert(key, FieldValue::Block(raw));
176                        }
177                    }
178                } else {
179                    // Peek: list or block? Skip over comment lines before deciding.
180                    let peek_pos = {
181                        let mut p = *pos;
182                        while p < lines.len()
183                            && matches!(
184                                &lines[p].kind,
185                                LineKind::Comment | LineKind::TestExpectHeader(_)
186                            )
187                        {
188                            p += 1;
189                        }
190                        p
191                    };
192                    let is_list = peek_pos < lines.len()
193                        && matches!(&lines[peek_pos].kind, LineKind::ListItem(_));
194
195                    if is_list {
196                        let items = parse_indented_list(lines, pos);
197                        assign_list_field(&mut node, &key, items);
198                    } else {
199                        let text = parse_block(lines, pos);
200                        assign_block_field(&mut node, &key, text);
201                    }
202                }
203            }
204
205            LineKind::BodyMarker => {
206                let line_number = lines[*pos].number;
207                last_line = line_number;
208
209                let is_dup = tracker.track("body");
210                *pos += 1; // advance past BodyMarker
211
212                let text = parse_block(lines, pos);
213
214                if is_dup {
215                    // Already have body — stash in extra_fields.
216                    node.extra_fields
217                        .insert("body".to_owned(), FieldValue::Block(text));
218                } else {
219                    // Assign to detail (canonical body destination).
220                    if node.detail.is_none() {
221                        node.detail = Some(text);
222                    } else {
223                        node.extra_fields
224                            .insert("body".to_owned(), FieldValue::Block(text));
225                    }
226                }
227            }
228
229            LineKind::ListItem(_) | LineKind::IndentedLine(_) => {
230                errors.push(AgmError::new(
231                    ErrorCode::P003,
232                    format!(
233                        "Unexpected indentation at line {} in node `{}`",
234                        lines[*pos].number, node.id
235                    ),
236                    ErrorLocation::new(None, Some(lines[*pos].number), Some(node.id.clone())),
237                ));
238                last_line = lines[*pos].number;
239                *pos += 1;
240            }
241        }
242    }
243
244    node.span.end_line = last_line;
245    node
246}
247
248// ---------------------------------------------------------------------------
249// assign_scalar_field
250// ---------------------------------------------------------------------------
251
252fn assign_scalar_field(
253    node: &mut Node,
254    key: &str,
255    value: &str,
256    line_number: usize,
257    errors: &mut Vec<AgmError>,
258) {
259    match key {
260        "type" => {
261            node.node_type = value.parse().unwrap(); // infallible — returns Custom
262        }
263        "summary" => {
264            node.summary = value.to_owned();
265        }
266        "priority" => match value.parse::<Priority>() {
267            Ok(p) => node.priority = Some(p),
268            Err(_) => {
269                errors.push(AgmError::new(
270                    ErrorCode::P003,
271                    format!("Invalid `priority` value: {value:?}"),
272                    ErrorLocation::new(None, Some(line_number), Some(node.id.clone())),
273                ));
274            }
275        },
276        "stability" => match value.parse::<Stability>() {
277            Ok(s) => node.stability = Some(s),
278            Err(_) => {
279                errors.push(AgmError::new(
280                    ErrorCode::P003,
281                    format!("Invalid `stability` value: {value:?}"),
282                    ErrorLocation::new(None, Some(line_number), Some(node.id.clone())),
283                ));
284            }
285        },
286        "confidence" => match value.parse::<Confidence>() {
287            Ok(c) => node.confidence = Some(c),
288            Err(_) => {
289                errors.push(AgmError::new(
290                    ErrorCode::P003,
291                    format!("Invalid `confidence` value: {value:?}"),
292                    ErrorLocation::new(None, Some(line_number), Some(node.id.clone())),
293                ));
294            }
295        },
296        "status" => match value.parse::<NodeStatus>() {
297            Ok(s) => node.status = Some(s),
298            Err(_) => {
299                errors.push(AgmError::new(
300                    ErrorCode::P003,
301                    format!("Invalid `status` value: {value:?}"),
302                    ErrorLocation::new(None, Some(line_number), Some(node.id.clone())),
303                ));
304            }
305        },
306        "detail" => node.detail = Some(value.to_owned()),
307        "examples" => node.examples = Some(value.to_owned()),
308        "notes" => node.notes = Some(value.to_owned()),
309        "target" => node.target = Some(value.to_owned()),
310        "applies_when" => node.applies_when = Some(value.to_owned()),
311        "valid_from" => node.valid_from = Some(value.to_owned()),
312        "valid_until" => node.valid_until = Some(value.to_owned()),
313        // Ticket fields (v1.2.0)
314        "title" => node.title = Some(value.to_owned()),
315        "description" => node.description = Some(value.to_owned()),
316        "action" => match value.parse::<TicketAction>() {
317            Ok(a) => node.action = Some(a),
318            Err(_) => errors.push(AgmError::new(
319                ErrorCode::V029,
320                format!("Invalid `action` value: {value:?}"),
321                ErrorLocation::new(None, Some(line_number), Some(node.id.clone())),
322            )),
323        },
324        "sdd_phase" => match value.parse::<SddPhase>() {
325            Ok(p) => node.sdd_phase = Some(p),
326            Err(_) => errors.push(AgmError::new(
327                ErrorCode::V030,
328                format!("Invalid `sdd_phase` value: {value:?}"),
329                ErrorLocation::new(None, Some(line_number), Some(node.id.clone())),
330            )),
331        },
332        "prompt" => node.prompt = Some(value.to_owned()),
333        "assignee" => node.assignee = Some(value.to_owned()),
334        "ticket_id" => node.ticket_id = Some(value.to_owned()),
335        "execution_status" => match value.parse::<ExecutionStatus>() {
336            Ok(es) => node.execution_status = Some(es),
337            Err(_) => {
338                errors.push(AgmError::new(
339                    ErrorCode::P003,
340                    format!("Invalid `execution_status` value: {value:?}"),
341                    ErrorLocation::new(None, Some(line_number), Some(node.id.clone())),
342                ));
343            }
344        },
345        "executed_by" => node.executed_by = Some(value.to_owned()),
346        "executed_at" => node.executed_at = Some(value.to_owned()),
347        "execution_log" => node.execution_log = Some(value.to_owned()),
348        "retry_count" => match value.parse::<u32>() {
349            Ok(n) => node.retry_count = Some(n),
350            Err(_) => {
351                errors.push(AgmError::new(
352                    ErrorCode::P003,
353                    format!("Invalid `retry_count` value: {value:?}"),
354                    ErrorLocation::new(None, Some(line_number), Some(node.id.clone())),
355                ));
356            }
357        },
358        _ => {
359            node.extra_fields
360                .insert(key.to_owned(), FieldValue::Scalar(value.to_owned()));
361        }
362    }
363}
364
365// ---------------------------------------------------------------------------
366// assign_list_field
367// ---------------------------------------------------------------------------
368
369fn assign_list_field(node: &mut Node, key: &str, items: Vec<String>) {
370    match key {
371        "depends" => node.depends = Some(items),
372        "related_to" => node.related_to = Some(items),
373        "replaces" => node.replaces = Some(items),
374        "conflicts" => node.conflicts = Some(items),
375        "see_also" => node.see_also = Some(items),
376        "items" => node.items = Some(items),
377        "steps" => node.steps = Some(items),
378        "fields" => node.fields = Some(items),
379        "input" => node.input = Some(items),
380        "output" => node.output = Some(items),
381        "rationale" => node.rationale = Some(items),
382        "tradeoffs" => node.tradeoffs = Some(items),
383        "resolution" => node.resolution = Some(items),
384        "scope" => node.scope = Some(items),
385        "tags" => node.tags = Some(items),
386        "aliases" => node.aliases = Some(items),
387        "keywords" => node.keywords = Some(items),
388        // Ticket fields (v1.2.0)
389        "labels" => node.labels = Some(items),
390        _ => {
391            node.extra_fields
392                .insert(key.to_owned(), FieldValue::List(items));
393        }
394    }
395}
396
397// ---------------------------------------------------------------------------
398// assign_block_field
399// ---------------------------------------------------------------------------
400
401fn assign_block_field(node: &mut Node, key: &str, text: String) {
402    match key {
403        "detail" => node.detail = Some(text),
404        "examples" => node.examples = Some(text),
405        "notes" => node.notes = Some(text),
406        "execution_log" => node.execution_log = Some(text),
407        "applies_when" => node.applies_when = Some(text),
408        // Ticket fields (v1.2.0)
409        "title" => node.title = Some(text),
410        "description" => node.description = Some(text),
411        "prompt" => node.prompt = Some(text),
412        _ => {
413            node.extra_fields
414                .insert(key.to_owned(), FieldValue::Block(text));
415        }
416    }
417}