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    // .NET / Visual Studio project files are unambiguous Windows signals.
38    // `project.json` is the legacy dotnet project format. We check these
39    // before the generic Linux-ecosystem heuristics because a .sln/.csproj
40    // next to, say, a stray `requirements.txt` for test scripts is still
41    // primarily a Windows workload.
42    //
43    // NOTE: These are hints only. `resolve_runtime` honors an explicit
44    // runtime name / `--platform` / `os:` override, which always wins.
45    if has_dotnet_project_files(path) {
46        return Some(Runtime::WindowsServerCore);
47    }
48
49    // Check for Node.js ecosystem first (most common)
50    if path.join("package.json").exists() {
51        // Check for Bun - bun.lockb is definitive
52        if path.join("bun.lockb").exists() {
53            return Some(Runtime::Bun);
54        }
55
56        // Check for Deno - deno.json or deno.jsonc alongside package.json
57        if path.join("deno.json").exists() || path.join("deno.jsonc").exists() {
58            return Some(Runtime::Deno);
59        }
60
61        // Default to Node.js 20 for generic package.json projects
62        return Some(Runtime::Node20);
63    }
64
65    // Check for Deno without package.json
66    if path.join("deno.json").exists()
67        || path.join("deno.jsonc").exists()
68        || path.join("deno.lock").exists()
69    {
70        return Some(Runtime::Deno);
71    }
72
73    // Check for Rust
74    if path.join("Cargo.toml").exists() {
75        return Some(Runtime::Rust);
76    }
77
78    // Check for Python (check multiple indicators)
79    if path.join("pyproject.toml").exists()
80        || path.join("requirements.txt").exists()
81        || path.join("setup.py").exists()
82        || path.join("Pipfile").exists()
83        || path.join("poetry.lock").exists()
84    {
85        return Some(Runtime::Python312);
86    }
87
88    // Check for Go
89    if path.join("go.mod").exists() {
90        return Some(Runtime::Go);
91    }
92
93    // Last resort: a standalone `.exe` at the context root with no Linux
94    // ecosystem indicators present is probably a self-contained Windows
95    // binary that just needs to be wrapped. Suggest the smallest base
96    // (nanoserver).
97    if has_windows_exe(path) {
98        return Some(Runtime::WindowsNanoserver);
99    }
100
101    None
102}
103
104/// Return true when the context directory contains any `.sln`, `.csproj`,
105/// `.vcxproj`, or `project.json` file at its root.
106fn has_dotnet_project_files(path: &Path) -> bool {
107    if path.join("project.json").exists() {
108        return true;
109    }
110    dir_has_extension(path, &["sln", "csproj", "vcxproj"])
111}
112
113/// Return true when the context directory contains any `.exe` file at its
114/// root. Only checks the root (not recursive) — users with nested binary
115/// layouts should set the runtime explicitly.
116fn has_windows_exe(path: &Path) -> bool {
117    dir_has_extension(path, &["exe"])
118}
119
120/// Scan the top-level of `path` for any file whose extension matches one of
121/// `extensions` (case-insensitive, no leading dot). Returns false on I/O
122/// errors or if `path` is not a readable directory.
123fn dir_has_extension(path: &Path, extensions: &[&str]) -> bool {
124    let Ok(entries) = std::fs::read_dir(path) else {
125        return false;
126    };
127    for entry in entries.flatten() {
128        let Ok(file_type) = entry.file_type() else {
129            continue;
130        };
131        if !file_type.is_file() {
132            continue;
133        }
134        let file_name = entry.file_name();
135        let name = file_name.to_string_lossy();
136        if let Some((_, ext)) = name.rsplit_once('.') {
137            let ext_lower = ext.to_ascii_lowercase();
138            if extensions
139                .iter()
140                .any(|&e| e.eq_ignore_ascii_case(&ext_lower))
141            {
142                return true;
143            }
144        }
145    }
146    false
147}
148
149/// Detect runtime with version hints from project files.
150///
151/// This extends basic detection by attempting to read version specifications
152/// from configuration files to choose the most appropriate version.
153pub fn detect_runtime_with_version(context_path: impl AsRef<Path>) -> Option<Runtime> {
154    let path = context_path.as_ref();
155
156    // First do basic detection
157    let base_runtime = detect_runtime(path)?;
158
159    // Try to refine version based on configuration files
160    match base_runtime {
161        Runtime::Node20 | Runtime::Node22 => {
162            // Try to read .nvmrc or .node-version
163            if let Some(version) = read_node_version(path) {
164                if version.starts_with("22") || version.starts_with("v22") {
165                    return Some(Runtime::Node22);
166                }
167                if version.starts_with("20") || version.starts_with("v20") {
168                    return Some(Runtime::Node20);
169                }
170            }
171
172            // Try to read engines.node from package.json
173            if let Some(version) = read_package_node_version(path) {
174                if version.contains("22") {
175                    return Some(Runtime::Node22);
176                }
177            }
178
179            Some(Runtime::Node20)
180        }
181        Runtime::Python312 | Runtime::Python313 => {
182            // Try to read python version from pyproject.toml or .python-version
183            if let Some(version) = read_python_version(path) {
184                if version.starts_with("3.13") {
185                    return Some(Runtime::Python313);
186                }
187            }
188
189            Some(Runtime::Python312)
190        }
191        other => Some(other),
192    }
193}
194
195/// Detect whether the project at `path` targets `WebAssembly`, and if so
196/// whether it builds a raw module or a WASI component.
197///
198/// Detection priority (first match wins):
199/// 1. `cargo-component.toml` → `Component` (Rust component tooling).
200/// 2. `Cargo.toml` with `[package.metadata.component]` → `Component`.
201/// 3. `componentize-py.config` → `Component` (Python component tooling).
202/// 4. `package.json` with `jco` / `@bytecodealliance/jco` in any dep
203///    section → `Component` (JS component tooling).
204/// 5. `.cargo/config.toml` selecting a `wasm32-wasip*` target → `Module`.
205///
206/// Returns `None` when the project shows no WASM indicators at all so the
207/// caller can continue with regular language runtime detection.
208fn detect_wasm_hint(path: &Path) -> Option<WasmTargetHint> {
209    // 1. cargo-component.toml is a definitive Component signal.
210    if path.join("cargo-component.toml").exists() {
211        return Some(WasmTargetHint::Component);
212    }
213
214    // 2. Cargo.toml with [package.metadata.component] → Component.
215    //    (Also: if it only has a wasip* target without component metadata,
216    //    we'll fall through to step 5 and classify as Module.)
217    let cargo_toml = path.join("Cargo.toml");
218    let cargo_has_component_metadata = if cargo_toml.exists() {
219        cargo_toml_has_component_metadata(&cargo_toml)
220    } else {
221        false
222    };
223    if cargo_has_component_metadata {
224        return Some(WasmTargetHint::Component);
225    }
226
227    // 3. componentize-py.config → Component.
228    if path.join("componentize-py.config").exists() {
229        return Some(WasmTargetHint::Component);
230    }
231
232    // 4. package.json with jco / @bytecodealliance/jco → Component.
233    if path.join("package.json").exists() && package_json_uses_jco(&path.join("package.json")) {
234        return Some(WasmTargetHint::Component);
235    }
236
237    // 5. Cargo.toml + .cargo/config.toml with wasm32-wasip1 / wasip2 target → Module.
238    if cargo_toml.exists() && cargo_config_targets_wasip(path) {
239        return Some(WasmTargetHint::Module);
240    }
241
242    None
243}
244
245/// Return true when `Cargo.toml` declares `[package.metadata.component]`.
246///
247/// Uses a line-scan (no extra TOML parser dependency) which is sufficient for
248/// the documented `cargo-component` layout.
249fn cargo_toml_has_component_metadata(cargo_toml: &Path) -> bool {
250    let Ok(content) = std::fs::read_to_string(cargo_toml) else {
251        return false;
252    };
253    content.lines().any(|line| {
254        let trimmed = line.trim();
255        trimmed == "[package.metadata.component]"
256            || trimmed.starts_with("[package.metadata.component.")
257    })
258}
259
260/// `package.json` dependency sections scanned for the `jco` tool.
261const JCO_DEP_SECTIONS: &[&str] = &[
262    "dependencies",
263    "devDependencies",
264    "peerDependencies",
265    "optionalDependencies",
266];
267
268/// Return true when `package.json` references `jco` / `@bytecodealliance/jco`
269/// in any of its dependency sections.
270fn package_json_uses_jco(package_json: &Path) -> bool {
271    let Ok(content) = std::fs::read_to_string(package_json) else {
272        return false;
273    };
274    let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
275        return false;
276    };
277
278    for section in JCO_DEP_SECTIONS {
279        if let Some(obj) = json.get(section).and_then(|v| v.as_object()) {
280            if obj.contains_key("jco") || obj.contains_key("@bytecodealliance/jco") {
281                return true;
282            }
283        }
284    }
285    false
286}
287
288/// Return true when `.cargo/config.toml` selects a `wasm32-wasip1`/`wasip2`
289/// build target (line-scan, no TOML dependency).
290fn cargo_config_targets_wasip(path: &Path) -> bool {
291    let config = path.join(".cargo").join("config.toml");
292    let Ok(content) = std::fs::read_to_string(&config) else {
293        return false;
294    };
295    content.lines().any(|line| {
296        let trimmed = line.trim();
297        trimmed.contains("wasm32-wasip1") || trimmed.contains("wasm32-wasip2")
298    })
299}
300
301/// Read Node.js version from .nvmrc or .node-version files
302fn read_node_version(path: &Path) -> Option<String> {
303    for filename in &[".nvmrc", ".node-version"] {
304        let version_file = path.join(filename);
305        if version_file.exists() {
306            if let Ok(content) = std::fs::read_to_string(&version_file) {
307                let version = content.trim().to_string();
308                if !version.is_empty() {
309                    return Some(version);
310                }
311            }
312        }
313    }
314    None
315}
316
317/// Read Node.js version from package.json engines field
318fn read_package_node_version(path: &Path) -> Option<String> {
319    let package_json = path.join("package.json");
320    if package_json.exists() {
321        if let Ok(content) = std::fs::read_to_string(&package_json) {
322            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
323                if let Some(engines) = json.get("engines") {
324                    if let Some(node) = engines.get("node") {
325                        if let Some(version) = node.as_str() {
326                            return Some(version.to_string());
327                        }
328                    }
329                }
330            }
331        }
332    }
333    None
334}
335
336/// Read Python version from .python-version or pyproject.toml
337fn read_python_version(path: &Path) -> Option<String> {
338    // Check .python-version first (used by pyenv)
339    let python_version = path.join(".python-version");
340    if python_version.exists() {
341        if let Ok(content) = std::fs::read_to_string(&python_version) {
342            let version = content.trim().to_string();
343            if !version.is_empty() {
344                return Some(version);
345            }
346        }
347    }
348
349    // Check pyproject.toml for requires-python
350    let pyproject = path.join("pyproject.toml");
351    if pyproject.exists() {
352        if let Ok(content) = std::fs::read_to_string(&pyproject) {
353            // Simple parsing - look for requires-python
354            for line in content.lines() {
355                let line = line.trim();
356                if line.starts_with("requires-python") {
357                    if let Some(version) = line.split('=').nth(1) {
358                        let version = version.trim().trim_matches('"').trim_matches('\'');
359                        return Some(version.to_string());
360                    }
361                }
362            }
363        }
364    }
365
366    None
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use std::fs;
373    use tempfile::TempDir;
374
375    fn create_temp_dir() -> TempDir {
376        TempDir::new().expect("Failed to create temp directory")
377    }
378
379    #[test]
380    fn test_detect_nodejs_project() {
381        let dir = create_temp_dir();
382        fs::write(dir.path().join("package.json"), "{}").unwrap();
383
384        let runtime = detect_runtime(dir.path());
385        assert_eq!(runtime, Some(Runtime::Node20));
386    }
387
388    #[test]
389    fn test_detect_bun_project() {
390        let dir = create_temp_dir();
391        fs::write(dir.path().join("package.json"), "{}").unwrap();
392        fs::write(dir.path().join("bun.lockb"), "").unwrap();
393
394        let runtime = detect_runtime(dir.path());
395        assert_eq!(runtime, Some(Runtime::Bun));
396    }
397
398    #[test]
399    fn test_detect_deno_project() {
400        let dir = create_temp_dir();
401        fs::write(dir.path().join("deno.json"), "{}").unwrap();
402
403        let runtime = detect_runtime(dir.path());
404        assert_eq!(runtime, Some(Runtime::Deno));
405    }
406
407    #[test]
408    fn test_detect_deno_with_package_json() {
409        let dir = create_temp_dir();
410        fs::write(dir.path().join("package.json"), "{}").unwrap();
411        fs::write(dir.path().join("deno.json"), "{}").unwrap();
412
413        let runtime = detect_runtime(dir.path());
414        assert_eq!(runtime, Some(Runtime::Deno));
415    }
416
417    #[test]
418    fn test_detect_rust_project() {
419        let dir = create_temp_dir();
420        fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
421
422        let runtime = detect_runtime(dir.path());
423        assert_eq!(runtime, Some(Runtime::Rust));
424    }
425
426    #[test]
427    fn test_detect_python_requirements() {
428        let dir = create_temp_dir();
429        fs::write(dir.path().join("requirements.txt"), "flask==2.0").unwrap();
430
431        let runtime = detect_runtime(dir.path());
432        assert_eq!(runtime, Some(Runtime::Python312));
433    }
434
435    #[test]
436    fn test_detect_python_pyproject() {
437        let dir = create_temp_dir();
438        fs::write(dir.path().join("pyproject.toml"), "[project]").unwrap();
439
440        let runtime = detect_runtime(dir.path());
441        assert_eq!(runtime, Some(Runtime::Python312));
442    }
443
444    #[test]
445    fn test_detect_go_project() {
446        let dir = create_temp_dir();
447        fs::write(dir.path().join("go.mod"), "module example.com/app").unwrap();
448
449        let runtime = detect_runtime(dir.path());
450        assert_eq!(runtime, Some(Runtime::Go));
451    }
452
453    #[test]
454    fn test_detect_no_runtime() {
455        let dir = create_temp_dir();
456        // Empty directory
457
458        let runtime = detect_runtime(dir.path());
459        assert_eq!(runtime, None);
460    }
461
462    #[test]
463    fn test_detect_node22_from_nvmrc() {
464        let dir = create_temp_dir();
465        fs::write(dir.path().join("package.json"), "{}").unwrap();
466        fs::write(dir.path().join(".nvmrc"), "22.0.0").unwrap();
467
468        let runtime = detect_runtime_with_version(dir.path());
469        assert_eq!(runtime, Some(Runtime::Node22));
470    }
471
472    #[test]
473    fn test_detect_node22_from_package_engines() {
474        let dir = create_temp_dir();
475        let package_json = r#"{"engines": {"node": ">=22.0.0"}}"#;
476        fs::write(dir.path().join("package.json"), package_json).unwrap();
477
478        let runtime = detect_runtime_with_version(dir.path());
479        assert_eq!(runtime, Some(Runtime::Node22));
480    }
481
482    #[test]
483    fn test_detect_python313_from_version_file() {
484        let dir = create_temp_dir();
485        fs::write(dir.path().join("requirements.txt"), "flask").unwrap();
486        fs::write(dir.path().join(".python-version"), "3.13.0").unwrap();
487
488        let runtime = detect_runtime_with_version(dir.path());
489        assert_eq!(runtime, Some(Runtime::Python313));
490    }
491
492    // -- WASM detection --------------------------------------------------
493
494    #[test]
495    fn test_detect_wasm_cargo_component_toml() {
496        let dir = create_temp_dir();
497        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"foo\"").unwrap();
498        fs::write(dir.path().join("cargo-component.toml"), "").unwrap();
499
500        let runtime = detect_runtime(dir.path());
501        assert_eq!(runtime, Some(Runtime::Wasm(WasmTargetHint::Component)));
502    }
503
504    #[test]
505    fn test_detect_wasm_cargo_metadata_component() {
506        let dir = create_temp_dir();
507        let toml = r#"
508[package]
509name = "foo"
510version = "0.1.0"
511
512[package.metadata.component]
513package = "zlayer:example"
514"#;
515        fs::write(dir.path().join("Cargo.toml"), toml).unwrap();
516
517        let runtime = detect_runtime(dir.path());
518        assert_eq!(runtime, Some(Runtime::Wasm(WasmTargetHint::Component)));
519    }
520
521    #[test]
522    fn test_detect_wasm_componentize_py() {
523        let dir = create_temp_dir();
524        fs::write(dir.path().join("pyproject.toml"), "[project]").unwrap();
525        fs::write(dir.path().join("componentize-py.config"), "").unwrap();
526
527        let runtime = detect_runtime(dir.path());
528        assert_eq!(runtime, Some(Runtime::Wasm(WasmTargetHint::Component)));
529    }
530
531    #[test]
532    fn test_detect_wasm_jco_in_package_json() {
533        let dir = create_temp_dir();
534        let pkg = r#"{
535  "name": "foo",
536  "devDependencies": { "@bytecodealliance/jco": "^1.0.0" }
537}"#;
538        fs::write(dir.path().join("package.json"), pkg).unwrap();
539
540        let runtime = detect_runtime(dir.path());
541        assert_eq!(runtime, Some(Runtime::Wasm(WasmTargetHint::Component)));
542    }
543
544    #[test]
545    fn test_detect_wasm_cargo_config_wasip1_module() {
546        let dir = create_temp_dir();
547        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"foo\"").unwrap();
548        fs::create_dir_all(dir.path().join(".cargo")).unwrap();
549        fs::write(
550            dir.path().join(".cargo").join("config.toml"),
551            "[build]\ntarget = \"wasm32-wasip1\"\n",
552        )
553        .unwrap();
554
555        let runtime = detect_runtime(dir.path());
556        assert_eq!(runtime, Some(Runtime::Wasm(WasmTargetHint::Module)));
557    }
558
559    #[test]
560    fn test_plain_rust_project_is_not_wasm() {
561        // Cargo.toml without component metadata and without a wasip config
562        // should still detect as plain Rust.
563        let dir = create_temp_dir();
564        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"foo\"").unwrap();
565
566        let runtime = detect_runtime(dir.path());
567        assert_eq!(runtime, Some(Runtime::Rust));
568    }
569
570    #[test]
571    fn test_plain_node_project_is_not_wasm() {
572        // package.json without jco should detect as Node, not Wasm.
573        let dir = create_temp_dir();
574        fs::write(
575            dir.path().join("package.json"),
576            r#"{"dependencies":{"express":"^4"}}"#,
577        )
578        .unwrap();
579
580        let runtime = detect_runtime(dir.path());
581        assert_eq!(runtime, Some(Runtime::Node20));
582    }
583
584    // -- Windows detection -----------------------------------------------
585
586    #[test]
587    fn test_detect_windows_servercore_from_sln() {
588        let dir = create_temp_dir();
589        fs::write(dir.path().join("MyApp.sln"), "").unwrap();
590
591        let runtime = detect_runtime(dir.path());
592        assert_eq!(runtime, Some(Runtime::WindowsServerCore));
593    }
594
595    #[test]
596    fn test_detect_windows_servercore_from_csproj() {
597        let dir = create_temp_dir();
598        fs::write(
599            dir.path().join("MyApp.csproj"),
600            r#"<Project Sdk="Microsoft.NET.Sdk" />"#,
601        )
602        .unwrap();
603
604        let runtime = detect_runtime(dir.path());
605        assert_eq!(runtime, Some(Runtime::WindowsServerCore));
606    }
607
608    #[test]
609    fn test_detect_windows_servercore_from_vcxproj() {
610        let dir = create_temp_dir();
611        fs::write(dir.path().join("Native.vcxproj"), "").unwrap();
612
613        let runtime = detect_runtime(dir.path());
614        assert_eq!(runtime, Some(Runtime::WindowsServerCore));
615    }
616
617    #[test]
618    fn test_detect_windows_servercore_from_legacy_project_json() {
619        let dir = create_temp_dir();
620        // Legacy dotnet project.json — without this rule, a bare
621        // project.json with no other indicators would go undetected.
622        fs::write(dir.path().join("project.json"), "{}").unwrap();
623
624        let runtime = detect_runtime(dir.path());
625        assert_eq!(runtime, Some(Runtime::WindowsServerCore));
626    }
627
628    #[test]
629    fn test_detect_windows_nanoserver_from_standalone_exe() {
630        let dir = create_temp_dir();
631        fs::write(dir.path().join("app.exe"), b"MZ\x90\x00").unwrap();
632
633        let runtime = detect_runtime(dir.path());
634        assert_eq!(runtime, Some(Runtime::WindowsNanoserver));
635    }
636
637    #[test]
638    fn test_detect_case_insensitive_exe_extension() {
639        let dir = create_temp_dir();
640        // Windows filesystems are case-insensitive; our scan should be too.
641        fs::write(dir.path().join("App.EXE"), b"MZ").unwrap();
642
643        let runtime = detect_runtime(dir.path());
644        assert_eq!(runtime, Some(Runtime::WindowsNanoserver));
645    }
646
647    #[test]
648    fn test_dotnet_wins_over_linux_python_hint() {
649        // A .sln alongside a requirements.txt (maybe Python test scripts)
650        // should still route to Windows Server Core — .sln is the primary
651        // build artifact for a .NET workload.
652        let dir = create_temp_dir();
653        fs::write(dir.path().join("MyApp.sln"), "").unwrap();
654        fs::write(dir.path().join("requirements.txt"), "requests").unwrap();
655
656        let runtime = detect_runtime(dir.path());
657        assert_eq!(runtime, Some(Runtime::WindowsServerCore));
658    }
659
660    #[test]
661    fn test_exe_does_not_override_rust_project() {
662        // A bare .exe in a Rust project (e.g. a prebuilt test tool) should
663        // NOT demote to Windows Nanoserver — the Cargo.toml wins.
664        let dir = create_temp_dir();
665        fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
666        fs::write(dir.path().join("helper.exe"), b"MZ").unwrap();
667
668        let runtime = detect_runtime(dir.path());
669        assert_eq!(runtime, Some(Runtime::Rust));
670    }
671
672    #[test]
673    fn test_exe_does_not_override_go_project() {
674        let dir = create_temp_dir();
675        fs::write(dir.path().join("go.mod"), "module example.com/app").unwrap();
676        fs::write(dir.path().join("helper.exe"), b"MZ").unwrap();
677
678        let runtime = detect_runtime(dir.path());
679        assert_eq!(runtime, Some(Runtime::Go));
680    }
681
682    #[test]
683    fn test_exe_does_not_override_node_project() {
684        let dir = create_temp_dir();
685        fs::write(dir.path().join("package.json"), "{}").unwrap();
686        fs::write(dir.path().join("helper.exe"), b"MZ").unwrap();
687
688        let runtime = detect_runtime(dir.path());
689        assert_eq!(runtime, Some(Runtime::Node20));
690    }
691}