Skip to main content

amql_engine/
manifest.rs

1//! Parse and validate `.config/aql.schema` schema manifests.
2
3use 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
10/// Current AQL schema version.
11///
12/// Manifests from any version are currently accepted. When the schema changes
13/// incompatibly, this becomes the enforcement point — `check_schema_version`
14/// can reject manifests below the minimum supported version.
15pub const SCHEMA_VERSION: &str = "1.0";
16
17/// Built-in attributes available on all tags without manifest definition.
18pub const BUILTIN_ATTRS: &[&str] = &["id", "name", "visibility", "audience", "owner", "note"];
19
20/// Built-in attributes that are never generated (human/agent-authored only).
21pub const NON_GENERATED_BUILTINS: &[&str] = &["note"];
22
23/// Configuration for a single extractor, parsed from the manifest.
24#[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    /// Human-readable name for this extractor.
31    pub name: String,
32    /// Shell command to execute (run via `sh -c`).
33    /// Empty string means use a built-in extractor if available.
34    pub run: String,
35    /// Glob patterns for files this extractor covers.
36    pub globs: Vec<String>,
37}
38
39/// A parsed AQL manifest from `.config/aql.schema`.
40#[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    /// External extractors that discover annotations from framework conventions.
69    pub extractors: Vec<ExtractorConfig>,
70}
71
72/// Definition of an annotation tag.
73#[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    /// When true, validator reports an error for annotations of this tag without a `bind` attribute.
90    pub require_bind: bool,
91}
92
93/// Definition of a tag attribute.
94#[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    /// Whether this attribute is deterministically generated from source code.
107    /// Non-generated attributes (e.g. free-text descriptions) are preserved
108    /// across regeneration but never emitted by `aql generate`.
109    pub generated: bool,
110}
111
112/// Attribute type enumeration.
113#[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    /// Check whether an attribute on a given tag is generated (deterministic).
132    /// Returns `false` for non-generated builtins (`note`) and attrs with `generated="false"`.
133    /// Returns `true` for all other builtins and any attr not explicitly marked non-generated.
134    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        // Unknown attr or builtin — assume generated
144        true
145    }
146}
147
148/// Walk up from `start_dir` looking for `.config/aql.schema`.
149/// Returns the project root directory (parent of `.config/`), or `None`.
150#[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/// Load and parse a manifest from `project_root/.config/aql.schema`.
166#[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
180/// Look up `key` in pre-collected attr pairs.
181fn 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
188/// XML parser state machine for manifest parsing.
189enum 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/// Parse a manifest from a raw XML string.
201#[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
357/// Validate a schema version string.
358///
359/// Currently permissive — any non-empty version is accepted.
360/// Future: enforce minimum supported version when the schema changes incompatibly.
361pub(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        // Arrange
440        let raw = SAMPLE_MANIFEST;
441
442        // Act
443        let manifest = parse_manifest(raw).unwrap();
444
445        // Assert
446        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        // Arrange
469        let raw = SAMPLE_MANIFEST;
470
471        // Act
472        let manifest = parse_manifest(raw).unwrap();
473
474        // Assert — enum type
475        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        // Assert — string type
490        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        // Assert — expression type
501        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        // Arrange
515        let missing_version = "<schema><define tag=\"x\" /></schema>";
516        let empty_input = "";
517
518        // Act
519        let err_missing = parse_manifest(missing_version).unwrap_err();
520        let err_empty = parse_manifest(empty_input).unwrap_err();
521
522        // Assert
523        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        // Arrange
540        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        // Act
550        let manifest = parse_manifest(raw).unwrap();
551
552        // Assert
553        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        // Arrange
575        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        // Act
585        let manifest = parse_manifest(raw).unwrap();
586
587        // Assert
588        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        // Arrange
598        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        // Act and Assert
609        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}