Skip to main content

zlayer_builder/templates/
mod.rs

1//! Runtime templates for `ZLayer` builder
2//!
3//! This module provides pre-built Dockerfile templates for common runtimes,
4//! allowing users to build container images without writing Dockerfiles.
5//!
6//! # Usage
7//!
8//! Templates can be used via the `zlayer build` command:
9//!
10//! ```bash
11//! # Use a specific runtime template
12//! zlayer build --runtime node20
13//!
14//! # Auto-detect runtime from project files
15//! zlayer build --detect-runtime
16//! ```
17//!
18//! # Available Runtimes
19//!
20//! - **Node.js 20** (`node20`): Production-ready Node.js 20 with Alpine base
21//! - **Node.js 22** (`node22`): Production-ready Node.js 22 with Alpine base
22//! - **Python 3.12** (`python312`): Python 3.12 slim with pip packages
23//! - **Python 3.13** (`python313`): Python 3.13 slim with pip packages
24//! - **Rust** (`rust`): Static binary build with musl
25//! - **Go** (`go`): Static binary build with Alpine
26//! - **Deno** (`deno`): Official Deno runtime
27//! - **Bun** (`bun`): Official Bun runtime
28//! - **Windows Nanoserver** (`windows-nanoserver`): Minimal Windows base for
29//!   self-contained binaries; no package managers / no `PowerShell`
30//! - **Windows Server Core** (`windows-servercore`): Larger Windows base with
31//!   `PowerShell` bundled; required for chocolatey / winget / full .NET SDK
32//!
33//! # Auto-Detection
34//!
35//! The [`detect_runtime`] function can automatically detect the appropriate
36//! runtime based on files present in the project directory:
37//!
38//! - `package.json` -> Node.js (unless Bun or Deno indicators present)
39//! - `bun.lockb` -> Bun
40//! - `deno.json` or `deno.jsonc` -> Deno
41//! - `Cargo.toml` -> Rust
42//! - `requirements.txt`, `pyproject.toml`, `setup.py` -> Python
43//! - `go.mod` -> Go
44//! - `*.sln` / `*.csproj` / `*.vcxproj` / `project.json` -> Windows Server Core
45//! - `*.exe` (with no Linux indicators) -> Windows Nanoserver
46//!
47//! Auto-detection is a *hint*. Explicit overrides (`os:` field, `--platform`
48//! CLI flag) always win — see `resolve_runtime`.
49
50mod detect;
51
52use std::fmt;
53use std::path::Path;
54use std::str::FromStr;
55
56pub use detect::{detect_runtime, detect_runtime_with_version};
57
58/// Supported runtime environments
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60pub enum Runtime {
61    /// Node.js 20 (LTS)
62    Node20,
63    /// Node.js 22 (Current)
64    Node22,
65    /// Python 3.12
66    Python312,
67    /// Python 3.13
68    Python313,
69    /// Rust (latest stable)
70    Rust,
71    /// Go (latest stable)
72    Go,
73    /// Deno (latest)
74    Deno,
75    /// Bun (latest)
76    Bun,
77    /// Windows Nanoserver (`mcr.microsoft.com/windows/nanoserver:ltsc2022`).
78    ///
79    /// Minimal Windows base image for self-contained binaries. No `PowerShell`,
80    /// no chocolatey, no winget.
81    WindowsNanoserver,
82    /// Windows Server Core (`mcr.microsoft.com/windows/servercore:ltsc2022`).
83    ///
84    /// Larger Windows base that bundles `PowerShell` and is compatible with
85    /// chocolatey / winget / full .NET SDK workloads.
86    WindowsServerCore,
87    /// `WebAssembly` (delegates to `wasm:` build mode).
88    ///
89    /// The associated [`WasmTargetHint`] is a best-effort indicator of whether
90    /// the project builds a raw WASI module or a WASI component. Downstream
91    /// build logic treats this as guidance only; the actual target is still
92    /// driven by the `ZImagefile` `wasm:` section (or its defaults).
93    Wasm(WasmTargetHint),
94}
95
96/// Hint for the kind of `WebAssembly` artifact a project produces.
97///
98/// This is populated by [`Runtime::detect_from_path`] (via the `detect` module)
99/// and carried through `Runtime::Wasm(hint)` so callers can pick reasonable
100/// defaults when delegating to the `wasm:` build mode.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
102pub enum WasmTargetHint {
103    /// WASI preview1 / preview2 raw module (no component wrapper).
104    Module,
105    /// WASI component (cargo-component, jco, componentize-py, ...).
106    Component,
107    /// Unknown / let the builder pick its own default (currently preview2).
108    #[default]
109    Auto,
110}
111
112impl WasmTargetHint {
113    /// Short lowercase name used in diagnostics and YAML emission.
114    #[must_use]
115    pub fn as_str(&self) -> &'static str {
116        match self {
117            Self::Module => "module",
118            Self::Component => "component",
119            Self::Auto => "auto",
120        }
121    }
122}
123
124impl Runtime {
125    /// Get all available runtimes
126    #[must_use]
127    pub fn all() -> &'static [RuntimeInfo] {
128        &[
129            RuntimeInfo {
130                runtime: Runtime::Node20,
131                name: "node20",
132                description: "Node.js 20 (LTS) - Alpine-based, production optimized",
133                detect_files: &["package.json"],
134            },
135            RuntimeInfo {
136                runtime: Runtime::Node22,
137                name: "node22",
138                description: "Node.js 22 (Current) - Alpine-based, production optimized",
139                detect_files: &["package.json"],
140            },
141            RuntimeInfo {
142                runtime: Runtime::Python312,
143                name: "python312",
144                description: "Python 3.12 - Slim Debian-based with pip",
145                detect_files: &["requirements.txt", "pyproject.toml", "setup.py"],
146            },
147            RuntimeInfo {
148                runtime: Runtime::Python313,
149                name: "python313",
150                description: "Python 3.13 - Slim Debian-based with pip",
151                detect_files: &["requirements.txt", "pyproject.toml", "setup.py"],
152            },
153            RuntimeInfo {
154                runtime: Runtime::Rust,
155                name: "rust",
156                description: "Rust - Static musl binary, minimal Alpine runtime",
157                detect_files: &["Cargo.toml"],
158            },
159            RuntimeInfo {
160                runtime: Runtime::Go,
161                name: "go",
162                description: "Go - Static binary, minimal Alpine runtime",
163                detect_files: &["go.mod"],
164            },
165            RuntimeInfo {
166                runtime: Runtime::Deno,
167                name: "deno",
168                description: "Deno - Official runtime with TypeScript support",
169                detect_files: &["deno.json", "deno.jsonc"],
170            },
171            RuntimeInfo {
172                runtime: Runtime::Bun,
173                name: "bun",
174                description: "Bun - Fast JavaScript runtime and bundler",
175                detect_files: &["bun.lockb"],
176            },
177            RuntimeInfo {
178                runtime: Runtime::WindowsNanoserver,
179                name: "windows-nanoserver",
180                description:
181                    "Windows Nanoserver - Minimal Windows base (no package managers, no `PowerShell`)",
182                // Any .exe file at the context root suggests a self-contained
183                // Windows binary workload.
184                detect_files: &["*.exe"],
185            },
186            RuntimeInfo {
187                runtime: Runtime::WindowsServerCore,
188                name: "windows-servercore",
189                description:
190                    "Windows Server Core - Windows base with `PowerShell`, chocolatey/winget compatible",
191                detect_files: &["*.sln", "*.csproj", "*.vcxproj", "project.json"],
192            },
193            RuntimeInfo {
194                runtime: Runtime::Wasm(WasmTargetHint::Auto),
195                name: "wasm",
196                description: "WebAssembly - Delegates to wasm: build mode (auto-detects target)",
197                detect_files: &["cargo-component.toml", "componentize-py.config"],
198            },
199        ]
200    }
201
202    /// Parse a runtime from its name
203    #[must_use]
204    pub fn from_name(name: &str) -> Option<Runtime> {
205        let name_lower = name.to_lowercase();
206        match name_lower.as_str() {
207            "node20" | "node-20" | "nodejs20" | "node" => Some(Runtime::Node20),
208            "node22" | "node-22" | "nodejs22" => Some(Runtime::Node22),
209            "python312" | "python-312" | "python3.12" | "python" => Some(Runtime::Python312),
210            "python313" | "python-313" | "python3.13" => Some(Runtime::Python313),
211            "rust" | "rs" => Some(Runtime::Rust),
212            "go" | "golang" => Some(Runtime::Go),
213            "deno" => Some(Runtime::Deno),
214            "bun" => Some(Runtime::Bun),
215            "windows-nanoserver" | "nanoserver" | "windows_nanoserver" => {
216                Some(Runtime::WindowsNanoserver)
217            }
218            "windows-servercore" | "windows-server-core" | "servercore" | "windows_servercore" => {
219                Some(Runtime::WindowsServerCore)
220            }
221            "wasm" | "webassembly" => Some(Runtime::Wasm(WasmTargetHint::Auto)),
222            "wasm-module" | "wasm-preview1" | "wasm-preview2" => {
223                Some(Runtime::Wasm(WasmTargetHint::Module))
224            }
225            "wasm-component" | "wasi-component" => Some(Runtime::Wasm(WasmTargetHint::Component)),
226            _ => None,
227        }
228    }
229
230    /// Get information about this runtime
231    ///
232    /// # Panics
233    ///
234    /// Panics if the runtime variant is missing from the static info table (internal invariant).
235    #[must_use]
236    pub fn info(&self) -> &'static RuntimeInfo {
237        // WASM variants all share a single info entry regardless of the
238        // [`WasmTargetHint`] payload, so we collapse to the `Auto` hint for
239        // lookup.
240        let lookup = match self {
241            Runtime::Wasm(_) => Runtime::Wasm(WasmTargetHint::Auto),
242            other => *other,
243        };
244        Runtime::all()
245            .iter()
246            .find(|info| info.runtime == lookup)
247            .expect("All runtimes must have info")
248    }
249
250    /// Get the Dockerfile template for this runtime
251    ///
252    /// # Note on `Runtime::Wasm`
253    ///
254    /// The WASM variant does not produce a Dockerfile — it is a sentinel that
255    /// tells the builder to delegate to the `wasm:` build mode. The returned
256    /// string is a `ZImagefile` YAML snippet (see [`Runtime::wasm_zimagefile`])
257    /// so callers that feed `template()` output through the `ZImagefile` parser
258    /// will cleanly route into the WASM build path. Callers that feed it
259    /// through the Dockerfile parser must special-case `Runtime::Wasm(_)`.
260    #[must_use]
261    pub fn template(&self) -> &'static str {
262        match self {
263            Runtime::Node20 => include_str!("dockerfiles/node20.Dockerfile"),
264            Runtime::Node22 => include_str!("dockerfiles/node22.Dockerfile"),
265            Runtime::Python312 => include_str!("dockerfiles/python312.Dockerfile"),
266            Runtime::Python313 => include_str!("dockerfiles/python313.Dockerfile"),
267            Runtime::Rust => include_str!("dockerfiles/rust.Dockerfile"),
268            Runtime::Go => include_str!("dockerfiles/go.Dockerfile"),
269            Runtime::Deno => include_str!("dockerfiles/deno.Dockerfile"),
270            Runtime::Bun => include_str!("dockerfiles/bun.Dockerfile"),
271            Runtime::WindowsNanoserver => {
272                include_str!("dockerfiles/windows-nanoserver.Dockerfile")
273            }
274            Runtime::WindowsServerCore => {
275                include_str!("dockerfiles/windows-servercore.Dockerfile")
276            }
277            Runtime::Wasm(hint) => Self::wasm_zimagefile(*hint),
278        }
279    }
280
281    /// Return a minimal `ZImagefile` YAML snippet for the WASM runtime.
282    ///
283    /// The snippet sets `wasm:` mode with defaults appropriate for the given
284    /// [`WasmTargetHint`]. The parser's `validate_wasm` accepts these defaults,
285    /// and the builder's WASM path will auto-detect the source language when
286    /// `language` is omitted.
287    fn wasm_zimagefile(hint: WasmTargetHint) -> &'static str {
288        match hint {
289            // Components default to preview2 (WASI component model).
290            WasmTargetHint::Component => "wasm:\n  target: preview2\n",
291            // Raw modules default to preview1, which is what a bare
292            // `wasm32-wasip1` Cargo build produces.
293            WasmTargetHint::Module => "wasm:\n  target: preview1\n",
294            // Unknown — pick the modern default and let the builder decide.
295            WasmTargetHint::Auto => "wasm: {}\n",
296        }
297    }
298
299    /// Get the canonical name for this runtime
300    #[must_use]
301    pub fn name(&self) -> &'static str {
302        self.info().name
303    }
304}
305
306impl fmt::Display for Runtime {
307    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308        write!(f, "{}", self.name())
309    }
310}
311
312impl FromStr for Runtime {
313    type Err = String;
314
315    fn from_str(s: &str) -> Result<Self, Self::Err> {
316        Runtime::from_name(s).ok_or_else(|| format!("Unknown runtime: {s}"))
317    }
318}
319
320/// Information about a runtime template
321#[derive(Debug, Clone, Copy)]
322pub struct RuntimeInfo {
323    /// The runtime enum value
324    pub runtime: Runtime,
325    /// Short name used in CLI (e.g., "node20")
326    pub name: &'static str,
327    /// Human-readable description
328    pub description: &'static str,
329    /// Files that indicate this runtime should be used
330    pub detect_files: &'static [&'static str],
331}
332
333/// List all available templates
334#[must_use]
335pub fn list_templates() -> Vec<&'static RuntimeInfo> {
336    Runtime::all().iter().collect()
337}
338
339/// Get template content for a runtime
340#[must_use]
341pub fn get_template(runtime: Runtime) -> &'static str {
342    runtime.template()
343}
344
345/// Get template content by runtime name
346#[must_use]
347pub fn get_template_by_name(name: &str) -> Option<&'static str> {
348    Runtime::from_name(name).map(|r| r.template())
349}
350
351/// Resolve runtime from either explicit name or auto-detection
352pub fn resolve_runtime(
353    runtime_name: Option<&str>,
354    context_path: impl AsRef<Path>,
355    use_version_hints: bool,
356) -> Option<Runtime> {
357    // If explicitly specified, use that
358    if let Some(name) = runtime_name {
359        return Runtime::from_name(name);
360    }
361
362    // Otherwise, auto-detect
363    if use_version_hints {
364        detect_runtime_with_version(context_path)
365    } else {
366        detect_runtime(context_path)
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::Dockerfile;
374    use std::fs;
375    use tempfile::TempDir;
376
377    #[test]
378    fn test_runtime_from_name() {
379        assert_eq!(Runtime::from_name("node20"), Some(Runtime::Node20));
380        assert_eq!(Runtime::from_name("Node20"), Some(Runtime::Node20));
381        assert_eq!(Runtime::from_name("node"), Some(Runtime::Node20));
382        assert_eq!(Runtime::from_name("python"), Some(Runtime::Python312));
383        assert_eq!(Runtime::from_name("rust"), Some(Runtime::Rust));
384        assert_eq!(Runtime::from_name("go"), Some(Runtime::Go));
385        assert_eq!(Runtime::from_name("golang"), Some(Runtime::Go));
386        assert_eq!(Runtime::from_name("deno"), Some(Runtime::Deno));
387        assert_eq!(Runtime::from_name("bun"), Some(Runtime::Bun));
388        assert_eq!(Runtime::from_name("unknown"), None);
389    }
390
391    #[test]
392    fn test_runtime_info() {
393        let info = Runtime::Node20.info();
394        assert_eq!(info.name, "node20");
395        assert!(info.description.contains("Node.js"));
396        assert!(info.detect_files.contains(&"package.json"));
397    }
398
399    #[test]
400    fn test_all_templates_parse_correctly() {
401        for info in Runtime::all() {
402            // The WASM runtime emits a ZImagefile YAML snippet (routed to the
403            // `wasm:` build mode), not a Dockerfile — skip the Dockerfile
404            // parser for that variant.
405            if matches!(info.runtime, Runtime::Wasm(_)) {
406                continue;
407            }
408
409            let template = info.runtime.template();
410            let result = Dockerfile::parse(template);
411            assert!(
412                result.is_ok(),
413                "Template {} failed to parse: {:?}",
414                info.name,
415                result.err()
416            );
417
418            let dockerfile = result.unwrap();
419            assert!(
420                !dockerfile.stages.is_empty(),
421                "Template {} has no stages",
422                info.name
423            );
424        }
425    }
426
427    #[test]
428    fn test_runtime_wasm_from_name() {
429        assert_eq!(
430            Runtime::from_name("wasm"),
431            Some(Runtime::Wasm(WasmTargetHint::Auto))
432        );
433        assert_eq!(
434            Runtime::from_name("WASM"),
435            Some(Runtime::Wasm(WasmTargetHint::Auto))
436        );
437        assert_eq!(
438            Runtime::from_name("webassembly"),
439            Some(Runtime::Wasm(WasmTargetHint::Auto))
440        );
441        assert_eq!(
442            Runtime::from_name("wasm-component"),
443            Some(Runtime::Wasm(WasmTargetHint::Component))
444        );
445        assert_eq!(
446            Runtime::from_name("wasm-module"),
447            Some(Runtime::Wasm(WasmTargetHint::Module))
448        );
449    }
450
451    #[test]
452    fn test_runtime_wasm_template_is_zimagefile_yaml() {
453        let t = Runtime::Wasm(WasmTargetHint::Auto).template();
454        assert!(t.contains("wasm:"), "template should set wasm mode: {t}");
455
456        let component = Runtime::Wasm(WasmTargetHint::Component).template();
457        assert!(component.contains("preview2"), "component → preview2");
458
459        let module = Runtime::Wasm(WasmTargetHint::Module).template();
460        assert!(module.contains("preview1"), "module → preview1");
461    }
462
463    #[test]
464    fn test_runtime_wasm_info_lookup() {
465        // All WasmTargetHint variants must resolve through info() without
466        // panicking.
467        let auto = Runtime::Wasm(WasmTargetHint::Auto).info();
468        let module = Runtime::Wasm(WasmTargetHint::Module).info();
469        let component = Runtime::Wasm(WasmTargetHint::Component).info();
470        assert_eq!(auto.name, "wasm");
471        assert_eq!(module.name, "wasm");
472        assert_eq!(component.name, "wasm");
473    }
474
475    #[test]
476    fn test_node20_template_structure() {
477        let template = Runtime::Node20.template();
478        let dockerfile = Dockerfile::parse(template).expect("Should parse");
479
480        // Should be multi-stage
481        assert_eq!(dockerfile.stages.len(), 2);
482
483        // First stage is builder
484        assert_eq!(dockerfile.stages[0].name, Some("builder".to_string()));
485
486        // Final stage should have USER instruction for security
487        let final_stage = dockerfile.final_stage().unwrap();
488        let has_user = final_stage
489            .instructions
490            .iter()
491            .any(|i| matches!(i, crate::Instruction::User(_)));
492        assert!(has_user, "Node template should run as non-root user");
493    }
494
495    #[test]
496    fn test_rust_template_structure() {
497        let template = Runtime::Rust.template();
498        let dockerfile = Dockerfile::parse(template).expect("Should parse");
499
500        // Should be multi-stage
501        assert_eq!(dockerfile.stages.len(), 2);
502
503        // First stage is builder
504        assert_eq!(dockerfile.stages[0].name, Some("builder".to_string()));
505    }
506
507    #[test]
508    fn test_windows_nanoserver_from_name() {
509        assert_eq!(
510            Runtime::from_name("windows-nanoserver"),
511            Some(Runtime::WindowsNanoserver)
512        );
513        assert_eq!(
514            Runtime::from_name("nanoserver"),
515            Some(Runtime::WindowsNanoserver)
516        );
517        assert_eq!(
518            Runtime::from_name("Windows-Nanoserver"),
519            Some(Runtime::WindowsNanoserver)
520        );
521    }
522
523    #[test]
524    fn test_windows_servercore_from_name() {
525        assert_eq!(
526            Runtime::from_name("windows-servercore"),
527            Some(Runtime::WindowsServerCore)
528        );
529        assert_eq!(
530            Runtime::from_name("windows-server-core"),
531            Some(Runtime::WindowsServerCore)
532        );
533        assert_eq!(
534            Runtime::from_name("servercore"),
535            Some(Runtime::WindowsServerCore)
536        );
537    }
538
539    #[test]
540    fn test_windows_nanoserver_template_structure() {
541        let template = Runtime::WindowsNanoserver.template();
542        let dockerfile = Dockerfile::parse(template).expect("nanoserver template should parse");
543
544        // Single-stage minimal template.
545        assert_eq!(dockerfile.stages.len(), 1);
546
547        let stage = &dockerfile.stages[0];
548        let base = stage.base_image.to_string();
549        assert!(
550            base.contains("nanoserver"),
551            "nanoserver template must FROM an mcr nanoserver image, got {base}"
552        );
553
554        // Must set USER to ContainerAdministrator (the parser preserves the
555        // argument verbatim, so trim before comparing).
556        let has_user = stage.instructions.iter().any(
557            |i| matches!(i, crate::Instruction::User(u) if u.trim() == "ContainerAdministrator"),
558        );
559        assert!(
560            has_user,
561            "nanoserver template must set USER ContainerAdministrator"
562        );
563
564        // Must set a Windows-style WORKDIR.
565        let has_windows_workdir = stage
566            .instructions
567            .iter()
568            .any(|i| matches!(i, crate::Instruction::Workdir(w) if w.trim().starts_with("C:")));
569        assert!(
570            has_windows_workdir,
571            "nanoserver template must set a Windows WORKDIR (C:\\...)"
572        );
573
574        // Must have a CMD.
575        let has_cmd = stage
576            .instructions
577            .iter()
578            .any(|i| matches!(i, crate::Instruction::Cmd(_)));
579        assert!(has_cmd, "nanoserver template must define a CMD");
580    }
581
582    #[test]
583    fn test_windows_servercore_template_structure() {
584        let template = Runtime::WindowsServerCore.template();
585        let dockerfile = Dockerfile::parse(template).expect("servercore template should parse");
586
587        assert_eq!(dockerfile.stages.len(), 1);
588
589        let stage = &dockerfile.stages[0];
590        let base = stage.base_image.to_string();
591        assert!(
592            base.contains("servercore"),
593            "servercore template must FROM an mcr servercore image, got {base}"
594        );
595
596        // Servercore bundles `PowerShell` — template must SHELL into it so
597        // subsequent RUNs accept `PowerShell` syntax.
598        let has_powershell_shell = stage.instructions.iter().any(|i| {
599            matches!(
600                i,
601                crate::Instruction::Shell(argv)
602                    if argv.first().map(String::as_str) == Some("powershell")
603            )
604        });
605        assert!(
606            has_powershell_shell,
607            "servercore template must switch SHELL to powershell"
608        );
609
610        // USER + WORKDIR sanity checks (same skeleton as nanoserver); parser
611        // preserves the raw argument so we trim before comparing.
612        let has_user = stage.instructions.iter().any(
613            |i| matches!(i, crate::Instruction::User(u) if u.trim() == "ContainerAdministrator"),
614        );
615        assert!(
616            has_user,
617            "servercore template must set USER ContainerAdministrator"
618        );
619
620        let has_windows_workdir = stage
621            .instructions
622            .iter()
623            .any(|i| matches!(i, crate::Instruction::Workdir(w) if w.trim().starts_with("C:")));
624        assert!(
625            has_windows_workdir,
626            "servercore template must set a Windows WORKDIR (C:\\...)"
627        );
628    }
629
630    #[test]
631    fn test_windows_templates_listed_in_all() {
632        let names: Vec<&str> = Runtime::all().iter().map(|info| info.name).collect();
633        assert!(
634            names.contains(&"windows-nanoserver"),
635            "windows-nanoserver missing from Runtime::all()"
636        );
637        assert!(
638            names.contains(&"windows-servercore"),
639            "windows-servercore missing from Runtime::all()"
640        );
641    }
642
643    #[test]
644    fn test_get_windows_templates_by_name() {
645        let nano = get_template_by_name("windows-nanoserver");
646        assert!(nano.is_some(), "windows-nanoserver template must resolve");
647        assert!(nano.unwrap().contains("nanoserver"));
648
649        let sc = get_template_by_name("windows-servercore");
650        assert!(sc.is_some(), "windows-servercore template must resolve");
651        assert!(sc.unwrap().contains("servercore"));
652    }
653
654    #[test]
655    fn test_list_templates() {
656        let templates = list_templates();
657        assert!(!templates.is_empty());
658        assert!(templates.iter().any(|t| t.name == "node20"));
659        assert!(templates.iter().any(|t| t.name == "rust"));
660        assert!(templates.iter().any(|t| t.name == "go"));
661    }
662
663    #[test]
664    fn test_get_template_by_name() {
665        let template = get_template_by_name("node20");
666        assert!(template.is_some());
667        assert!(template.unwrap().contains("node:20"));
668
669        let template = get_template_by_name("unknown");
670        assert!(template.is_none());
671    }
672
673    #[test]
674    fn test_resolve_runtime_explicit() {
675        let dir = TempDir::new().unwrap();
676
677        // Explicit name takes precedence
678        let runtime = resolve_runtime(Some("rust"), dir.path(), false);
679        assert_eq!(runtime, Some(Runtime::Rust));
680    }
681
682    #[test]
683    fn test_resolve_runtime_detect() {
684        let dir = TempDir::new().unwrap();
685        fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
686
687        // Auto-detect when no name given
688        let runtime = resolve_runtime(None, dir.path(), false);
689        assert_eq!(runtime, Some(Runtime::Rust));
690    }
691
692    #[test]
693    fn test_runtime_display() {
694        assert_eq!(format!("{}", Runtime::Node20), "node20");
695        assert_eq!(format!("{}", Runtime::Rust), "rust");
696    }
697
698    #[test]
699    fn test_runtime_from_str() {
700        let runtime: Result<Runtime, _> = "node20".parse();
701        assert_eq!(runtime, Ok(Runtime::Node20));
702
703        let runtime: Result<Runtime, _> = "unknown".parse();
704        assert!(runtime.is_err());
705    }
706}