Skip to main content

amql_engine/
declare.rs

1//! Type-safe programmatic config declaration, inspired by Babel's `declare()`.
2//!
3//! Provides a structured alternative to XML `.config/aql.schema` manifests.
4//! `declare_config` validates a `DeclareConfig` and produces a `Manifest`.
5
6use crate::error::AqlError;
7use crate::manifest::{
8    check_schema_version, AttrDefinition, AttrType, ExtractorConfig, Manifest, TagDefinition,
9    BUILTIN_ATTRS,
10};
11use crate::types::{AttrName, TagName};
12use rustc_hash::FxHashMap;
13use serde::{Deserialize, Serialize};
14
15/// User-facing config for `declare()`. All fields have sensible defaults.
16///
17/// This is the programmatic equivalent of `.config/aql.schema`:
18/// ```ignore
19/// declare({
20///   version: "1.0",
21///   tags: {
22///     route: {
23///       description: "HTTP route handler",
24///       attrs: {
25///         method: { type: "enum", values: ["GET", "POST"], required: true },
26///         path:   { type: "string", required: true },
27///       },
28///     },
29///   },
30///   extractors: [
31///     { name: "express", globs: ["src/**/*.ts"] },
32///   ],
33/// })
34/// ```
35#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
36#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
37#[cfg_attr(feature = "ts", ts(export))]
38#[cfg_attr(feature = "flow", flow(export))]
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct DeclareConfig {
41    /// Schema version. Defaults to `"1.0"`.
42    #[serde(default = "default_version")]
43    pub version: String,
44
45    /// Tag definitions keyed by tag name.
46    #[serde(default)]
47    #[cfg_attr(
48        feature = "ts",
49        ts(as = "std::collections::HashMap<String, DeclareTag>")
50    )]
51    #[cfg_attr(
52        feature = "flow",
53        flow(as = "std::collections::HashMap<String, DeclareTag>")
54    )]
55    pub tags: FxHashMap<String, DeclareTag>,
56
57    /// Audience labels (e.g. `{ "product": "Product engineers" }`).
58    #[serde(default)]
59    #[cfg_attr(feature = "ts", ts(as = "std::collections::HashMap<String, String>"))]
60    #[cfg_attr(
61        feature = "flow",
62        flow(as = "std::collections::HashMap<String, String>")
63    )]
64    pub audiences: FxHashMap<String, String>,
65
66    /// Visibility labels (e.g. `{ "public": "Stable API" }`).
67    #[serde(default)]
68    #[cfg_attr(feature = "ts", ts(as = "std::collections::HashMap<String, String>"))]
69    #[cfg_attr(
70        feature = "flow",
71        flow(as = "std::collections::HashMap<String, String>")
72    )]
73    pub visibilities: FxHashMap<String, String>,
74
75    /// Extractors to enable.
76    #[serde(default)]
77    pub extractors: Vec<DeclareExtractor>,
78}
79
80/// Tag definition within a `DeclareConfig`.
81#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
82#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
83#[cfg_attr(feature = "ts", ts(export))]
84#[cfg_attr(feature = "flow", flow(export))]
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct DeclareTag {
87    /// Human-readable description.
88    #[serde(default)]
89    pub description: String,
90
91    /// Attribute definitions keyed by attribute name.
92    #[serde(default)]
93    #[cfg_attr(
94        feature = "ts",
95        ts(as = "std::collections::HashMap<String, DeclareAttr>")
96    )]
97    #[cfg_attr(
98        feature = "flow",
99        flow(as = "std::collections::HashMap<String, DeclareAttr>")
100    )]
101    pub attrs: FxHashMap<String, DeclareAttr>,
102
103    /// When true, annotations of this tag must have a `bind` attribute.
104    #[serde(default, rename = "requireBind")]
105    pub require_bind: bool,
106}
107
108/// Attribute definition within a `DeclareTag`.
109#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
110#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
111#[cfg_attr(feature = "ts", ts(export))]
112#[cfg_attr(feature = "flow", flow(export))]
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct DeclareAttr {
115    /// Attribute type.
116    #[serde(rename = "type")]
117    pub attr_type: AttrType,
118
119    /// Whether this attribute is required on every annotation of the parent tag.
120    #[serde(default)]
121    pub required: bool,
122
123    /// Default value when omitted.
124    #[serde(default)]
125    pub default: Option<String>,
126
127    /// Allowed values (for `enum` type).
128    #[serde(default)]
129    pub values: Option<Vec<String>>,
130
131    /// Human-readable description.
132    #[serde(default)]
133    pub description: Option<String>,
134
135    /// Whether this attribute is deterministically generated from source code.
136    #[serde(default = "default_true")]
137    pub generated: bool,
138}
139
140/// Extractor definition within a `DeclareConfig`.
141#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
142#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
143#[cfg_attr(feature = "ts", ts(export))]
144#[cfg_attr(feature = "flow", flow(export))]
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct DeclareExtractor {
147    /// Extractor name. Built-in names: `express`, `test`, `react`,
148    /// `go-http`, `go-structure`, `go_test`, `rust-structure`, `ts-structure`.
149    pub name: String,
150
151    /// Shell command for subprocess extractors. Omit for built-in extractors.
152    #[serde(default)]
153    pub run: Option<String>,
154
155    /// Glob patterns for files this extractor covers.
156    #[serde(default)]
157    pub globs: Vec<String>,
158}
159
160fn default_version() -> String {
161    "1.0".to_string()
162}
163
164fn default_true() -> bool {
165    true
166}
167
168/// Validate a `DeclareConfig` and produce a `Manifest`.
169///
170/// This is the programmatic equivalent of `parse_manifest()` for XML configs.
171/// Returns an error if the config contains invalid attribute types or
172/// enum attributes without values.
173#[must_use = "declaring a config is useless without using the result"]
174pub fn declare_config(config: DeclareConfig) -> Result<Manifest, AqlError> {
175    check_schema_version(&config.version)?;
176
177    let mut tags = FxHashMap::default();
178
179    for (tag_name, tag_def) in config.tags {
180        if tag_name.is_empty() {
181            return Err("Tag name cannot be empty".into());
182        }
183        if BUILTIN_ATTRS.contains(&tag_name.as_str()) {
184            return Err(format!("'{tag_name}' is a reserved built-in attribute name").into());
185        }
186
187        let mut attrs = FxHashMap::default();
188        for (attr_name, attr_def) in tag_def.attrs {
189            if attr_name.is_empty() {
190                return Err(format!("Attribute name cannot be empty in tag '{tag_name}'").into());
191            }
192
193            // Enum type must have values
194            if attr_def.attr_type == AttrType::Enum && attr_def.values.is_none() {
195                return Err(format!(
196                    "Attribute '{attr_name}' on tag '{tag_name}' has type 'enum' but no values"
197                )
198                .into());
199            }
200
201            attrs.insert(
202                AttrName::from(attr_name),
203                AttrDefinition {
204                    attr_type: attr_def.attr_type,
205                    required: attr_def.required,
206                    default: attr_def.default,
207                    values: attr_def.values,
208                    description: attr_def.description,
209                    generated: attr_def.generated,
210                },
211            );
212        }
213
214        tags.insert(
215            TagName::from(tag_name),
216            TagDefinition {
217                description: tag_def.description,
218                attrs,
219                require_bind: tag_def.require_bind,
220            },
221        );
222    }
223
224    let extractors = config
225        .extractors
226        .into_iter()
227        .map(|e| ExtractorConfig {
228            name: e.name,
229            run: e.run.unwrap_or_default(),
230            globs: e.globs,
231        })
232        .collect();
233
234    Ok(Manifest {
235        version: config.version,
236        tags,
237        audiences: config.audiences,
238        visibilities: config.visibilities,
239        extractors,
240    })
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn declares_simple_config() {
249        // Arrange
250        let json = r#"{
251            "tags": {
252                "route": {
253                    "description": "HTTP route",
254                    "attrs": {
255                        "method": { "type": "enum", "values": ["GET", "POST"], "required": true },
256                        "path": { "type": "string", "required": true }
257                    },
258                    "requireBind": true
259                }
260            },
261            "extractors": [
262                { "name": "express", "globs": ["src/**/*.ts"] }
263            ]
264        }"#;
265
266        // Act
267        let config: DeclareConfig = serde_json::from_str(json).unwrap();
268        let manifest = declare_config(config).unwrap();
269
270        // Assert
271        assert_eq!(manifest.version, "1.0", "should default version to 1.0");
272        assert!(manifest.tags.contains_key("route"), "should have route tag");
273        let route = manifest.tags.get("route").unwrap();
274        assert!(route.require_bind, "route should require bind");
275        assert_eq!(
276            route.attrs.get("method").unwrap().attr_type,
277            AttrType::Enum,
278            "method should be enum"
279        );
280        assert!(
281            route.attrs.get("method").unwrap().required,
282            "method should be required"
283        );
284        assert_eq!(manifest.extractors.len(), 1, "should have 1 extractor");
285        assert_eq!(
286            manifest.extractors[0].name, "express",
287            "extractor should be express"
288        );
289    }
290
291    #[test]
292    fn declares_with_defaults() {
293        // Arrange
294        let config = DeclareConfig {
295            version: default_version(),
296            tags: FxHashMap::default(),
297            audiences: FxHashMap::default(),
298            visibilities: FxHashMap::default(),
299            extractors: Vec::new(),
300        };
301
302        // Act
303        let manifest = declare_config(config).unwrap();
304
305        // Assert
306        assert_eq!(manifest.version, "1.0", "should use default version");
307        assert!(manifest.tags.is_empty(), "should have no tags");
308    }
309
310    #[test]
311    fn rejects_enum_without_values() {
312        // Arrange
313        let json = r#"{
314            "tags": {
315                "route": {
316                    "attrs": {
317                        "method": { "type": "enum" }
318                    }
319                }
320            }
321        }"#;
322
323        // Act
324        let config: DeclareConfig = serde_json::from_str(json).unwrap();
325        let result = declare_config(config);
326
327        // Assert
328        assert!(result.is_err(), "should reject enum without values");
329        assert!(
330            result.unwrap_err().to_string().contains("no values"),
331            "error should mention missing values"
332        );
333    }
334
335    #[test]
336    fn rejects_empty_tag_name() {
337        // Arrange
338        let mut tags = FxHashMap::default();
339        tags.insert(
340            String::new(),
341            DeclareTag {
342                description: String::new(),
343                attrs: FxHashMap::default(),
344                require_bind: false,
345            },
346        );
347
348        // Act
349        let result = declare_config(DeclareConfig {
350            version: default_version(),
351            tags,
352            audiences: FxHashMap::default(),
353            visibilities: FxHashMap::default(),
354            extractors: Vec::new(),
355        });
356
357        // Assert
358        assert!(result.is_err(), "should reject empty tag name");
359    }
360
361    #[test]
362    fn subprocess_extractor_with_run() {
363        // Arrange
364        let json = r#"{
365            "extractors": [
366                { "name": "flask", "run": "python3 extract_flask.py", "globs": ["**/*.py"] }
367            ]
368        }"#;
369
370        // Act
371        let config: DeclareConfig = serde_json::from_str(json).unwrap();
372        let manifest = declare_config(config).unwrap();
373
374        // Assert
375        assert_eq!(manifest.extractors[0].name, "flask", "extractor name");
376        assert_eq!(
377            manifest.extractors[0].run, "python3 extract_flask.py",
378            "extractor run command"
379        );
380    }
381
382    #[test]
383    fn roundtrip_json() {
384        // Arrange — config as JSON
385        let json = r#"{
386            "version": "2.0",
387            "tags": {
388                "component": {
389                    "description": "React component",
390                    "attrs": {
391                        "memo": { "type": "boolean" },
392                        "displayName": { "type": "string", "description": "Component display name" }
393                    }
394                }
395            },
396            "audiences": { "frontend": "Frontend engineers" },
397            "visibilities": { "stable": "Stable API" }
398        }"#;
399
400        // Act
401        let config: DeclareConfig = serde_json::from_str(json).unwrap();
402        let manifest = declare_config(config).unwrap();
403
404        // Assert
405        assert_eq!(manifest.version, "2.0", "should preserve version");
406        assert_eq!(
407            manifest.audiences.get("frontend").unwrap(),
408            "Frontend engineers",
409            "should preserve audiences"
410        );
411        let component = manifest.tags.get("component").unwrap();
412        assert_eq!(
413            component.attrs.get("memo").unwrap().attr_type,
414            AttrType::Boolean,
415            "memo should be boolean"
416        );
417    }
418}