Skip to main content

agm_core/parser/
node.rs

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