Skip to main content

amql_engine/
validator.rs

1//! Validate annotation files against a schema manifest.
2
3use crate::manifest::{AttrDefinition, AttrType, Manifest, BUILTIN_ATTRS};
4use crate::sidecar::SidecarLocator;
5use crate::store::{Annotation, AnnotationStore};
6use crate::types::RelativePath;
7#[cfg(feature = "fs")]
8use rayon::prelude::*;
9use serde::Serialize;
10use serde_json::Value as JsonValue;
11
12/// A validation result.
13#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
14#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
15#[cfg_attr(feature = "ts", ts(export))]
16#[cfg_attr(feature = "flow", flow(export))]
17#[derive(Debug, Clone, Serialize)]
18pub struct ValidationResult {
19    pub level: ValidationLevel,
20    pub file: RelativePath,
21    pub message: String,
22}
23
24/// Severity level.
25#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
26#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
27#[cfg_attr(feature = "ts", ts(export))]
28#[cfg_attr(feature = "flow", flow(export))]
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "lowercase")]
31#[non_exhaustive]
32pub enum ValidationLevel {
33    Error,
34    Warning,
35}
36
37/// Validate all loaded annotations against the manifest.
38/// Parallelizes per-file validation via rayon when the `fs` feature is enabled.
39#[must_use = "returns validation results that should be inspected"]
40pub fn validate(store: &AnnotationStore, manifest: &Manifest) -> Vec<ValidationResult> {
41    let files = store.annotated_files();
42    let locator = store.locator();
43
44    let validate_file = |file: &RelativePath| -> Vec<ValidationResult> {
45        let annotations = store.get_file_annotations(file);
46        let mut results = Vec::new();
47        for ann in annotations {
48            validate_annotation(ann, manifest, locator, &mut results);
49        }
50        results
51    };
52
53    #[cfg(feature = "fs")]
54    {
55        files.par_iter().flat_map(validate_file).collect()
56    }
57    #[cfg(not(feature = "fs"))]
58    {
59        files.iter().flat_map(validate_file).collect()
60    }
61}
62
63fn validate_annotation(
64    ann: &Annotation,
65    manifest: &Manifest,
66    locator: &dyn SidecarLocator,
67    results: &mut Vec<ValidationResult>,
68) {
69    let file = locator.sidecar_for(&ann.file);
70
71    // Check tag exists in manifest
72    let tag_def = match manifest.tags.get(&*ann.tag) {
73        Some(def) => def,
74        None => {
75            results.push(ValidationResult {
76                level: ValidationLevel::Error,
77                file: file.clone(),
78                message: format!("Unknown tag '{}'", ann.tag),
79            });
80            // Can't validate attrs for unknown tag
81            validate_children(ann, manifest, locator, results);
82            return;
83        }
84    };
85
86    // Check required bind
87    if tag_def.require_bind && ann.binding.is_empty() {
88        results.push(ValidationResult {
89            level: ValidationLevel::Error,
90            file: file.clone(),
91            message: format!("Tag '{}' requires a 'bind' attribute", ann.tag),
92        });
93    }
94
95    // Check for unknown attributes
96    for attr_name in ann.attrs.keys() {
97        if BUILTIN_ATTRS.contains(&attr_name.as_ref()) {
98            continue;
99        }
100        if !tag_def.attrs.contains_key(&**attr_name) {
101            results.push(ValidationResult {
102                level: ValidationLevel::Warning,
103                file: file.clone(),
104                message: format!("Unknown attribute '{}' on tag '{}'", attr_name, ann.tag),
105            });
106        }
107    }
108
109    // Check required attributes
110    for (attr_name, attr_def) in &tag_def.attrs {
111        if attr_def.required && !ann.attrs.contains_key(&**attr_name) {
112            results.push(ValidationResult {
113                level: ValidationLevel::Error,
114                file: file.clone(),
115                message: format!(
116                    "Missing required attribute '{}' on tag '{}'",
117                    attr_name, ann.tag
118                ),
119            });
120        }
121    }
122
123    // Type-check attribute values
124    for (attr_name, value) in &ann.attrs {
125        if BUILTIN_ATTRS.contains(&attr_name.as_ref()) {
126            continue;
127        }
128        if let Some(attr_def) = tag_def.attrs.get(&**attr_name) {
129            validate_attr_value(ann, attr_name, value, attr_def, &file, results);
130        }
131    }
132
133    validate_children(ann, manifest, locator, results);
134}
135
136fn validate_attr_value(
137    ann: &Annotation,
138    attr_name: &str,
139    value: &JsonValue,
140    attr_def: &AttrDefinition,
141    file: &RelativePath,
142    results: &mut Vec<ValidationResult>,
143) {
144    if attr_def.attr_type == AttrType::Enum {
145        if let Some(ref values) = attr_def.values {
146            let str_val = crate::json_value_to_string(value);
147            if !values.iter().any(|v| v == str_val.as_ref()) {
148                results.push(ValidationResult {
149                    level: ValidationLevel::Error,
150                    file: file.clone(),
151                    message: format!(
152                        "Invalid value '{}' for enum attribute '{}' on tag '{}'. Expected one of: {}",
153                        str_val,
154                        attr_name,
155                        ann.tag,
156                        values.join(", ")
157                    ),
158                });
159            }
160        }
161    }
162
163    if attr_def.attr_type == AttrType::Boolean && !value.is_boolean() {
164        results.push(ValidationResult {
165            level: ValidationLevel::Warning,
166            file: file.clone(),
167            message: format!(
168                "Attribute '{}' on tag '{}' should be a boolean",
169                attr_name, ann.tag
170            ),
171        });
172    }
173
174    if attr_def.attr_type == AttrType::Number && !value.is_number() {
175        results.push(ValidationResult {
176            level: ValidationLevel::Warning,
177            file: file.clone(),
178            message: format!(
179                "Attribute '{}' on tag '{}' should be a number",
180                attr_name, ann.tag
181            ),
182        });
183    }
184}
185
186fn validate_children(
187    ann: &Annotation,
188    manifest: &Manifest,
189    locator: &dyn SidecarLocator,
190    results: &mut Vec<ValidationResult>,
191) {
192    for child in &ann.children {
193        validate_annotation(child, manifest, locator, results);
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::manifest::parse_manifest;
201    use crate::store::AnnotationStore;
202    use crate::types::{AttrName, Binding, TagName};
203    use rustc_hash::FxHashMap;
204    use std::path::Path;
205
206    const MANIFEST_XML: &str = r#"
207<schema version="1.0">
208  <define tag="controller" description="HTTP handler">
209    <attr name="method" type="enum" values="GET,POST,PUT,DELETE" required="true" />
210    <attr name="path" type="string" required="true" />
211  </define>
212</schema>
213"#;
214
215    fn create_store_with_annotations(
216        annotations: Vec<(String, FxHashMap<AttrName, JsonValue>, String)>,
217    ) -> AnnotationStore {
218        let mut store = AnnotationStore::new(Path::new("/project"));
219        let mut by_file: FxHashMap<RelativePath, Vec<Annotation>> = FxHashMap::default();
220
221        for (tag, attrs, file) in annotations {
222            let rel = RelativePath::from(file);
223            by_file.entry(rel.clone()).or_default().push(Annotation {
224                tag: TagName::from(tag),
225                attrs,
226                binding: Binding::from(""),
227                file: rel,
228                children: vec![],
229            });
230        }
231
232        for (file, anns) in by_file {
233            store.inject_test_data(&file, anns);
234        }
235
236        store
237    }
238
239    fn attrs(pairs: &[(&str, JsonValue)]) -> FxHashMap<AttrName, JsonValue> {
240        pairs
241            .iter()
242            .map(|(k, v)| (AttrName::from(*k), v.clone()))
243            .collect()
244    }
245
246    #[test]
247    fn reports_validation_errors() {
248        // Arrange
249        let manifest = parse_manifest(MANIFEST_XML).unwrap();
250        let store = create_store_with_annotations(vec![
251            // Unknown tag
252            (
253                "controllr".to_string(),
254                FxHashMap::default(),
255                "src/api.ts".to_string(),
256            ),
257            // Missing required attr (path)
258            (
259                "controller".to_string(),
260                attrs(&[("method", JsonValue::String("POST".to_string()))]),
261                "src/handlers.ts".to_string(),
262            ),
263            // Invalid enum value
264            (
265                "controller".to_string(),
266                attrs(&[
267                    ("method", JsonValue::String("PATCH".to_string())),
268                    ("path", JsonValue::String("/api".to_string())),
269                ]),
270                "src/routes.ts".to_string(),
271            ),
272        ]);
273
274        // Act
275        let results = validate(&store, &manifest);
276
277        // Assert
278        let unknown_tag = results.iter().find(|r| r.message.contains("Unknown tag"));
279        assert!(unknown_tag.is_some(), "should flag unknown tag");
280        assert_eq!(
281            unknown_tag.unwrap().level,
282            ValidationLevel::Error,
283            "unknown tag should be error level"
284        );
285        assert!(
286            unknown_tag.unwrap().message.contains("controllr"),
287            "should mention the unknown tag name"
288        );
289
290        let missing_attr = results
291            .iter()
292            .find(|r| r.message.contains("Missing required attribute"));
293        assert!(missing_attr.is_some(), "should flag missing required attr");
294        assert_eq!(
295            missing_attr.unwrap().level,
296            ValidationLevel::Error,
297            "missing attr should be error level"
298        );
299        assert!(
300            missing_attr.unwrap().message.contains("path"),
301            "should mention the missing attr name"
302        );
303
304        let invalid_enum = results.iter().find(|r| r.message.contains("Invalid value"));
305        assert!(invalid_enum.is_some(), "should flag invalid enum value");
306        assert_eq!(
307            invalid_enum.unwrap().level,
308            ValidationLevel::Error,
309            "invalid enum should be error level"
310        );
311        assert!(
312            invalid_enum.unwrap().message.contains("PATCH"),
313            "should mention the invalid value"
314        );
315    }
316
317    #[test]
318    fn passes_valid_annotations() {
319        // Arrange
320        let manifest = parse_manifest(MANIFEST_XML).unwrap();
321        let store = create_store_with_annotations(vec![
322            // Valid annotation with required attrs
323            (
324                "controller".to_string(),
325                attrs(&[
326                    ("method", JsonValue::String("POST".to_string())),
327                    ("path", JsonValue::String("/api/users".to_string())),
328                ]),
329                "src/api.ts".to_string(),
330            ),
331            // Valid annotation with builtin attrs that need no manifest definition
332            (
333                "controller".to_string(),
334                attrs(&[
335                    ("method", JsonValue::String("POST".to_string())),
336                    ("path", JsonValue::String("/api/teams".to_string())),
337                    ("owner", JsonValue::String("@backend".to_string())),
338                    ("visibility", JsonValue::String("public".to_string())),
339                ]),
340                "src/teams.ts".to_string(),
341            ),
342        ]);
343
344        // Act
345        let results = validate(&store, &manifest);
346
347        // Assert
348        assert!(
349            results.is_empty(),
350            "valid annotations should produce no issues"
351        );
352    }
353}