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 ticket;
39pub mod type_schema;
40pub mod verify;
41
42#[derive(Clone)]
51pub struct ValidateOptions {
52 pub enforcement_level: EnforcementLevel,
55 pub import_resolver: Option<Arc<dyn ImportResolver + Send + Sync>>,
58}
59
60impl std::fmt::Debug for ValidateOptions {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.debug_struct("ValidateOptions")
63 .field("enforcement_level", &self.enforcement_level)
64 .field("import_resolver", &self.import_resolver.is_some())
65 .finish()
66 }
67}
68
69impl Default for ValidateOptions {
70 fn default() -> Self {
71 Self {
72 enforcement_level: EnforcementLevel::Standard,
73 import_resolver: None,
74 }
75 }
76}
77
78#[must_use]
88pub fn validate(
89 agm_file: &AgmFile,
90 source: &str,
91 file_name: &str,
92 options: &ValidateOptions,
93) -> DiagnosticCollection {
94 let mut collection = DiagnosticCollection::new(file_name, source);
95 let mut all_errors: Vec<AgmError> = Vec::new();
96
97 all_errors.extend(file::validate_file(agm_file, file_name));
99
100 all_errors.extend(node::validate_node_ids(&agm_file.nodes, file_name));
102 let all_ids: HashSet<String> = agm_file.nodes.iter().map(|n| n.id.clone()).collect();
103 for n in &agm_file.nodes {
104 all_errors.extend(node::validate_node(n, &all_ids, file_name));
105 }
106
107 let all_memory_topics: HashSet<String> = agm_file
109 .nodes
110 .iter()
111 .filter_map(|n| n.memory.as_ref())
112 .flat_map(|entries| entries.iter().map(|e| e.topic.clone()))
113 .collect();
114
115 for n in &agm_file.nodes {
117 all_errors.extend(code::validate_code(n, file_name));
118 all_errors.extend(verify::validate_verify(n, &all_ids, file_name));
119 all_errors.extend(context::validate_context(
120 n,
121 &all_ids,
122 &all_memory_topics,
123 file_name,
124 ));
125 all_errors.extend(orchestration::validate_orchestration(
126 n, &all_ids, file_name,
127 ));
128 all_errors.extend(execution::validate_execution(n, file_name));
129 all_errors.extend(memory::validate_memory(n, file_name));
130 all_errors.extend(ticket::validate_ticket(n, file_name));
131 }
132
133 for n in &agm_file.nodes {
135 all_errors.extend(type_schema::validate_type_schema(
136 n,
137 &options.enforcement_level,
138 file_name,
139 ));
140 }
141
142 if !agm_file.nodes.is_empty() {
144 all_errors.extend(references::validate_references(
145 agm_file, &all_ids, file_name,
146 ));
147 all_errors.extend(cycles::validate_cycles(agm_file, file_name));
148 all_errors.extend(compatibility::validate_compatibility(agm_file, file_name));
149 }
150
151 if let Some(ref resolver) = options.import_resolver {
153 all_errors.extend(imports::validate_imports(
154 agm_file,
155 resolver.as_ref(),
156 file_name,
157 ));
158 }
159
160 sort_diagnostics(&mut all_errors);
162
163 collection.extend(all_errors);
164 collection
165}
166
167fn sort_diagnostics(errors: &mut [AgmError]) {
170 errors.sort_by(|a, b| {
171 let line_a = a.location.line.unwrap_or(0);
172 let line_b = b.location.line.unwrap_or(0);
173
174 line_a
175 .cmp(&line_b)
176 .then_with(|| severity_rank(a.severity).cmp(&severity_rank(b.severity)))
177 });
178}
179
180fn severity_rank(sev: crate::error::diagnostic::Severity) -> u8 {
182 use crate::error::diagnostic::Severity;
183 match sev {
184 Severity::Error => 0,
185 Severity::Warning => 1,
186 Severity::Info => 2,
187 }
188}
189
190#[cfg(test)]
195mod tests {
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 span: Span::new(line, line + 2),
225 ..Default::default()
226 }
227 }
228
229 #[test]
230 fn test_validate_valid_file_returns_no_errors() {
231 let file = AgmFile {
232 header: valid_header(),
233 nodes: vec![minimal_node("auth.login", 5)],
234 };
235 let result = validate(&file, "", "test.agm", &Default::default());
236 assert!(!result.has_errors(), "Valid file should produce no errors");
237 }
238
239 #[test]
240 fn test_validate_default_options_uses_standard_level() {
241 let opts = ValidateOptions::default();
242 assert_eq!(opts.enforcement_level, EnforcementLevel::Standard);
243 assert!(opts.import_resolver.is_none());
244 }
245
246 #[test]
247 fn test_validate_multiple_errors_sorted_by_line() {
248 let mut node_a = minimal_node("auth.a", 5);
250 node_a.summary = "x".repeat(201); let mut node_b = minimal_node("auth.b", 20);
253 node_b.depends = Some(vec!["missing.dep".to_owned()]); let file = AgmFile {
256 header: valid_header(),
257 nodes: vec![node_a, node_b],
258 };
259 let result = validate(&file, "", "test.agm", &Default::default());
260 let diags = result.diagnostics();
261 assert!(!diags.is_empty());
263 for i in 1..diags.len() {
265 let prev_line = diags[i - 1].location.line.unwrap_or(0);
266 let curr_line = diags[i].location.line.unwrap_or(0);
267 assert!(
268 prev_line <= curr_line,
269 "Diagnostics not sorted by line: {} > {}",
270 prev_line,
271 curr_line
272 );
273 }
274 }
275
276 #[test]
277 fn test_validate_empty_header_fields_produce_p001() {
278 let mut file = AgmFile {
279 header: valid_header(),
280 nodes: vec![minimal_node("test.node", 5)],
281 };
282 file.header.agm = String::new();
283 let result = validate(&file, "", "test.agm", &Default::default());
284 assert!(
285 result
286 .diagnostics()
287 .iter()
288 .any(|d| d.code == ErrorCode::P001)
289 );
290 }
291
292 #[test]
293 fn test_validate_no_nodes_produces_p008() {
294 let file = AgmFile {
295 header: valid_header(),
296 nodes: vec![],
297 };
298 let result = validate(&file, "", "test.agm", &Default::default());
299 assert!(
300 result
301 .diagnostics()
302 .iter()
303 .any(|d| d.code == ErrorCode::P008)
304 );
305 }
306
307 #[test]
308 fn test_validate_options_clone() {
309 let opts = ValidateOptions::default();
310 let _cloned = opts.clone();
311 }
312
313 #[test]
314 fn test_sort_diagnostics_orders_by_line_then_severity() {
315 use crate::error::diagnostic::{AgmError, ErrorLocation, Severity};
316
317 let mut errors = vec![
318 AgmError::with_severity(
319 ErrorCode::V012,
320 Severity::Warning,
321 "warn",
322 ErrorLocation::file_line("f", 10),
323 ),
324 AgmError::with_severity(
325 ErrorCode::V004,
326 Severity::Error,
327 "err",
328 ErrorLocation::file_line("f", 10),
329 ),
330 AgmError::with_severity(
331 ErrorCode::V003,
332 Severity::Error,
333 "earlier",
334 ErrorLocation::file_line("f", 5),
335 ),
336 ];
337
338 sort_diagnostics(&mut errors);
339
340 assert_eq!(errors[0].location.line, Some(5));
342 assert_eq!(errors[1].severity, Severity::Error);
344 assert_eq!(errors[2].severity, Severity::Warning);
345 }
346}