Skip to main content

amql_engine/
init.rs

1//! Stack detection and schema generation for `aql init`.
2//!
3//! Scans the project directory for stack markers (package.json, go.mod,
4//! Cargo.toml) and generates a `.config/aql.schema` with appropriate
5//! tag definitions and extractor configurations.
6
7use crate::error::AqlError;
8use serde::Serialize;
9use std::path::Path;
10
11/// Detected project stack.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
13#[non_exhaustive]
14pub enum Stack {
15    /// Node.js with Express
16    Express,
17    /// Node.js with React
18    React,
19    /// Node.js (generic, with test framework)
20    Node,
21    /// Go (HTTP server)
22    Go,
23    /// Rust
24    Rust,
25}
26
27/// Result of stack detection.
28#[derive(Debug, Clone, Serialize)]
29pub struct DetectedStacks {
30    /// All detected stacks in the project.
31    pub stacks: Vec<Stack>,
32    /// Whether a test framework was detected.
33    pub has_tests: bool,
34}
35
36/// Detect project stacks from marker files in the given directory.
37///
38/// Reads package.json, go.mod, and Cargo.toml to determine which
39/// frameworks are in use.
40pub fn detect_stacks(root: &Path) -> DetectedStacks {
41    let mut stacks = Vec::new();
42    let mut has_tests = false;
43
44    // Check package.json
45    if let Ok(content) = std::fs::read_to_string(root.join("package.json")) {
46        if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
47            let all_deps = collect_npm_deps(&pkg);
48
49            if all_deps.contains(&"express") {
50                stacks.push(Stack::Express);
51            }
52            if all_deps.contains(&"react")
53                || all_deps.contains(&"react-dom")
54                || all_deps.contains(&"next")
55            {
56                stacks.push(Stack::React);
57            }
58            if stacks.is_empty() {
59                stacks.push(Stack::Node);
60            }
61            if all_deps.contains(&"jest")
62                || all_deps.contains(&"vitest")
63                || all_deps.contains(&"mocha")
64            {
65                has_tests = true;
66            }
67        }
68    }
69
70    // Check go.mod
71    if root.join("go.mod").exists() {
72        stacks.push(Stack::Go);
73    }
74
75    // Check Cargo.toml
76    if root.join("Cargo.toml").exists() {
77        stacks.push(Stack::Rust);
78    }
79
80    DetectedStacks { stacks, has_tests }
81}
82
83/// Generate a `.config/aql.schema` XML string for the detected stacks.
84pub fn generate_schema(detected: &DetectedStacks) -> String {
85    let mut lines = Vec::new();
86    lines.push(r#"<schema version="1.0">"#.to_string());
87
88    for stack in &detected.stacks {
89        match stack {
90            Stack::Express => {
91                lines.push(r#"  <define tag="route" description="HTTP route handler">"#.into());
92                lines.push(
93                    r#"    <attr name="method" type="enum" values="GET,POST,PUT,DELETE,PATCH" required />"#.into(),
94                );
95                lines.push(r#"    <attr name="path" type="string" required />"#.into());
96                lines.push(
97                    r#"    <attr name="auth" type="enum" values="required,optional,none" />"#
98                        .into(),
99                );
100                lines.push(r#"  </define>"#.into());
101                lines
102                    .push(r#"  <define tag="middleware" description="Express middleware">"#.into());
103                lines.push(
104                    r#"    <attr name="scope" type="enum" values="global,router,route" />"#.into(),
105                );
106                lines.push(r#"  </define>"#.into());
107                lines.push(
108                    r#"  <extractor name="express" run="aql extract express" globs="**/*.ts,**/*.js" />"#
109                        .into(),
110                );
111            }
112            Stack::React => {
113                lines.push(r#"  <define tag="component" description="React component">"#.into());
114                lines.push(r#"    <attr name="name" type="string" />"#.into());
115                lines.push(r#"  </define>"#.into());
116                lines.push(r#"  <define tag="hook" description="React hook">"#.into());
117                lines.push(r#"    <attr name="name" type="string" />"#.into());
118                lines.push(r#"    <attr name="custom" type="boolean" />"#.into());
119                lines.push(r#"  </define>"#.into());
120                lines.push(
121                    r#"  <extractor name="react" run="aql extract react" globs="**/*.tsx,**/*.jsx" />"#
122                        .into(),
123                );
124            }
125            Stack::Node => {
126                // Generic Node — no special tags beyond test
127            }
128            Stack::Go => {
129                lines
130                    .push(r#"  <define tag="handler" description="HTTP handler function">"#.into());
131                lines.push(
132                    r#"    <attr name="method" type="enum" values="GET,POST,PUT,DELETE,PATCH" />"#
133                        .into(),
134                );
135                lines.push(r#"    <attr name="path" type="string" />"#.into());
136                lines.push(r#"  </define>"#.into());
137                lines.push(
138                    r#"  <extractor name="go_http" run="aql extract go_http" globs="**/*.go" />"#
139                        .into(),
140                );
141            }
142            Stack::Rust => {
143                // Rust uses code resolvers, no special tags needed by default
144            }
145        }
146    }
147
148    if detected.has_tests {
149        lines.push(r#"  <define tag="describe" description="Test suite">"#.into());
150        lines.push(r#"    <attr name="name" type="string" />"#.into());
151        lines.push(r#"  </define>"#.into());
152        lines.push(r#"  <define tag="test" description="Test case">"#.into());
153        lines.push(r#"    <attr name="name" type="string" />"#.into());
154        lines.push(r#"    <attr name="skip" type="boolean" />"#.into());
155        lines.push(r#"  </define>"#.into());
156        lines.push(
157            r#"  <extractor name="test" run="aql extract test" globs="**/*.test.ts,**/*.spec.ts,**/*.test.js,**/*.spec.js" />"#
158                .into(),
159        );
160    }
161
162    lines.push("</schema>".to_string());
163    lines.join("\n")
164}
165
166/// Run `aql init`: detect stacks and write `.config/aql.schema`.
167///
168/// Returns `Err` if the schema file already exists or the directory
169/// cannot be created.
170#[cfg(feature = "fs")]
171pub fn init_project(root: &Path) -> Result<String, AqlError> {
172    let config_dir = root.join(".config");
173    let schema_path = config_dir.join("aql.schema");
174
175    if schema_path.exists() {
176        return Err(AqlError::new(format!(
177            ".config/aql.schema already exists at {}",
178            schema_path.display()
179        )));
180    }
181
182    let detected = detect_stacks(root);
183    let schema = generate_schema(&detected);
184
185    std::fs::create_dir_all(&config_dir).map_err(|e| {
186        format!(
187            "Failed to create .config directory at {}: {e}",
188            config_dir.display()
189        )
190    })?;
191    std::fs::write(&schema_path, &schema)
192        .map_err(|e| format!("Failed to write schema at {}: {e}", schema_path.display()))?;
193
194    Ok(schema)
195}
196
197fn collect_npm_deps(pkg: &serde_json::Value) -> Vec<&str> {
198    let mut deps = Vec::new();
199    for key in &["dependencies", "devDependencies", "peerDependencies"] {
200        if let Some(obj) = pkg.get(key).and_then(|v| v.as_object()) {
201            for name in obj.keys() {
202                deps.push(name.as_str());
203            }
204        }
205    }
206    deps
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn detect_stacks_empty_dir() {
215        // Arrange and Act
216        let detected = detect_stacks(Path::new("/nonexistent"));
217
218        // Assert
219        assert!(detected.stacks.is_empty(), "no stacks in nonexistent dir");
220        assert!(!detected.has_tests, "no tests in nonexistent dir");
221    }
222
223    #[test]
224    fn generate_schema_express_with_tests() {
225        // Arrange
226        let detected = DetectedStacks {
227            stacks: vec![Stack::Express],
228            has_tests: true,
229        };
230
231        // Act
232        let schema = generate_schema(&detected);
233
234        // Assert
235        assert!(
236            schema.contains(r#"<define tag="route""#),
237            "should define route tag"
238        );
239        assert!(
240            schema.contains(r#"<extractor name="express""#),
241            "should configure express extractor"
242        );
243        assert!(
244            schema.contains(r#"<define tag="test""#),
245            "should define test tag when has_tests"
246        );
247        assert!(
248            schema.contains(r#"<extractor name="test""#),
249            "should configure test extractor when has_tests"
250        );
251    }
252
253    #[test]
254    fn generate_schema_react() {
255        // Arrange
256        let detected = DetectedStacks {
257            stacks: vec![Stack::React],
258            has_tests: false,
259        };
260
261        // Act
262        let schema = generate_schema(&detected);
263
264        // Assert
265        assert!(
266            schema.contains(r#"<define tag="component""#),
267            "should define component tag"
268        );
269        assert!(
270            schema.contains(r#"<define tag="hook""#),
271            "should define hook tag"
272        );
273        assert!(
274            schema.contains(r#"<extractor name="react""#),
275            "should configure react extractor"
276        );
277    }
278
279    #[test]
280    fn generate_schema_go() {
281        // Arrange
282        let detected = DetectedStacks {
283            stacks: vec![Stack::Go],
284            has_tests: false,
285        };
286
287        // Act
288        let schema = generate_schema(&detected);
289
290        // Assert
291        assert!(
292            schema.contains(r#"<define tag="handler""#),
293            "should define handler tag"
294        );
295        assert!(
296            schema.contains(r#"<extractor name="go_http""#),
297            "should configure go_http extractor"
298        );
299    }
300}