1use 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
15static 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#[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 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 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#[must_use]
67pub fn validate_node(node: &Node, all_ids: &HashSet<String>, file_name: &str) -> Vec<AgmError> {
68 let _ = all_ids; let mut errors = Vec::new();
70 let line = node.span.start_line;
71 let id = node.id.as_str();
72
73 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 return errors;
84 }
85
86 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 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 if let (Some(from), Some(until)) = (&node.valid_from, &node.valid_until) {
108 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 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}