1use 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#[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#[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#[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 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 validate_children(ann, manifest, locator, results);
82 return;
83 }
84 };
85
86 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 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 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 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 let manifest = parse_manifest(MANIFEST_XML).unwrap();
250 let store = create_store_with_annotations(vec![
251 (
253 "controllr".to_string(),
254 FxHashMap::default(),
255 "src/api.ts".to_string(),
256 ),
257 (
259 "controller".to_string(),
260 attrs(&[("method", JsonValue::String("POST".to_string()))]),
261 "src/handlers.ts".to_string(),
262 ),
263 (
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 let results = validate(&store, &manifest);
276
277 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 let manifest = parse_manifest(MANIFEST_XML).unwrap();
321 let store = create_store_with_annotations(vec![
322 (
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 (
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 let results = validate(&store, &manifest);
346
347 assert!(
349 results.is_empty(),
350 "valid annotations should produce no issues"
351 );
352 }
353}