Skip to main content

agm_core/validator/
node.rs

1//! Per-node structural validation (spec S11, S12).
2//!
3//! Pass 2: validates node IDs, summary, dates, status constraints.
4
5use std::collections::{HashMap, HashSet};
6use std::sync::OnceLock;
7
8use regex::Regex;
9
10use crate::error::codes::ErrorCode;
11use crate::error::diagnostic::{AgmError, ErrorLocation, Severity};
12use crate::model::fields::NodeStatus;
13use crate::model::node::Node;
14
15/// Regex pattern for valid node IDs: dot-or-hyphen-separated lowercase segments.
16/// Segments may contain lowercase letters, digits, and underscores.
17/// First character of each segment must be a letter.
18static NODE_ID_PATTERN: OnceLock<Regex> = OnceLock::new();
19
20fn node_id_regex() -> &'static Regex {
21    NODE_ID_PATTERN.get_or_init(|| Regex::new(r"^[a-z][a-z0-9_]*([.\-][a-z][a-z0-9_]*)*$").unwrap())
22}
23
24/// Validates all node IDs for uniqueness (V003) and pattern compliance (V021).
25///
26/// Returns errors for duplicate IDs and invalid ID patterns.
27#[must_use]
28pub fn validate_node_ids(nodes: &[Node], file_name: &str) -> Vec<AgmError> {
29    let mut errors = Vec::new();
30    let mut seen: HashMap<&str, usize> = HashMap::new();
31    let pattern = node_id_regex();
32
33    for node in nodes {
34        let id = node.id.as_str();
35        let line = node.span.start_line;
36
37        // V003 — uniqueness
38        if let Some(&first_line) = seen.get(id) {
39            errors.push(AgmError::new(
40                ErrorCode::V003,
41                format!("Duplicate node ID: `{id}` (first seen at line {first_line})"),
42                ErrorLocation::full(file_name, line, id),
43            ));
44        } else {
45            seen.insert(id, line);
46        }
47
48        // V021 — ID pattern
49        if !pattern.is_match(id) {
50            errors.push(AgmError::new(
51                ErrorCode::V021,
52                format!("Node ID does not match required pattern: `{id}`"),
53                ErrorLocation::full(file_name, line, id),
54            ));
55        }
56    }
57
58    errors
59}
60
61/// Validates a single node's structural fields.
62///
63/// Rules: V002 (missing summary), V007 (valid_from > valid_until),
64/// V011 (empty summary warning), V012 (summary too long warning),
65/// V014 (deprecated/superseded without replaces).
66#[must_use]
67pub fn validate_node(node: &Node, all_ids: &HashSet<String>, file_name: &str) -> Vec<AgmError> {
68    let _ = all_ids; // used by callers building sets; kept in signature for consistency
69    let mut errors = Vec::new();
70    let line = node.span.start_line;
71    let id = node.id.as_str();
72
73    // V002 — summary required (missing entirely, not just empty)
74    // In the model, summary is String (never None). We check for empty here
75    // as defensive validation; parser may produce empty string.
76    if node.summary.is_empty() {
77        errors.push(AgmError::new(
78            ErrorCode::V002,
79            format!("Node `{id}` missing required field: `summary`"),
80            ErrorLocation::full(file_name, line, id),
81        ));
82        // If truly empty, skip the whitespace/length checks
83        return errors;
84    }
85
86    // V011 — summary is whitespace-only (warning)
87    if node.summary.trim().is_empty() {
88        errors.push(AgmError::with_severity(
89            ErrorCode::V011,
90            Severity::Warning,
91            format!("Summary is empty in node `{id}`"),
92            ErrorLocation::full(file_name, line, id),
93        ));
94    }
95
96    // V012 — summary exceeds 200 characters (warning, measured in chars)
97    if node.summary.chars().count() > 200 {
98        errors.push(AgmError::with_severity(
99            ErrorCode::V012,
100            Severity::Warning,
101            format!("Summary exceeds 200 characters in node `{id}`"),
102            ErrorLocation::full(file_name, line, id),
103        ));
104    }
105
106    // V007 — valid_from must not be after valid_until
107    if let (Some(from), Some(until)) = (&node.valid_from, &node.valid_until) {
108        // Compare as strings: ISO 8601 dates and datetimes sort lexicographically
109        // when zero-padded (which is the standard). This handles both date-only
110        // and datetime formats without pulling in a date library.
111        if from.as_str() > until.as_str() {
112            errors.push(AgmError::new(
113                ErrorCode::V007,
114                format!("`valid_from` is after `valid_until` in node `{id}`"),
115                ErrorLocation::full(file_name, line, id),
116            ));
117        }
118    }
119
120    // V014 — deprecated or superseded node missing `replaces` (warning)
121    let needs_replaces = matches!(
122        &node.status,
123        Some(NodeStatus::Deprecated) | Some(NodeStatus::Superseded)
124    );
125    if needs_replaces && node.replaces.is_none() {
126        errors.push(AgmError::with_severity(
127            ErrorCode::V014,
128            Severity::Warning,
129            format!("Deprecated node `{id}` missing `replaces` or `superseded_by`"),
130            ErrorLocation::full(file_name, line, id),
131        ));
132    }
133
134    errors
135}
136
137#[cfg(test)]
138mod tests {
139    use std::collections::BTreeMap;
140
141    use super::*;
142    use crate::model::fields::{NodeStatus, NodeType, Span};
143    use crate::model::node::Node;
144
145    fn minimal_node() -> Node {
146        Node {
147            id: "test.node".to_owned(),
148            node_type: NodeType::Facts,
149            summary: "a test node".to_owned(),
150            priority: None,
151            stability: None,
152            confidence: None,
153            status: None,
154            depends: None,
155            related_to: None,
156            replaces: None,
157            conflicts: None,
158            see_also: None,
159            items: None,
160            steps: None,
161            fields: None,
162            input: None,
163            output: None,
164            detail: None,
165            rationale: None,
166            tradeoffs: None,
167            resolution: None,
168            examples: None,
169            notes: None,
170            code: None,
171            code_blocks: None,
172            verify: None,
173            agent_context: None,
174            target: None,
175            execution_status: None,
176            executed_by: None,
177            executed_at: None,
178            execution_log: None,
179            retry_count: None,
180            parallel_groups: None,
181            memory: None,
182            scope: None,
183            applies_when: None,
184            valid_from: None,
185            valid_until: None,
186            tags: None,
187            aliases: None,
188            keywords: None,
189            extra_fields: BTreeMap::new(),
190            span: Span::new(5, 7),
191        }
192    }
193
194    #[test]
195    fn test_validate_node_ids_duplicate_returns_v003() {
196        let mut n1 = minimal_node();
197        let mut n2 = minimal_node();
198        n1.id = "auth.login".to_owned();
199        n2.id = "auth.login".to_owned();
200        n2.span = Span::new(10, 12);
201        let errors = validate_node_ids(&[n1, n2], "test.agm");
202        assert!(errors.iter().any(|e| e.code == ErrorCode::V003));
203    }
204
205    #[test]
206    fn test_validate_node_id_valid_pattern_returns_empty() {
207        let mut node = minimal_node();
208        node.id = "auth.login".to_owned();
209        let errors = validate_node_ids(&[node], "test.agm");
210        assert!(errors.is_empty());
211    }
212
213    #[test]
214    fn test_validate_node_id_single_segment_valid() {
215        let mut node = minimal_node();
216        node.id = "auth".to_owned();
217        let errors = validate_node_ids(&[node], "test.agm");
218        assert!(errors.is_empty());
219    }
220
221    #[test]
222    fn test_validate_node_id_invalid_pattern_uppercase_returns_v021() {
223        let mut node = minimal_node();
224        node.id = "Auth.Login".to_owned();
225        let errors = validate_node_ids(&[node], "test.agm");
226        assert!(errors.iter().any(|e| e.code == ErrorCode::V021));
227    }
228
229    #[test]
230    fn test_validate_node_id_leading_dot_returns_v021() {
231        let mut node = minimal_node();
232        node.id = ".auth.login".to_owned();
233        let errors = validate_node_ids(&[node], "test.agm");
234        assert!(errors.iter().any(|e| e.code == ErrorCode::V021));
235    }
236
237    #[test]
238    fn test_validate_node_id_trailing_dot_returns_v021() {
239        let mut node = minimal_node();
240        node.id = "auth.login.".to_owned();
241        let errors = validate_node_ids(&[node], "test.agm");
242        assert!(errors.iter().any(|e| e.code == ErrorCode::V021));
243    }
244
245    #[test]
246    fn test_validate_node_id_consecutive_dots_returns_v021() {
247        let mut node = minimal_node();
248        node.id = "auth..login".to_owned();
249        let errors = validate_node_ids(&[node], "test.agm");
250        assert!(errors.iter().any(|e| e.code == ErrorCode::V021));
251    }
252
253    #[test]
254    fn test_validate_node_id_starts_with_digit_returns_v021() {
255        let mut node = minimal_node();
256        node.id = "1auth".to_owned();
257        let errors = validate_node_ids(&[node], "test.agm");
258        assert!(errors.iter().any(|e| e.code == ErrorCode::V021));
259    }
260
261    #[test]
262    fn test_validate_node_empty_summary_returns_v002() {
263        let mut node = minimal_node();
264        node.summary = String::new();
265        let all_ids = HashSet::new();
266        let errors = validate_node(&node, &all_ids, "test.agm");
267        assert!(errors.iter().any(|e| e.code == ErrorCode::V002));
268    }
269
270    #[test]
271    fn test_validate_node_whitespace_only_summary_returns_v011() {
272        let mut node = minimal_node();
273        node.summary = "   ".to_owned();
274        let all_ids = HashSet::new();
275        let errors = validate_node(&node, &all_ids, "test.agm");
276        assert!(errors.iter().any(|e| e.code == ErrorCode::V011));
277    }
278
279    #[test]
280    fn test_validate_node_summary_exactly_200_returns_empty() {
281        let mut node = minimal_node();
282        node.summary = "a".repeat(200);
283        let all_ids = HashSet::new();
284        let errors = validate_node(&node, &all_ids, "test.agm");
285        assert!(errors.is_empty(), "200 chars should not trigger V012");
286    }
287
288    #[test]
289    fn test_validate_node_summary_201_chars_returns_v012() {
290        let mut node = minimal_node();
291        node.summary = "a".repeat(201);
292        let all_ids = HashSet::new();
293        let errors = validate_node(&node, &all_ids, "test.agm");
294        assert!(errors.iter().any(|e| e.code == ErrorCode::V012));
295    }
296
297    #[test]
298    fn test_validate_node_valid_from_after_until_returns_v007() {
299        let mut node = minimal_node();
300        node.valid_from = Some("2025-12-31".to_owned());
301        node.valid_until = Some("2025-01-01".to_owned());
302        let all_ids = HashSet::new();
303        let errors = validate_node(&node, &all_ids, "test.agm");
304        assert!(errors.iter().any(|e| e.code == ErrorCode::V007));
305    }
306
307    #[test]
308    fn test_validate_node_valid_from_equals_until_returns_empty() {
309        let mut node = minimal_node();
310        node.valid_from = Some("2025-06-01".to_owned());
311        node.valid_until = Some("2025-06-01".to_owned());
312        let all_ids = HashSet::new();
313        let errors = validate_node(&node, &all_ids, "test.agm");
314        assert!(errors.is_empty());
315    }
316
317    #[test]
318    fn test_validate_node_valid_from_before_until_returns_empty() {
319        let mut node = minimal_node();
320        node.valid_from = Some("2025-01-01".to_owned());
321        node.valid_until = Some("2025-12-31".to_owned());
322        let all_ids = HashSet::new();
323        let errors = validate_node(&node, &all_ids, "test.agm");
324        assert!(errors.is_empty());
325    }
326
327    #[test]
328    fn test_validate_node_deprecated_no_replaces_returns_v014() {
329        let mut node = minimal_node();
330        node.status = Some(NodeStatus::Deprecated);
331        node.replaces = None;
332        let all_ids = HashSet::new();
333        let errors = validate_node(&node, &all_ids, "test.agm");
334        assert!(errors.iter().any(|e| e.code == ErrorCode::V014));
335    }
336
337    #[test]
338    fn test_validate_node_superseded_no_replaces_returns_v014() {
339        let mut node = minimal_node();
340        node.status = Some(NodeStatus::Superseded);
341        node.replaces = None;
342        let all_ids = HashSet::new();
343        let errors = validate_node(&node, &all_ids, "test.agm");
344        assert!(errors.iter().any(|e| e.code == ErrorCode::V014));
345    }
346
347    #[test]
348    fn test_validate_node_superseded_with_replaces_returns_empty() {
349        let mut node = minimal_node();
350        node.status = Some(NodeStatus::Superseded);
351        node.replaces = Some(vec!["other.node".to_owned()]);
352        let all_ids = HashSet::new();
353        let errors = validate_node(&node, &all_ids, "test.agm");
354        assert!(!errors.iter().any(|e| e.code == ErrorCode::V014));
355    }
356
357    #[test]
358    fn test_validate_node_valid_node_returns_empty() {
359        let node = minimal_node();
360        let all_ids = HashSet::new();
361        let errors = validate_node(&node, &all_ids, "test.agm");
362        assert!(errors.is_empty());
363    }
364}