1mod 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60pub enum Runtime {
61 Node20,
63 Node22,
65 Python312,
67 Python313,
69 Rust,
71 Go,
73 Deno,
75 Bun,
77 WindowsNanoserver,
82 WindowsServerCore,
87 Wasm(WasmTargetHint),
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
102pub enum WasmTargetHint {
103 Module,
105 Component,
107 #[default]
109 Auto,
110}
111
112impl WasmTargetHint {
113 #[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 #[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 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 #[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 #[must_use]
236 pub fn info(&self) -> &'static RuntimeInfo {
237 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 #[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 fn wasm_zimagefile(hint: WasmTargetHint) -> &'static str {
288 match hint {
289 WasmTargetHint::Component => "wasm:\n target: preview2\n",
291 WasmTargetHint::Module => "wasm:\n target: preview1\n",
294 WasmTargetHint::Auto => "wasm: {}\n",
296 }
297 }
298
299 #[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#[derive(Debug, Clone, Copy)]
322pub struct RuntimeInfo {
323 pub runtime: Runtime,
325 pub name: &'static str,
327 pub description: &'static str,
329 pub detect_files: &'static [&'static str],
331}
332
333#[must_use]
335pub fn list_templates() -> Vec<&'static RuntimeInfo> {
336 Runtime::all().iter().collect()
337}
338
339#[must_use]
341pub fn get_template(runtime: Runtime) -> &'static str {
342 runtime.template()
343}
344
345#[must_use]
347pub fn get_template_by_name(name: &str) -> Option<&'static str> {
348 Runtime::from_name(name).map(|r| r.template())
349}
350
351pub fn resolve_runtime(
353 runtime_name: Option<&str>,
354 context_path: impl AsRef<Path>,
355 use_version_hints: bool,
356) -> Option<Runtime> {
357 if let Some(name) = runtime_name {
359 return Runtime::from_name(name);
360 }
361
362 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 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 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 assert_eq!(dockerfile.stages.len(), 2);
482
483 assert_eq!(dockerfile.stages[0].name, Some("builder".to_string()));
485
486 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 assert_eq!(dockerfile.stages.len(), 2);
502
503 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 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 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 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 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 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 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 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 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}