1use 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#[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 #[serde(default = "default_version")]
43 pub version: String,
44
45 #[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 #[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 #[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 #[serde(default)]
77 pub extractors: Vec<DeclareExtractor>,
78}
79
80#[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 #[serde(default)]
89 pub description: String,
90
91 #[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 #[serde(default, rename = "requireBind")]
105 pub require_bind: bool,
106}
107
108#[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 #[serde(rename = "type")]
117 pub attr_type: AttrType,
118
119 #[serde(default)]
121 pub required: bool,
122
123 #[serde(default)]
125 pub default: Option<String>,
126
127 #[serde(default)]
129 pub values: Option<Vec<String>>,
130
131 #[serde(default)]
133 pub description: Option<String>,
134
135 #[serde(default = "default_true")]
137 pub generated: bool,
138}
139
140#[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 pub name: String,
150
151 #[serde(default)]
153 pub run: Option<String>,
154
155 #[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#[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 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 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 let config: DeclareConfig = serde_json::from_str(json).unwrap();
268 let manifest = declare_config(config).unwrap();
269
270 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 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 let manifest = declare_config(config).unwrap();
304
305 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 let json = r#"{
314 "tags": {
315 "route": {
316 "attrs": {
317 "method": { "type": "enum" }
318 }
319 }
320 }
321 }"#;
322
323 let config: DeclareConfig = serde_json::from_str(json).unwrap();
325 let result = declare_config(config);
326
327 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 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 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!(result.is_err(), "should reject empty tag name");
359 }
360
361 #[test]
362 fn subprocess_extractor_with_run() {
363 let json = r#"{
365 "extractors": [
366 { "name": "flask", "run": "python3 extract_flask.py", "globs": ["**/*.py"] }
367 ]
368 }"#;
369
370 let config: DeclareConfig = serde_json::from_str(json).unwrap();
372 let manifest = declare_config(config).unwrap();
373
374 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 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 let config: DeclareConfig = serde_json::from_str(json).unwrap();
402 let manifest = declare_config(config).unwrap();
403
404 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}