1use std::collections::HashSet;
20use std::sync::Arc;
21
22use crate::error::diagnostic::{AgmError, DiagnosticCollection};
23use crate::import::ImportResolver;
24use crate::model::file::AgmFile;
25use crate::model::schema::EnforcementLevel;
26
27pub mod code;
28pub mod compatibility;
29pub mod context;
30pub mod cycles;
31pub mod execution;
32pub mod file;
33pub mod imports;
34pub mod memory;
35pub mod node;
36pub mod orchestration;
37pub mod references;
38pub mod type_schema;
39pub mod verify;
40
41#[derive(Clone)]
50pub struct ValidateOptions {
51 pub enforcement_level: EnforcementLevel,
54 pub import_resolver: Option<Arc<dyn ImportResolver + Send + Sync>>,
57}
58
59impl std::fmt::Debug for ValidateOptions {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 f.debug_struct("ValidateOptions")
62 .field("enforcement_level", &self.enforcement_level)
63 .field("import_resolver", &self.import_resolver.is_some())
64 .finish()
65 }
66}
67
68impl Default for ValidateOptions {
69 fn default() -> Self {
70 Self {
71 enforcement_level: EnforcementLevel::Standard,
72 import_resolver: None,
73 }
74 }
75}
76
77#[must_use]
87pub fn validate(
88 agm_file: &AgmFile,
89 source: &str,
90 file_name: &str,
91 options: &ValidateOptions,
92) -> DiagnosticCollection {
93 let mut collection = DiagnosticCollection::new(file_name, source);
94 let mut all_errors: Vec<AgmError> = Vec::new();
95
96 all_errors.extend(file::validate_file(agm_file, file_name));
98
99 all_errors.extend(node::validate_node_ids(&agm_file.nodes, file_name));
101 let all_ids: HashSet<String> = agm_file.nodes.iter().map(|n| n.id.clone()).collect();
102 for n in &agm_file.nodes {
103 all_errors.extend(node::validate_node(n, &all_ids, file_name));
104 }
105
106 let all_memory_topics: HashSet<String> = agm_file
108 .nodes
109 .iter()
110 .filter_map(|n| n.memory.as_ref())
111 .flat_map(|entries| entries.iter().map(|e| e.topic.clone()))
112 .collect();
113
114 for n in &agm_file.nodes {
116 all_errors.extend(code::validate_code(n, file_name));
117 all_errors.extend(verify::validate_verify(n, &all_ids, file_name));
118 all_errors.extend(context::validate_context(
119 n,
120 &all_ids,
121 &all_memory_topics,
122 file_name,
123 ));
124 all_errors.extend(orchestration::validate_orchestration(
125 n, &all_ids, file_name,
126 ));
127 all_errors.extend(execution::validate_execution(n, file_name));
128 all_errors.extend(memory::validate_memory(n, file_name));
129 }
130
131 for n in &agm_file.nodes {
133 all_errors.extend(type_schema::validate_type_schema(
134 n,
135 &options.enforcement_level,
136 file_name,
137 ));
138 }
139
140 if !agm_file.nodes.is_empty() {
142 all_errors.extend(references::validate_references(
143 agm_file, &all_ids, file_name,
144 ));
145 all_errors.extend(cycles::validate_cycles(agm_file, file_name));
146 all_errors.extend(compatibility::validate_compatibility(agm_file, file_name));
147 }
148
149 if let Some(ref resolver) = options.import_resolver {
151 all_errors.extend(imports::validate_imports(
152 agm_file,
153 resolver.as_ref(),
154 file_name,
155 ));
156 }
157
158 sort_diagnostics(&mut all_errors);
160
161 collection.extend(all_errors);
162 collection
163}
164
165fn sort_diagnostics(errors: &mut [AgmError]) {
168 errors.sort_by(|a, b| {
169 let line_a = a.location.line.unwrap_or(0);
170 let line_b = b.location.line.unwrap_or(0);
171
172 line_a
173 .cmp(&line_b)
174 .then_with(|| severity_rank(a.severity).cmp(&severity_rank(b.severity)))
175 });
176}
177
178fn severity_rank(sev: crate::error::diagnostic::Severity) -> u8 {
180 use crate::error::diagnostic::Severity;
181 match sev {
182 Severity::Error => 0,
183 Severity::Warning => 1,
184 Severity::Info => 2,
185 }
186}
187
188#[cfg(test)]
193mod tests {
194 use std::collections::BTreeMap;
195
196 use super::*;
197 use crate::error::codes::ErrorCode;
198 use crate::model::fields::{NodeType, Span};
199 use crate::model::file::{AgmFile, Header};
200 use crate::model::node::Node;
201
202 fn valid_header() -> Header {
203 Header {
204 agm: "1.0".to_owned(),
205 package: "test.pkg".to_owned(),
206 version: "0.1.0".to_owned(),
207 title: None,
208 owner: None,
209 imports: None,
210 default_load: None,
211 description: None,
212 tags: None,
213 status: None,
214 load_profiles: None,
215 target_runtime: None,
216 }
217 }
218
219 fn minimal_node(id: &str, line: usize) -> Node {
220 Node {
221 id: id.to_owned(),
222 node_type: NodeType::Facts,
223 summary: "a test node".to_owned(),
224 priority: None,
225 stability: None,
226 confidence: None,
227 status: None,
228 depends: None,
229 related_to: None,
230 replaces: None,
231 conflicts: None,
232 see_also: None,
233 items: None,
234 steps: None,
235 fields: None,
236 input: None,
237 output: None,
238 detail: None,
239 rationale: None,
240 tradeoffs: None,
241 resolution: None,
242 examples: None,
243 notes: None,
244 code: None,
245 code_blocks: None,
246 verify: None,
247 agent_context: None,
248 target: None,
249 execution_status: None,
250 executed_by: None,
251 executed_at: None,
252 execution_log: None,
253 retry_count: None,
254 parallel_groups: None,
255 memory: None,
256 scope: None,
257 applies_when: None,
258 valid_from: None,
259 valid_until: None,
260 tags: None,
261 aliases: None,
262 keywords: None,
263 extra_fields: BTreeMap::new(),
264 span: Span::new(line, line + 2),
265 }
266 }
267
268 #[test]
269 fn test_validate_valid_file_returns_no_errors() {
270 let file = AgmFile {
271 header: valid_header(),
272 nodes: vec![minimal_node("auth.login", 5)],
273 };
274 let result = validate(&file, "", "test.agm", &Default::default());
275 assert!(!result.has_errors(), "Valid file should produce no errors");
276 }
277
278 #[test]
279 fn test_validate_default_options_uses_standard_level() {
280 let opts = ValidateOptions::default();
281 assert_eq!(opts.enforcement_level, EnforcementLevel::Standard);
282 assert!(opts.import_resolver.is_none());
283 }
284
285 #[test]
286 fn test_validate_multiple_errors_sorted_by_line() {
287 let mut node_a = minimal_node("auth.a", 5);
289 node_a.summary = "x".repeat(201); let mut node_b = minimal_node("auth.b", 20);
292 node_b.depends = Some(vec!["missing.dep".to_owned()]); let file = AgmFile {
295 header: valid_header(),
296 nodes: vec![node_a, node_b],
297 };
298 let result = validate(&file, "", "test.agm", &Default::default());
299 let diags = result.diagnostics();
300 assert!(!diags.is_empty());
302 for i in 1..diags.len() {
304 let prev_line = diags[i - 1].location.line.unwrap_or(0);
305 let curr_line = diags[i].location.line.unwrap_or(0);
306 assert!(
307 prev_line <= curr_line,
308 "Diagnostics not sorted by line: {} > {}",
309 prev_line,
310 curr_line
311 );
312 }
313 }
314
315 #[test]
316 fn test_validate_empty_header_fields_produce_p001() {
317 let mut file = AgmFile {
318 header: valid_header(),
319 nodes: vec![minimal_node("test.node", 5)],
320 };
321 file.header.agm = String::new();
322 let result = validate(&file, "", "test.agm", &Default::default());
323 assert!(
324 result
325 .diagnostics()
326 .iter()
327 .any(|d| d.code == ErrorCode::P001)
328 );
329 }
330
331 #[test]
332 fn test_validate_no_nodes_produces_p008() {
333 let file = AgmFile {
334 header: valid_header(),
335 nodes: vec![],
336 };
337 let result = validate(&file, "", "test.agm", &Default::default());
338 assert!(
339 result
340 .diagnostics()
341 .iter()
342 .any(|d| d.code == ErrorCode::P008)
343 );
344 }
345
346 #[test]
347 fn test_validate_options_clone() {
348 let opts = ValidateOptions::default();
349 let _cloned = opts.clone();
350 }
351
352 #[test]
353 fn test_sort_diagnostics_orders_by_line_then_severity() {
354 use crate::error::diagnostic::{AgmError, ErrorLocation, Severity};
355
356 let mut errors = vec![
357 AgmError::with_severity(
358 ErrorCode::V012,
359 Severity::Warning,
360 "warn",
361 ErrorLocation::file_line("f", 10),
362 ),
363 AgmError::with_severity(
364 ErrorCode::V004,
365 Severity::Error,
366 "err",
367 ErrorLocation::file_line("f", 10),
368 ),
369 AgmError::with_severity(
370 ErrorCode::V003,
371 Severity::Error,
372 "earlier",
373 ErrorLocation::file_line("f", 5),
374 ),
375 ];
376
377 sort_diagnostics(&mut errors);
378
379 assert_eq!(errors[0].location.line, Some(5));
381 assert_eq!(errors[1].severity, Severity::Error);
383 assert_eq!(errors[2].severity, Severity::Warning);
384 }
385}