Skip to main content

zlayer_builder/templates/
detect.rs

1//! Runtime auto-detection
2//!
3//! This module provides functionality to automatically detect the runtime
4//! of a project based on the files present in the project directory.
5
6use std::path::Path;
7
8use super::{Runtime, WasmTargetHint};
9
10/// Detect the runtime from files in the given directory.
11///
12/// This function examines the project directory for characteristic files
13/// that indicate which runtime the project uses. Detection order matters:
14/// more specific runtimes (like Bun, Deno) are checked before generic ones (Node.js).
15///
16/// # Examples
17///
18/// ```no_run
19/// use zlayer_builder::templates::detect_runtime;
20///
21/// let runtime = detect_runtime("/path/to/project");
22/// if let Some(rt) = runtime {
23///     println!("Detected runtime: {:?}", rt);
24/// }
25/// ```
26pub fn detect_runtime(context_path: impl AsRef<Path>) -> Option<Runtime> {
27    let path = context_path.as_ref();
28
29    // WASM takes priority over language runtimes because a `Cargo.toml` with a
30    // `wasm32-wasip*` target (or a `package.json` pulling in `jco`) still
31    // looks like a regular Rust / Node project on the surface. We want the
32    // explicit WASM indicators to win.
33    if let Some(hint) = detect_wasm_hint(path) {
34        return Some(Runtime::Wasm(hint));
35    }
36
37    // Check for Node.js ecosystem first (most common)
38    if path.join("package.json").exists() {
39        // Check for Bun - bun.lockb is definitive
40        if path.join("bun.lockb").exists() {
41            return Some(Runtime::Bun);
42        }
43
44        // Check for Deno - deno.json or deno.jsonc alongside package.json
45        if path.join("deno.json").exists() || path.join("deno.jsonc").exists() {
46            return Some(Runtime::Deno);
47        }
48
49        // Default to Node.js 20 for generic package.json projects
50        return Some(Runtime::Node20);
51    }
52
53    // Check for Deno without package.json
54    if path.join("deno.json").exists()
55        || path.join("deno.jsonc").exists()
56        || path.join("deno.lock").exists()
57    {
58        return Some(Runtime::Deno);
59    }
60
61    // Check for Rust
62    if path.join("Cargo.toml").exists() {
63        return Some(Runtime::Rust);
64    }
65
66    // Check for Python (check multiple indicators)
67    if path.join("pyproject.toml").exists()
68        || path.join("requirements.txt").exists()
69        || path.join("setup.py").exists()
70        || path.join("Pipfile").exists()
71        || path.join("poetry.lock").exists()
72    {
73        return Some(Runtime::Python312);
74    }
75
76    // Check for Go
77    if path.join("go.mod").exists() {
78        return Some(Runtime::Go);
79    }
80
81    None
82}
83
84/// Detect runtime with version hints from project files.
85///
86/// This extends basic detection by attempting to read version specifications
87/// from configuration files to choose the most appropriate version.
88pub fn detect_runtime_with_version(context_path: impl AsRef<Path>) -> Option<Runtime> {
89    let path = context_path.as_ref();
90
91    // First do basic detection
92    let base_runtime = detect_runtime(path)?;
93
94    // Try to refine version based on configuration files
95    match base_runtime {
96        Runtime::Node20 | Runtime::Node22 => {
97            // Try to read .nvmrc or .node-version
98            if let Some(version) = read_node_version(path) {
99                if version.starts_with("22") || version.starts_with("v22") {
100                    return Some(Runtime::Node22);
101                }
102                if version.starts_with("20") || version.starts_with("v20") {
103                    return Some(Runtime::Node20);
104                }
105            }
106
107            // Try to read engines.node from package.json
108            if let Some(version) = read_package_node_version(path) {
109                if version.contains("22") {
110                    return Some(Runtime::Node22);
111                }
112            }
113
114            Some(Runtime::Node20)
115        }
116        Runtime::Python312 | Runtime::Python313 => {
117            // Try to read python version from pyproject.toml or .python-version
118            if let Some(version) = read_python_version(path) {
119                if version.starts_with("3.13") {
120                    return Some(Runtime::Python313);
121                }
122            }
123
124            Some(Runtime::Python312)
125        }
126        other => Some(other),
127    }
128}
129
130/// Detect whether the project at `path` targets `WebAssembly`, and if so
131/// whether it builds a raw module or a WASI component.
132///
133/// Detection priority (first match wins):
134/// 1. `cargo-component.toml` → `Component` (Rust component tooling).
135/// 2. `Cargo.toml` with `[package.metadata.component]` → `Component`.
136/// 3. `componentize-py.config` → `Component` (Python component tooling).
137/// 4. `package.json` with `jco` / `@bytecodealliance/jco` in any dep
138///    section → `Component` (JS component tooling).
139/// 5. `.cargo/config.toml` selecting a `wasm32-wasip*` target → `Module`.
140///
141/// Returns `None` when the project shows no WASM indicators at all so the
142/// caller can continue with regular language runtime detection.
143fn detect_wasm_hint(path: &Path) -> Option<WasmTargetHint> {
144    // 1. cargo-component.toml is a definitive Component signal.
145    if path.join("cargo-component.toml").exists() {
146        return Some(WasmTargetHint::Component);
147    }
148
149    // 2. Cargo.toml with [package.metadata.component] → Component.
150    //    (Also: if it only has a wasip* target without component metadata,
151    //    we'll fall through to step 5 and classify as Module.)
152    let cargo_toml = path.join("Cargo.toml");
153    let cargo_has_component_metadata = if cargo_toml.exists() {
154        cargo_toml_has_component_metadata(&cargo_toml)
155    } else {
156        false
157    };
158    if cargo_has_component_metadata {
159        return Some(WasmTargetHint::Component);
160    }
161
162    // 3. componentize-py.config → Component.
163    if path.join("componentize-py.config").exists() {
164        return Some(WasmTargetHint::Component);
165    }
166
167    // 4. package.json with jco / @bytecodealliance/jco → Component.
168    if path.join("package.json").exists() && package_json_uses_jco(&path.join("package.json")) {
169        return Some(WasmTargetHint::Component);
170    }
171
172    // 5. Cargo.toml + .cargo/config.toml with wasm32-wasip1 / wasip2 target → Module.
173    if cargo_toml.exists() && cargo_config_targets_wasip(path) {
174        return Some(WasmTargetHint::Module);
175    }
176
177    None
178}
179
180/// Return true when `Cargo.toml` declares `[package.metadata.component]`.
181///
182/// Uses a line-scan (no extra TOML parser dependency) which is sufficient for
183/// the documented `cargo-component` layout.
184fn cargo_toml_has_component_metadata(cargo_toml: &Path) -> bool {
185    let Ok(content) = std::fs::read_to_string(cargo_toml) else {
186        return false;
187    };
188    content.lines().any(|line| {
189        let trimmed = line.trim();
190        trimmed == "[package.metadata.component]"
191            || trimmed.starts_with("[package.metadata.component.")
192    })
193}
194
195/// `package.json` dependency sections scanned for the `jco` tool.
196const JCO_DEP_SECTIONS: &[&str] = &[
197    "dependencies",
198    "devDependencies",
199    "peerDependencies",
200    "optionalDependencies",
201];
202
203/// Return true when `package.json` references `jco` / `@bytecodealliance/jco`
204/// in any of its dependency sections.
205fn package_json_uses_jco(package_json: &Path) -> bool {
206    let Ok(content) = std::fs::read_to_string(package_json) else {
207        return false;
208    };
209    let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
210        return false;
211    };
212
213    for section in JCO_DEP_SECTIONS {
214        if let Some(obj) = json.get(section).and_then(|v| v.as_object()) {
215            if obj.contains_key("jco") || obj.contains_key("@bytecodealliance/jco") {
216                return true;
217            }
218        }
219    }
220    false
221}
222
223/// Return true when `.cargo/config.toml` selects a `wasm32-wasip1`/`wasip2`
224/// build target (line-scan, no TOML dependency).
225fn cargo_config_targets_wasip(path: &Path) -> bool {
226    let config = path.join(".cargo").join("config.toml");
227    let Ok(content) = std::fs::read_to_string(&config) else {
228        return false;
229    };
230    content.lines().any(|line| {
231        let trimmed = line.trim();
232        trimmed.contains("wasm32-wasip1") || trimmed.contains("wasm32-wasip2")
233    })
234}
235
236/// Read Node.js version from .nvmrc or .node-version files
237fn read_node_version(path: &Path) -> Option<String> {
238    for filename in &[".nvmrc", ".node-version"] {
239        let version_file = path.join(filename);
240        if version_file.exists() {
241            if let Ok(content) = std::fs::read_to_string(&version_file) {
242                let version = content.trim().to_string();
243                if !version.is_empty() {
244                    return Some(version);
245                }
246            }
247        }
248    }
249    None
250}
251
252/// Read Node.js version from package.json engines field
253fn read_package_node_version(path: &Path) -> Option<String> {
254    let package_json = path.join("package.json");
255    if package_json.exists() {
256        if let Ok(content) = std::fs::read_to_string(&package_json) {
257            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
258                if let Some(engines) = json.get("engines") {
259                    if let Some(node) = engines.get("node") {
260                        if let Some(version) = node.as_str() {
261                            return Some(version.to_string());
262                        }
263                    }
264                }
265            }
266        }
267    }
268    None
269}
270
271/// Read Python version from .python-version or pyproject.toml
272fn read_python_version(path: &Path) -> Option<String> {
273    // Check .python-version first (used by pyenv)
274    let python_version = path.join(".python-version");
275    if python_version.exists() {
276        if let Ok(content) = std::fs::read_to_string(&python_version) {
277            let version = content.trim().to_string();
278            if !version.is_empty() {
279                return Some(version);
280            }
281        }
282    }
283
284    // Check pyproject.toml for requires-python
285    let pyproject = path.join("pyproject.toml");
286    if pyproject.exists() {
287        if let Ok(content) = std::fs::read_to_string(&pyproject) {
288            // Simple parsing - look for requires-python
289            for line in content.lines() {
290                let line = line.trim();
291                if line.starts_with("requires-python") {
292                    if let Some(version) = line.split('=').nth(1) {
293                        let version = version.trim().trim_matches('"').trim_matches('\'');
294                        return Some(version.to_string());
295                    }
296                }
297            }
298        }
299    }
300
301    None
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use std::fs;
308    use tempfile::TempDir;
309
310    fn create_temp_dir() -> TempDir {
311        TempDir::new().expect("Failed to create temp directory")
312    }
313
314    #[test]
315    fn test_detect_nodejs_project() {
316        let dir = create_temp_dir();
317        fs::write(dir.path().join("package.json"), "{}").unwrap();
318
319        let runtime = detect_runtime(dir.path());
320        assert_eq!(runtime, Some(Runtime::Node20));
321    }
322
323    #[test]
324    fn test_detect_bun_project() {
325        let dir = create_temp_dir();
326        fs::write(dir.path().join("package.json"), "{}").unwrap();
327        fs::write(dir.path().join("bun.lockb"), "").unwrap();
328
329        let runtime = detect_runtime(dir.path());
330        assert_eq!(runtime, Some(Runtime::Bun));
331    }
332
333    #[test]
334    fn test_detect_deno_project() {
335        let dir = create_temp_dir();
336        fs::write(dir.path().join("deno.json"), "{}").unwrap();
337
338        let runtime = detect_runtime(dir.path());
339        assert_eq!(runtime, Some(Runtime::Deno));
340    }
341
342    #[test]
343    fn test_detect_deno_with_package_json() {
344        let dir = create_temp_dir();
345        fs::write(dir.path().join("package.json"), "{}").unwrap();
346        fs::write(dir.path().join("deno.json"), "{}").unwrap();
347
348        let runtime = detect_runtime(dir.path());
349        assert_eq!(runtime, Some(Runtime::Deno));
350    }
351
352    #[test]
353    fn test_detect_rust_project() {
354        let dir = create_temp_dir();
355        fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
356
357        let runtime = detect_runtime(dir.path());
358        assert_eq!(runtime, Some(Runtime::Rust));
359    }
360
361    #[test]
362    fn test_detect_python_requirements() {
363        let dir = create_temp_dir();
364        fs::write(dir.path().join("requirements.txt"), "flask==2.0").unwrap();
365
366        let runtime = detect_runtime(dir.path());
367        assert_eq!(runtime, Some(Runtime::Python312));
368    }
369
370    #[test]
371    fn test_detect_python_pyproject() {
372        let dir = create_temp_dir();
373        fs::write(dir.path().join("pyproject.toml"), "[project]").unwrap();
374
375        let runtime = detect_runtime(dir.path());
376        assert_eq!(runtime, Some(Runtime::Python312));
377    }
378
379    #[test]
380    fn test_detect_go_project() {
381        let dir = create_temp_dir();
382        fs::write(dir.path().join("go.mod"), "module example.com/app").unwrap();
383
384        let runtime = detect_runtime(dir.path());
385        assert_eq!(runtime, Some(Runtime::Go));
386    }
387
388    #[test]
389    fn test_detect_no_runtime() {
390        let dir = create_temp_dir();
391        // Empty directory
392
393        let runtime = detect_runtime(dir.path());
394        assert_eq!(runtime, None);
395    }
396
397    #[test]
398    fn test_detect_node22_from_nvmrc() {
399        let dir = create_temp_dir();
400        fs::write(dir.path().join("package.json"), "{}").unwrap();
401        fs::write(dir.path().join(".nvmrc"), "22.0.0").unwrap();
402
403        let runtime = detect_runtime_with_version(dir.path());
404        assert_eq!(runtime, Some(Runtime::Node22));
405    }
406
407    #[test]
408    fn test_detect_node22_from_package_engines() {
409        let dir = create_temp_dir();
410        let package_json = r#"{"engines": {"node": ">=22.0.0"}}"#;
411        fs::write(dir.path().join("package.json"), package_json).unwrap();
412
413        let runtime = detect_runtime_with_version(dir.path());
414        assert_eq!(runtime, Some(Runtime::Node22));
415    }
416
417    #[test]
418    fn test_detect_python313_from_version_file() {
419        let dir = create_temp_dir();
420        fs::write(dir.path().join("requirements.txt"), "flask").unwrap();
421        fs::write(dir.path().join(".python-version"), "3.13.0").unwrap();
422
423        let runtime = detect_runtime_with_version(dir.path());
424        assert_eq!(runtime, Some(Runtime::Python313));
425    }
426
427    // -- WASM detection --------------------------------------------------
428
429    #[test]
430    fn test_detect_wasm_cargo_component_toml() {
431        let dir = create_temp_dir();
432        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"foo\"").unwrap();
433        fs::write(dir.path().join("cargo-component.toml"), "").unwrap();
434
435        let runtime = detect_runtime(dir.path());
436        assert_eq!(runtime, Some(Runtime::Wasm(WasmTargetHint::Component)));
437    }
438
439    #[test]
440    fn test_detect_wasm_cargo_metadata_component() {
441        let dir = create_temp_dir();
442        let toml = r#"
443[package]
444name = "foo"
445version = "0.1.0"
446
447[package.metadata.component]
448package = "zlayer:example"
449"#;
450        fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
451
452        let runtime = detect_runtime(dir.path());
453        assert_eq!(runtime, Some(Runtime::Wasm(WasmTargetHint::Component)));
454    }
455
456    #[test]
457    fn test_detect_wasm_componentize_py() {
458        let dir = create_temp_dir();
459        fs::write(dir.path().join("pyproject.toml"), "[project]").unwrap();
460        fs::write(dir.path().join("componentize-py.config"), "").unwrap();
461
462        let runtime = detect_runtime(dir.path());
463        assert_eq!(runtime, Some(Runtime::Wasm(WasmTargetHint::Component)));
464    }
465
466    #[test]
467    fn test_detect_wasm_jco_in_package_json() {
468        let dir = create_temp_dir();
469        let pkg = r#"{
470  "name": "foo",
471  "devDependencies": { "@bytecodealliance/jco": "^1.0.0" }
472}"#;
473        fs::write(dir.path().join("package.json"), pkg).unwrap();
474
475        let runtime = detect_runtime(dir.path());
476        assert_eq!(runtime, Some(Runtime::Wasm(WasmTargetHint::Component)));
477    }
478
479    #[test]
480    fn test_detect_wasm_cargo_config_wasip1_module() {
481        let dir = create_temp_dir();
482        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"foo\"").unwrap();
483        fs::create_dir_all(dir.path().join(".cargo")).unwrap();
484        fs::write(
485            dir.path().join(".cargo").join("config.toml"),
486            "[build]\ntarget = \"wasm32-wasip1\"\n",
487        )
488        .unwrap();
489
490        let runtime = detect_runtime(dir.path());
491        assert_eq!(runtime, Some(Runtime::Wasm(WasmTargetHint::Module)));
492    }
493
494    #[test]
495    fn test_plain_rust_project_is_not_wasm() {
496        // Cargo.toml without component metadata and without a wasip config
497        // should still detect as plain Rust.
498        let dir = create_temp_dir();
499        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"foo\"").unwrap();
500
501        let runtime = detect_runtime(dir.path());
502        assert_eq!(runtime, Some(Runtime::Rust));
503    }
504
505    #[test]
506    fn test_plain_node_project_is_not_wasm() {
507        // package.json without jco should detect as Node, not Wasm.
508        let dir = create_temp_dir();
509        fs::write(
510            dir.path().join("package.json"),
511            r#"{"dependencies":{"express":"^4"}}"#,
512        )
513        .unwrap();
514
515        let runtime = detect_runtime(dir.path());
516        assert_eq!(runtime, Some(Runtime::Node20));
517    }
518}