1use crate::error::AqlError;
4use crate::types::{AttrName, TagName};
5use quick_xml::events::Event;
6use quick_xml::Reader;
7use rustc_hash::FxHashMap;
8use serde::{Deserialize, Serialize};
9
10pub const SCHEMA_VERSION: &str = "1.0";
16
17pub const BUILTIN_ATTRS: &[&str] = &["id", "name", "visibility", "audience", "owner", "note"];
19
20pub const NON_GENERATED_BUILTINS: &[&str] = &["note"];
22
23#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
25#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
26#[cfg_attr(feature = "ts", ts(export))]
27#[cfg_attr(feature = "flow", flow(export))]
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ExtractorConfig {
30 pub name: String,
32 pub run: String,
35 pub globs: Vec<String>,
37}
38
39#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
41#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
42#[cfg_attr(feature = "ts", ts(export))]
43#[cfg_attr(feature = "flow", flow(export))]
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Manifest {
46 pub version: String,
47 #[cfg_attr(
48 feature = "ts",
49 ts(as = "std::collections::HashMap<TagName, TagDefinition>")
50 )]
51 #[cfg_attr(
52 feature = "flow",
53 flow(as = "std::collections::HashMap<TagName, TagDefinition>")
54 )]
55 pub tags: FxHashMap<TagName, TagDefinition>,
56 #[cfg_attr(feature = "ts", ts(as = "std::collections::HashMap<String, String>"))]
57 #[cfg_attr(
58 feature = "flow",
59 flow(as = "std::collections::HashMap<String, String>")
60 )]
61 pub audiences: FxHashMap<String, String>,
62 #[cfg_attr(feature = "ts", ts(as = "std::collections::HashMap<String, String>"))]
63 #[cfg_attr(
64 feature = "flow",
65 flow(as = "std::collections::HashMap<String, String>")
66 )]
67 pub visibilities: FxHashMap<String, String>,
68 pub extractors: Vec<ExtractorConfig>,
70}
71
72#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
74#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
75#[cfg_attr(feature = "ts", ts(export))]
76#[cfg_attr(feature = "flow", flow(export))]
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct TagDefinition {
79 pub description: String,
80 #[cfg_attr(
81 feature = "ts",
82 ts(as = "std::collections::HashMap<AttrName, AttrDefinition>")
83 )]
84 #[cfg_attr(
85 feature = "flow",
86 flow(as = "std::collections::HashMap<AttrName, AttrDefinition>")
87 )]
88 pub attrs: FxHashMap<AttrName, AttrDefinition>,
89 pub require_bind: bool,
91}
92
93#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
95#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
96#[cfg_attr(feature = "ts", ts(export))]
97#[cfg_attr(feature = "flow", flow(export))]
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct AttrDefinition {
100 #[serde(rename = "type")]
101 pub attr_type: AttrType,
102 pub required: bool,
103 pub default: Option<String>,
104 pub values: Option<Vec<String>>,
105 pub description: Option<String>,
106 pub generated: bool,
110}
111
112#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
114#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
115#[cfg_attr(feature = "ts", ts(export))]
116#[cfg_attr(feature = "flow", flow(export))]
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "lowercase")]
119#[non_exhaustive]
120pub enum AttrType {
121 String,
122 Boolean,
123 Number,
124 Enum,
125 Expression,
126 #[serde(rename = "string[]")]
127 StringArray,
128}
129
130impl Manifest {
131 pub fn is_generated_attr(&self, tag: &TagName, attr: &AttrName) -> bool {
135 if NON_GENERATED_BUILTINS.contains(&attr.as_ref()) {
136 return false;
137 }
138 if let Some(tag_def) = self.tags.get(tag) {
139 if let Some(attr_def) = tag_def.attrs.get(attr) {
140 return attr_def.generated;
141 }
142 }
143 true
145 }
146}
147
148#[cfg(feature = "fs")]
151#[must_use]
152pub fn find_project_root(start_dir: &std::path::Path) -> Option<std::path::PathBuf> {
153 let mut dir = start_dir.to_path_buf();
154 loop {
155 let candidate = dir.join(".config").join("aql.schema");
156 if candidate.is_file() {
157 return Some(dir);
158 }
159 if !dir.pop() {
160 return None;
161 }
162 }
163}
164
165#[cfg(feature = "fs")]
167#[must_use = "loading a manifest is useless without inspecting the result"]
168pub fn load_manifest(project_root: &std::path::Path) -> Result<Manifest, AqlError> {
169 let manifest_path = project_root.join(".config").join("aql.schema");
170 let raw = std::fs::read_to_string(&manifest_path).map_err(|e| {
171 format!(
172 "Failed to read manifest at {}: {}",
173 manifest_path.display(),
174 e
175 )
176 })?;
177 parse_manifest(&raw)
178}
179
180fn get_attr<'a>(pairs: &'a [(String, String)], key: &str) -> Option<&'a str> {
182 pairs
183 .iter()
184 .find(|(k, _)| k == key)
185 .map(|(_, v)| v.as_str())
186}
187
188enum ParseContext {
190 Outside,
191 Schema,
192 Define {
193 tag: String,
194 description: String,
195 attrs: FxHashMap<AttrName, AttrDefinition>,
196 require_bind: bool,
197 },
198}
199
200#[must_use = "parsing a manifest is useless without inspecting the result"]
202pub fn parse_manifest(raw: &str) -> Result<Manifest, AqlError> {
203 let mut reader = Reader::from_str(raw);
204
205 let mut version = Option::<String>::None;
206 let mut tags = FxHashMap::default();
207 let mut audiences = FxHashMap::default();
208 let mut visibilities = FxHashMap::default();
209 let mut extractors: Vec<ExtractorConfig> = Vec::new();
210 let mut context = ParseContext::Outside;
211
212 let mut buf = Vec::new();
213
214 loop {
215 match reader.read_event_into(&mut buf) {
216 Ok(Event::Eof) => break,
217 Ok(Event::Start(ref e)) => {
218 let name = crate::xml::element_name(e)?;
219 let pairs = crate::xml::attr_map(e)?;
220
221 match name.as_str() {
222 "schema" if matches!(context, ParseContext::Outside) => {
223 if let Some(v) = get_attr(&pairs, "version") {
224 version = Some(v.to_string());
225 }
226 context = ParseContext::Schema;
227 }
228 "define" if matches!(context, ParseContext::Schema) => {
229 let require_bind =
230 get_attr(&pairs, "require-bind").is_some_and(|v| v == "true");
231 context = ParseContext::Define {
232 tag: get_attr(&pairs, "tag").unwrap_or("").to_string(),
233 description: get_attr(&pairs, "description").unwrap_or("").to_string(),
234 attrs: FxHashMap::default(),
235 require_bind,
236 };
237 }
238 _ => {}
239 }
240 }
241 Ok(Event::Empty(ref e)) => {
242 let name = crate::xml::element_name(e)?;
243 let pairs = crate::xml::attr_map(e)?;
244
245 match name.as_str() {
246 "attr" => {
247 if let ParseContext::Define { ref mut attrs, .. } = context {
248 if let Some((attr_name, def)) = parse_attr_pairs(&pairs)? {
249 attrs.insert(AttrName::from(attr_name), def);
250 }
251 }
252 }
253 "audience" if matches!(context, ParseContext::Schema) => {
254 if let Some(n) = get_attr(&pairs, "name").filter(|s| !s.is_empty()) {
255 audiences.insert(
256 n.to_string(),
257 get_attr(&pairs, "description").unwrap_or("").to_string(),
258 );
259 }
260 }
261 "visibility" if matches!(context, ParseContext::Schema) => {
262 if let Some(n) = get_attr(&pairs, "name").filter(|s| !s.is_empty()) {
263 visibilities.insert(
264 n.to_string(),
265 get_attr(&pairs, "description").unwrap_or("").to_string(),
266 );
267 }
268 }
269 "extractor" if matches!(context, ParseContext::Schema) => {
270 if let Some(name) = get_attr(&pairs, "name").filter(|s| !s.is_empty()) {
271 let run = get_attr(&pairs, "run").unwrap_or("").to_string();
272 let globs = get_attr(&pairs, "globs")
273 .map(|g| {
274 g.split(',')
275 .map(|s| s.trim().to_string())
276 .filter(|s| !s.is_empty())
277 .collect()
278 })
279 .unwrap_or_default();
280 extractors.push(ExtractorConfig {
281 name: name.to_string(),
282 run,
283 globs,
284 });
285 }
286 }
287 "define" if matches!(context, ParseContext::Schema) => {
288 if let Some(n) = get_attr(&pairs, "tag").filter(|s| !s.is_empty()) {
289 let require_bind =
290 get_attr(&pairs, "require-bind").is_some_and(|v| v == "true");
291 tags.insert(
292 TagName::from(n),
293 TagDefinition {
294 description: get_attr(&pairs, "description")
295 .unwrap_or("")
296 .to_string(),
297 attrs: FxHashMap::default(),
298 require_bind,
299 },
300 );
301 }
302 }
303 _ => {}
304 }
305 }
306 Ok(Event::End(ref e)) => {
307 let name = crate::xml::end_name(e)?;
308
309 match name.as_str() {
310 "schema" if matches!(context, ParseContext::Schema) => {
311 context = ParseContext::Outside;
312 }
313 "define" => match std::mem::replace(&mut context, ParseContext::Schema) {
314 ParseContext::Define {
315 tag,
316 description,
317 attrs,
318 require_bind,
319 } => {
320 if !tag.is_empty() {
321 tags.insert(
322 TagName::from(tag),
323 TagDefinition {
324 description,
325 attrs,
326 require_bind,
327 },
328 );
329 }
330 }
331 other => context = other,
332 },
333 _ => {}
334 }
335 }
336 Err(e) => return Err(format!("Invalid XML: {e}").into()),
337 _ => {}
338 }
339 buf.clear();
340 }
341
342 let version = version.ok_or_else(|| {
343 "Invalid manifest: missing or non-string 'version' attribute on <schema>".to_string()
344 })?;
345
346 check_schema_version(&version)?;
347
348 Ok(Manifest {
349 version,
350 tags,
351 audiences,
352 visibilities,
353 extractors,
354 })
355}
356
357pub(crate) fn check_schema_version(version: &str) -> Result<(), String> {
362 if version.trim().is_empty() {
363 return Err("Schema version cannot be empty".to_string());
364 }
365 Ok(())
366}
367
368fn parse_attr_pairs(
369 pairs: &[(String, String)],
370) -> Result<Option<(String, AttrDefinition)>, String> {
371 let attr_name = get_attr(pairs, "name").unwrap_or("").to_string();
372 if attr_name.is_empty() {
373 return Ok(None);
374 }
375
376 let attr_type_str = get_attr(pairs, "type").unwrap_or("");
377 let attr_type = match attr_type_str {
378 "string" => AttrType::String,
379 "boolean" => AttrType::Boolean,
380 "number" => AttrType::Number,
381 "enum" => AttrType::Enum,
382 "expression" => AttrType::Expression,
383 "string[]" => AttrType::StringArray,
384 other => {
385 return Err(format!(
386 "Unknown attr type '{other}' for attr '{attr_name}'"
387 ))
388 }
389 };
390
391 let generated = get_attr(pairs, "generated") != Some("false");
392
393 Ok(Some((
394 attr_name,
395 AttrDefinition {
396 attr_type,
397 required: get_attr(pairs, "required") == Some("true"),
398 default: get_attr(pairs, "default").map(|s| s.to_string()),
399 values: get_attr(pairs, "values")
400 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect()),
401 description: get_attr(pairs, "description").map(|s| s.to_string()),
402 generated,
403 },
404 )))
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 const SAMPLE_MANIFEST: &str = r#"
412<schema version="1.0">
413 <define tag="controller" description="HTTP handler">
414 <attr name="method" type="enum" values="GET,POST,PUT,DELETE,PATCH" required="true" />
415 <attr name="path" type="string" required="true" />
416 <attr name="auth" type="enum" values="required,optional,none" default="required" />
417 </define>
418
419 <define tag="react-hook" description="React hook with non-obvious behavior">
420 <attr name="error-handling" type="enum" values="throws,catches,propagates,silent" />
421 <attr name="preload" type="expression" />
422 </define>
423
424 <define tag="perf-critical" description="Performance-sensitive code with SLA">
425 <attr name="target" type="string" />
426 </define>
427
428 <audience name="product" description="Product engineers" />
429 <audience name="infra" description="Infrastructure team" />
430
431 <visibility name="public" description="Stable API" />
432 <visibility name="internal" description="May change" />
433 <visibility name="deprecated" description="Scheduled for removal" />
434</schema>
435"#;
436
437 #[test]
438 fn parses_complete_manifest() {
439 let raw = SAMPLE_MANIFEST;
441
442 let manifest = parse_manifest(raw).unwrap();
444
445 assert_eq!(manifest.version, "1.0");
447
448 let mut tag_names: Vec<&TagName> = manifest.tags.keys().collect();
449 tag_names.sort_by(|a, b| a.as_ref().cmp(b.as_ref()));
450 let tag_strs: Vec<&str> = tag_names.iter().map(|t| t.as_ref()).collect();
451 assert_eq!(tag_strs, vec!["controller", "perf-critical", "react-hook"]);
452
453 assert_eq!(
454 manifest.tags.get("controller").unwrap().description,
455 "HTTP handler"
456 );
457
458 assert_eq!(manifest.audiences["product"], "Product engineers");
459 assert_eq!(manifest.audiences["infra"], "Infrastructure team");
460
461 assert_eq!(manifest.visibilities["public"], "Stable API");
462 assert_eq!(manifest.visibilities["internal"], "May change");
463 assert_eq!(manifest.visibilities["deprecated"], "Scheduled for removal");
464 }
465
466 #[test]
467 fn parses_attr_types() {
468 let raw = SAMPLE_MANIFEST;
470
471 let manifest = parse_manifest(raw).unwrap();
473
474 let method = manifest
476 .tags
477 .get("controller")
478 .unwrap()
479 .attrs
480 .get("method")
481 .unwrap();
482 assert_eq!(method.attr_type, AttrType::Enum);
483 assert_eq!(
484 method.values.as_deref().unwrap(),
485 &["GET", "POST", "PUT", "DELETE", "PATCH"]
486 );
487 assert!(method.required);
488
489 let path = manifest
491 .tags
492 .get("controller")
493 .unwrap()
494 .attrs
495 .get("path")
496 .unwrap();
497 assert_eq!(path.attr_type, AttrType::String);
498 assert!(path.required);
499
500 let preload = manifest
502 .tags
503 .get("react-hook")
504 .unwrap()
505 .attrs
506 .get("preload")
507 .unwrap();
508 assert_eq!(preload.attr_type, AttrType::Expression);
509 assert!(!preload.required);
510 }
511
512 #[test]
513 fn validates_input() {
514 let missing_version = "<schema><define tag=\"x\" /></schema>";
516 let empty_input = "";
517
518 let err_missing = parse_manifest(missing_version).unwrap_err();
520 let err_empty = parse_manifest(empty_input).unwrap_err();
521
522 assert!(err_missing
524 .to_string()
525 .contains("missing or non-string 'version'"));
526 assert!(err_empty
527 .to_string()
528 .contains("missing or non-string 'version'"));
529
530 assert!(BUILTIN_ATTRS.contains(&"id"));
531 assert!(BUILTIN_ATTRS.contains(&"visibility"));
532 assert!(BUILTIN_ATTRS.contains(&"audience"));
533 assert!(BUILTIN_ATTRS.contains(&"owner"));
534 assert!(BUILTIN_ATTRS.contains(&"note"));
535 }
536
537 #[test]
538 fn parses_generated_flag() {
539 let raw = r#"
541<schema version="1.0">
542 <define tag="controller" description="HTTP handler">
543 <attr name="method" type="enum" values="GET,POST" required="true" />
544 <attr name="rationale" type="string" generated="false" />
545 </define>
546</schema>
547"#;
548
549 let manifest = parse_manifest(raw).unwrap();
551
552 let method = manifest
554 .tags
555 .get("controller")
556 .unwrap()
557 .attrs
558 .get("method")
559 .unwrap();
560 assert!(method.generated, "method should be generated by default");
561
562 let rationale = manifest
563 .tags
564 .get("controller")
565 .unwrap()
566 .attrs
567 .get("rationale")
568 .unwrap();
569 assert!(!rationale.generated, "rationale should be non-generated");
570 }
571
572 #[test]
573 fn parses_flat_schema_without_wrappers() {
574 let raw = r#"
576<schema version="1.0">
577 <define tag="route" description="HTTP route" />
578 <extractor name="express" run="aql extract express" globs="**/*.ts" />
579 <audience name="product" description="Product engineers" />
580 <visibility name="public" description="Stable API" />
581</schema>
582"#;
583
584 let manifest = parse_manifest(raw).unwrap();
586
587 assert!(manifest.tags.contains_key("route"), "should parse tag");
589 assert_eq!(manifest.extractors.len(), 1, "should parse extractor");
590 assert_eq!(manifest.extractors[0].name, "express", "extractor name");
591 assert_eq!(manifest.audiences["product"], "Product engineers");
592 assert_eq!(manifest.visibilities["public"], "Stable API");
593 }
594
595 #[test]
596 fn is_generated_attr_checks_builtins_and_schema() {
597 let raw = r#"
599<schema version="1.0">
600 <define tag="controller" description="HTTP handler">
601 <attr name="method" type="enum" values="GET,POST" required="true" />
602 <attr name="rationale" type="string" generated="false" />
603 </define>
604</schema>
605"#;
606 let manifest = parse_manifest(raw).unwrap();
607
608 assert!(
610 !manifest.is_generated_attr(&TagName::from("controller"), &AttrName::from("note")),
611 "note is a non-generated builtin"
612 );
613 assert!(
614 manifest.is_generated_attr(&TagName::from("controller"), &AttrName::from("method")),
615 "method is generated"
616 );
617 assert!(
618 !manifest.is_generated_attr(&TagName::from("controller"), &AttrName::from("rationale")),
619 "rationale is marked non-generated"
620 );
621 assert!(
622 manifest.is_generated_attr(&TagName::from("controller"), &AttrName::from("owner")),
623 "owner is a generated builtin"
624 );
625 assert!(
626 manifest.is_generated_attr(&TagName::from("unknown-tag"), &AttrName::from("anything")),
627 "unknown tag defaults to generated"
628 );
629 }
630}