Skip to main content

sbox/
init.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::ExitCode;
4
5use dialoguer::{Confirm, Input, Select, theme::ColorfulTheme};
6
7use crate::cli::{Cli, InitCommand};
8use crate::error::SboxError;
9
10pub fn execute(cli: &Cli, command: &InitCommand) -> Result<ExitCode, SboxError> {
11    if command.interactive {
12        return execute_interactive(cli, command);
13    }
14
15    if command.from_lockfile {
16        return execute_from_lockfile(cli, command);
17    }
18
19    let target = resolve_output_path(cli, command)?;
20    if target.exists() && !command.force {
21        return Err(SboxError::InitConfigExists { path: target });
22    }
23
24    if let Some(parent) = target.parent() {
25        fs::create_dir_all(parent).map_err(|source| SboxError::InitWrite {
26            path: target.clone(),
27            source,
28        })?;
29    }
30
31    let preset = command.preset.as_deref().unwrap_or("generic");
32    let template = render_template(preset)?;
33    fs::write(&target, template).map_err(|source| SboxError::InitWrite {
34        path: target.clone(),
35        source,
36    })?;
37
38    println!("created {}", target.display());
39    Ok(ExitCode::SUCCESS)
40}
41
42fn execute_from_lockfile(cli: &Cli, command: &InitCommand) -> Result<ExitCode, SboxError> {
43    let cwd = std::env::current_dir()
44        .map_err(|source| SboxError::CurrentDirectory { source })?;
45
46    let detected = detect_lockfile_preset(&cwd);
47    let preset = detected.ok_or_else(|| SboxError::ConfigValidation {
48        message: "no recognised lockfile found in the current directory. \
49                  Supported: package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lock(b), \
50                  uv.lock, requirements.txt, poetry.lock, Cargo.lock, go.sum, \
51                  composer.lock, Gemfile.lock"
52            .to_string(),
53    })?;
54
55    println!("detected lockfile → using preset: {preset}");
56
57    let target = resolve_output_path(cli, command)?;
58    if target.exists() && !command.force {
59        return Err(SboxError::InitConfigExists { path: target });
60    }
61
62    if let Some(parent) = target.parent() {
63        fs::create_dir_all(parent).map_err(|source| SboxError::InitWrite {
64            path: target.clone(),
65            source,
66        })?;
67    }
68
69    let template = render_template(preset)?;
70    fs::write(&target, template).map_err(|source| SboxError::InitWrite {
71        path: target.clone(),
72        source,
73    })?;
74
75    println!("created {}", target.display());
76    Ok(ExitCode::SUCCESS)
77}
78
79/// Maps a lockfile filename to its preset name. Checked in priority order — more specific
80/// lockfiles (uv.lock, poetry.lock) are checked before generic ones (requirements.txt).
81fn detect_lockfile_preset(dir: &Path) -> Option<&'static str> {
82    const LOCKFILE_MAP: &[(&str, &str)] = &[
83        ("package-lock.json", "npm"),
84        ("npm-shrinkwrap.json", "npm"),
85        ("yarn.lock", "yarn"),
86        ("pnpm-lock.yaml", "pnpm"),
87        ("bun.lockb", "bun"),
88        ("bun.lock", "bun"),
89        ("uv.lock", "uv"),
90        ("poetry.lock", "poetry"),
91        ("requirements.txt", "pip"),
92        ("Cargo.lock", "cargo"),
93        ("go.sum", "go"),
94        ("composer.lock", "composer"),
95        ("Gemfile.lock", "bundler"),
96    ];
97
98    for &(filename, preset) in LOCKFILE_MAP {
99        if dir.join(filename).exists() {
100            return Some(preset);
101        }
102    }
103    None
104}
105
106// ── Interactive wizard ────────────────────────────────────────────────────────
107
108fn execute_interactive(cli: &Cli, command: &InitCommand) -> Result<ExitCode, SboxError> {
109    let target = resolve_output_path(cli, command)?;
110    if target.exists() && !command.force {
111        return Err(SboxError::InitConfigExists { path: target });
112    }
113
114    let theme = ColorfulTheme::default();
115    println!("sbox interactive setup");
116    println!("──────────────────────");
117    println!("Use arrow keys to select, Enter to confirm.\n");
118
119    // ── Simple vs Advanced ────────────────────────────────────────────────────
120    let mode_idx = Select::with_theme(&theme)
121        .with_prompt("Setup mode")
122        .items(&[
123            "simple   — package_manager preset (recommended)",
124            "advanced — manual profiles and dispatch rules",
125        ])
126        .default(0)
127        .interact()
128        .map_err(|_| SboxError::CurrentDirectory {
129            source: std::io::Error::other("prompt cancelled"),
130        })?;
131
132    let config = if mode_idx == 0 {
133        execute_interactive_simple(&theme)?
134    } else {
135        execute_interactive_advanced(&theme)?
136    };
137
138    // ── Write ─────────────────────────────────────────────────────────────────
139    if let Some(parent) = target.parent() {
140        fs::create_dir_all(parent).map_err(|source| SboxError::InitWrite {
141            path: target.clone(),
142            source,
143        })?;
144    }
145    fs::write(&target, &config).map_err(|source| SboxError::InitWrite {
146        path: target.clone(),
147        source,
148    })?;
149
150    println!("\ncreated {}", target.display());
151    println!("Run `sbox plan -- <command>` to preview the resolved policy.");
152    Ok(ExitCode::SUCCESS)
153}
154
155fn detect_dockerfile(cwd: &Path) -> Option<String> {
156    for name in &[
157        "Dockerfile",
158        "Dockerfile.dev",
159        "Dockerfile.local",
160        "dockerfile",
161    ] {
162        if cwd.join(name).exists() {
163            return Some(name.to_string());
164        }
165    }
166    None
167}
168
169/// Well-known infrastructure/sidecar image name fragments to skip when scanning compose files.
170/// We want the application service image, not postgres/redis/etc.
171const COMPOSE_SIDECAR_PREFIXES: &[&str] = &[
172    "postgres",
173    "mysql",
174    "mariadb",
175    "mongo",
176    "redis",
177    "rabbitmq",
178    "elasticsearch",
179    "kibana",
180    "grafana",
181    "prometheus",
182    "influxdb",
183    "nginx",
184    "traefik",
185    "caddy",
186    "haproxy",
187    "zookeeper",
188    "kafka",
189    "memcached",
190    "vault",
191];
192
193/// Well-known service names that are almost certainly the primary application service.
194const APP_SERVICE_NAMES: &[&str] = &[
195    "app",
196    "web",
197    "api",
198    "backend",
199    "server",
200    "frontend",
201    "application",
202    "service",
203];
204
205fn detect_compose_image(cwd: &Path) -> Option<String> {
206    for name in &[
207        "compose.yaml",
208        "compose.yml",
209        "docker-compose.yml",
210        "docker-compose.yaml",
211    ] {
212        let path = cwd.join(name);
213        if !path.exists() {
214            continue;
215        }
216
217        let text = match fs::read_to_string(&path) {
218            Ok(t) => t,
219            Err(_) => continue,
220        };
221
222        // Scan the compose file tracking service context so we can disambiguate
223        // multi-service files (e.g. app + postgres). We do not do a full YAML parse —
224        // instead we use indentation to detect service-level keys and associate each
225        // `image:` line with the current service name.
226        //
227        // We detect the service-level indent dynamically: the first indented key we see
228        // under `services:` establishes the service-name indent depth. This handles 2-space,
229        // 4-space, and tab indentation without hard-coding 2 spaces.
230        let mut candidates: Vec<(String, String)> = Vec::new();
231        let mut current_service = String::new();
232        let mut in_services = false;
233        let mut service_indent: Option<usize> = None;
234
235        for line in text.lines() {
236            let trimmed = line.trim();
237            if trimmed.is_empty() || trimmed.starts_with('#') {
238                continue;
239            }
240
241            if trimmed == "services:" {
242                in_services = true;
243                service_indent = None;
244                continue;
245            }
246            // A top-level key (no leading whitespace) that isn't `services:` ends the block.
247            if !line.starts_with(' ') && !line.starts_with('\t') {
248                in_services = false;
249                continue;
250            }
251
252            if !in_services {
253                continue;
254            }
255
256            let indent = line.len() - line.trim_start().len();
257
258            // The first indented key under `services:` tells us the service-name depth.
259            let svc_indent = *service_indent.get_or_insert(indent);
260
261            // A line at exactly the service-name depth ending in `:` is a service name.
262            if indent == svc_indent && trimmed.ends_with(':') && !trimmed.contains(' ') {
263                current_service = trimmed.trim_end_matches(':').to_string();
264                continue;
265            }
266
267            // `image:` lines appear one level deeper than the service name.
268            if indent > svc_indent {
269                if let Some(rest) = trimmed.strip_prefix("image:") {
270                    let img = rest.trim().trim_matches('"').trim_matches('\'');
271                    if img.is_empty() {
272                        continue;
273                    }
274                    let img_lower = img.to_lowercase();
275                    let is_sidecar = COMPOSE_SIDECAR_PREFIXES
276                        .iter()
277                        .any(|p| img_lower.starts_with(p));
278                    if !is_sidecar {
279                        candidates.push((current_service.clone(), img.to_string()));
280                    }
281                }
282            }
283        }
284
285        if candidates.is_empty() {
286            continue;
287        }
288
289        // Single candidate — done.
290        if candidates.len() == 1 {
291            return Some(candidates.remove(0).1);
292        }
293
294        // Multiple app services: prefer well-known primary service names.
295        for &preferred in APP_SERVICE_NAMES {
296            if let Some((_, img)) = candidates.iter().find(|(svc, _)| svc == preferred) {
297                return Some(img.clone());
298            }
299        }
300
301        // Fall back to the first candidate found.
302        return Some(candidates.remove(0).1);
303    }
304    None
305}
306
307fn execute_interactive_simple(theme: &ColorfulTheme) -> Result<String, SboxError> {
308    let cwd = std::env::current_dir().map_err(|source| SboxError::CurrentDirectory { source })?;
309
310    // ── Detect existing Docker infrastructure ─────────────────────────────────
311    let found_dockerfile = detect_dockerfile(&cwd);
312    let found_compose_image = detect_compose_image(&cwd);
313
314    // ── Package manager ───────────────────────────────────────────────────────
315    let pm_idx = Select::with_theme(theme)
316        .with_prompt("Package manager")
317        .items(&[
318            "npm", "yarn", "pnpm", "bun", "uv", "pip", "poetry", "cargo", "go",
319        ])
320        .default(0)
321        .interact()
322        .map_err(|_| SboxError::CurrentDirectory {
323            source: std::io::Error::other("prompt cancelled"),
324        })?;
325    let (pm_name, stock_image) = [
326        ("npm", "node:22-bookworm-slim"),
327        ("yarn", "node:22-bookworm-slim"),
328        ("pnpm", "node:22-bookworm-slim"),
329        ("bun", "oven/bun:1"),
330        ("uv", "ghcr.io/astral-sh/uv:python3.13-bookworm-slim"),
331        ("pip", "python:3.13-slim"),
332        ("poetry", "python:3.13-slim"),
333        ("cargo", "rust:1-bookworm"),
334        ("go", "golang:1.23-bookworm"),
335    ][pm_idx];
336
337    // ── Image — prefer existing Docker infrastructure over stock public images ─
338    let image_block: String = if let Some(ref dockerfile) = found_dockerfile {
339        let use_it = Confirm::with_theme(theme)
340            .with_prompt(format!(
341                "Found `{dockerfile}` — use it as the container image?"
342            ))
343            .default(true)
344            .interact()
345            .map_err(|_| SboxError::CurrentDirectory {
346                source: std::io::Error::other("prompt cancelled"),
347            })?;
348        if use_it {
349            format!("image:\n  build: {dockerfile}\n")
350        } else {
351            let img = prompt_image(theme, stock_image)?;
352            format!("image:\n  ref: {img}\n")
353        }
354    } else if let Some(ref compose_image) = found_compose_image {
355        let use_it = Confirm::with_theme(theme)
356            .with_prompt(format!(
357                "Found image `{compose_image}` in compose file — use it?"
358            ))
359            .default(true)
360            .interact()
361            .map_err(|_| SboxError::CurrentDirectory {
362                source: std::io::Error::other("prompt cancelled"),
363            })?;
364        if use_it {
365            format!("image:\n  ref: {compose_image}\n")
366        } else {
367            let img = prompt_image(theme, stock_image)?;
368            format!("image:\n  ref: {img}\n")
369        }
370    } else {
371        let img = prompt_image(theme, stock_image)?;
372        format!("image:\n  ref: {img}\n")
373    };
374
375    // ── Backend ───────────────────────────────────────────────────────────────
376    let backend_idx = Select::with_theme(theme)
377        .with_prompt("Container backend")
378        .items(&["auto (detect podman or docker)", "podman", "docker"])
379        .default(0)
380        .interact()
381        .map_err(|_| SboxError::CurrentDirectory {
382            source: std::io::Error::other("prompt cancelled"),
383        })?;
384    let runtime_block = match backend_idx {
385        1 => "runtime:\n  backend: podman\n  rootless: true\n",
386        2 => "runtime:\n  backend: docker\n  rootless: false\n",
387        _ => "",
388    };
389
390    let exclude_paths = default_exclude_paths(pm_name);
391
392    Ok(format!(
393        "version: 1
394
395{runtime_block}
396workspace:
397  mount: /workspace
398  writable: false
399  exclude_paths:
400{exclude_paths}
401{image_block}
402environment:
403  pass_through:
404    - TERM
405
406package_manager:
407  name: {pm_name}
408"
409    ))
410}
411
412fn prompt_image(theme: &ColorfulTheme, default: &str) -> Result<String, SboxError> {
413    Input::with_theme(theme)
414        .with_prompt("Container image")
415        .default(default.to_string())
416        .interact_text()
417        .map_err(|_| SboxError::CurrentDirectory {
418            source: std::io::Error::other("prompt cancelled"),
419        })
420}
421
422fn default_exclude_paths(pm_name: &str) -> String {
423    let common = vec!["    - \".ssh/*\"", "    - \".aws/*\""];
424    let extras: &[&str] = match pm_name {
425        "npm" | "yarn" | "pnpm" | "bun" => &[
426            "    - .env",
427            "    - .env.local",
428            "    - .env.production",
429            "    - .env.development",
430            "    - .npmrc",
431            "    - .netrc",
432        ],
433        "uv" | "pip" | "poetry" => &["    - .env", "    - .env.local", "    - .netrc"],
434        _ => &[],
435    };
436
437    let mut lines: Vec<&str> = extras.to_vec();
438    lines.extend_from_slice(&common);
439    lines.join("\n") + "\n"
440}
441
442fn execute_interactive_advanced(theme: &ColorfulTheme) -> Result<String, SboxError> {
443    let cwd = std::env::current_dir().map_err(|source| SboxError::CurrentDirectory { source })?;
444    let found_dockerfile = detect_dockerfile(&cwd);
445    let found_compose_image = detect_compose_image(&cwd);
446
447    // ── Backend ───────────────────────────────────────────────────────────────
448    let backend_idx = Select::with_theme(theme)
449        .with_prompt("Container backend")
450        .items(&["auto (detect podman or docker)", "podman", "docker"])
451        .default(0)
452        .interact()
453        .map_err(|_| SboxError::CurrentDirectory {
454            source: std::io::Error::other("prompt cancelled"),
455        })?;
456    let (backend_line, rootless_line) = match backend_idx {
457        1 => ("  backend: podman", "  rootless: true"),
458        2 => ("  backend: docker", "  rootless: false"),
459        _ => ("  # backend: auto-detected", "  rootless: true"),
460    };
461
462    // ── Preset / image ────────────────────────────────────────────────────────
463    // Build the ecosystem list, prepending detected local infrastructure.
464    let mut image_choices: Vec<String> = Vec::new();
465    if let Some(ref df) = found_dockerfile {
466        image_choices.push(format!("existing Dockerfile ({df})"));
467    }
468    if let Some(ref img) = found_compose_image {
469        image_choices.push(format!("image from compose ({img})"));
470    }
471    image_choices.extend_from_slice(&[
472        "node".into(),
473        "python".into(),
474        "rust".into(),
475        "go".into(),
476        "generic".into(),
477        "custom image".into(),
478    ]);
479
480    let image_idx = Select::with_theme(theme)
481        .with_prompt("Container image source")
482        .items(&image_choices)
483        .default(0)
484        .interact()
485        .map_err(|_| SboxError::CurrentDirectory {
486            source: std::io::Error::other("prompt cancelled"),
487        })?;
488
489    // Resolve offset caused by prepended Dockerfile/compose choices.
490    let offset = (found_dockerfile.is_some() as usize) + (found_compose_image.is_some() as usize);
491    let ecosystem_names = ["node", "python", "rust", "go", "generic", "custom"];
492
493    let (image_yaml, preset, default_writable_paths, default_dispatch) = if found_dockerfile
494        .is_some()
495        && image_idx == 0
496    {
497        let df = found_dockerfile.as_deref().unwrap();
498        (
499            format!("image:\n  build: {df}"),
500            "custom",
501            vec![],
502            String::new(),
503        )
504    } else if found_compose_image.is_some() && image_idx == (found_dockerfile.is_some() as usize) {
505        let img = found_compose_image.as_deref().unwrap();
506        (
507            format!("image:\n  ref: {img}"),
508            "custom",
509            vec![],
510            String::new(),
511        )
512    } else {
513        let preset = ecosystem_names[image_idx - offset];
514        let (default_image, writable, dispatch) = match preset {
515            "node" => (
516                "node:22-bookworm-slim",
517                vec!["node_modules", "package-lock.json", "dist"],
518                node_dispatch(),
519            ),
520            "python" => ("python:3.13-slim", vec![".venv"], python_dispatch()),
521            "rust" => ("rust:1-bookworm", vec!["target"], rust_dispatch()),
522            "go" => ("golang:1.23-bookworm", vec![], go_dispatch()),
523            _ => ("ubuntu:24.04", vec![], String::new()),
524        };
525        let img = prompt_image(theme, default_image)?;
526        (format!("image:\n  ref: {img}"), preset, writable, dispatch)
527    };
528
529    // ── Network ───────────────────────────────────────────────────────────────
530    let network_idx = Select::with_theme(theme)
531        .with_prompt("Default network access in sandbox")
532        .items(&[
533            "off  — no internet (recommended for installs)",
534            "on   — full internet access",
535        ])
536        .default(0)
537        .interact()
538        .map_err(|_| SboxError::CurrentDirectory {
539            source: std::io::Error::other("prompt cancelled"),
540        })?;
541    let network = if network_idx == 0 { "off" } else { "on" };
542
543    // ── Workspace writable paths ──────────────────────────────────────────────
544    let default_wp = default_writable_paths.join(", ");
545    let wp_input: String = Input::with_theme(theme)
546        .with_prompt("Writable paths in workspace (comma-separated)")
547        .default(default_wp)
548        .allow_empty(true)
549        .interact_text()
550        .map_err(|_| SboxError::CurrentDirectory {
551            source: std::io::Error::other("prompt cancelled"),
552        })?;
553    let writable_paths: Vec<String> = wp_input
554        .split(',')
555        .map(|s| s.trim().to_string())
556        .filter(|s| !s.is_empty())
557        .collect();
558
559    // ── Dispatch rules ────────────────────────────────────────────────────────
560    let add_dispatch = if !default_dispatch.is_empty() {
561        Confirm::with_theme(theme)
562            .with_prompt(format!("Add default dispatch rules for {preset}?"))
563            .default(true)
564            .interact()
565            .map_err(|_| SboxError::CurrentDirectory {
566                source: std::io::Error::other("prompt cancelled"),
567            })?
568    } else {
569        false
570    };
571
572    // ── Render ────────────────────────────────────────────────────────────────
573    let writable_paths_yaml = if writable_paths.is_empty() {
574        "    []".to_string()
575    } else {
576        writable_paths
577            .iter()
578            .map(|p| format!("    - {p}"))
579            .collect::<Vec<_>>()
580            .join("\n")
581    };
582
583    let workspace_writable = writable_paths.is_empty();
584    let dispatch_section = if add_dispatch {
585        format!("dispatch:\n{default_dispatch}")
586    } else {
587        "dispatch: {}".to_string()
588    };
589
590    Ok(format!(
591        "version: 1
592
593runtime:
594{backend_line}
595{rootless_line}
596
597workspace:
598  root: .
599  mount: /workspace
600  writable: {workspace_writable}
601  writable_paths:
602{writable_paths_yaml}
603  exclude_paths:
604    - .env
605    - .env.local
606    - .env.production
607    - .env.development
608    - \"*.pem\"
609    - \"*.key\"
610    - .npmrc
611    - .netrc
612    - \".ssh/*\"
613    - \".aws/*\"
614
615{image_yaml}
616
617environment:
618  pass_through:
619    - TERM
620  set: {{}}
621  deny: []
622
623profiles:
624  default:
625    mode: sandbox
626    network: {network}
627    writable: true
628    no_new_privileges: true
629
630{dispatch_section}
631"
632    ))
633}
634
635// ── Default dispatch rules per preset (advanced mode) ────────────────────────
636
637fn node_dispatch() -> String {
638    "  npm-install:\n    match:\n      - \"npm install*\"\n      - \"npm ci\"\n    profile: default\n  \
639     yarn-install:\n    match:\n      - \"yarn install*\"\n    profile: default\n  \
640     pnpm-install:\n    match:\n      - \"pnpm install*\"\n    profile: default\n"
641        .to_string()
642}
643
644fn python_dispatch() -> String {
645    "  pip-install:\n    match:\n      - \"pip install*\"\n      - \"pip3 install*\"\n    profile: default\n  \
646     uv-sync:\n    match:\n      - \"uv sync*\"\n    profile: default\n  \
647     poetry-install:\n    match:\n      - \"poetry install*\"\n    profile: default\n"
648        .to_string()
649}
650
651fn rust_dispatch() -> String {
652    "  cargo-build:\n    match:\n      - \"cargo build*\"\n      - \"cargo check*\"\n    profile: default\n"
653        .to_string()
654}
655
656fn go_dispatch() -> String {
657    "  go-get:\n    match:\n      - \"go get*\"\n      - \"go mod download*\"\n    profile: default\n"
658        .to_string()
659}
660
661// ── Non-interactive (--preset) ────────────────────────────────────────────────
662
663fn resolve_output_path(cli: &Cli, command: &InitCommand) -> Result<PathBuf, SboxError> {
664    let cwd = std::env::current_dir().map_err(|source| SboxError::CurrentDirectory { source })?;
665    let base = cli.workspace.clone().unwrap_or(cwd);
666
667    Ok(match &command.output {
668        Some(path) if path.is_absolute() => path.clone(),
669        Some(path) => base.join(path),
670        None => base.join("sbox.yaml"),
671    })
672}
673
674pub fn render_template(preset: &str) -> Result<String, SboxError> {
675    match preset {
676        "node" => Ok("version: 1
677
678workspace:
679  mount: /workspace
680  writable: false
681  exclude_paths:
682    - .env
683    - .env.local
684    - .env.production
685    - .env.development
686    - .npmrc
687    - .netrc
688    - \".ssh/*\"
689    - \".aws/*\"
690
691image:
692  ref: node:22-bookworm-slim
693
694environment:
695  pass_through:
696    - TERM
697
698package_manager:
699  name: npm
700"
701        .to_string()),
702
703        "python" => Ok("version: 1
704
705workspace:
706  mount: /workspace
707  writable: false
708  exclude_paths:
709    - .env
710    - .env.local
711    - .netrc
712    - \".ssh/*\"
713    - \".aws/*\"
714
715image:
716  ref: ghcr.io/astral-sh/uv:python3.13-bookworm-slim
717
718environment:
719  pass_through:
720    - TERM
721
722package_manager:
723  name: uv
724"
725        .to_string()),
726
727        "rust" => Ok("version: 1
728
729workspace:
730  mount: /workspace
731  writable: false
732  exclude_paths:
733    - \".ssh/*\"
734    - \".aws/*\"
735
736image:
737  ref: rust:1-bookworm
738
739environment:
740  pass_through:
741    - TERM
742
743package_manager:
744  name: cargo
745"
746        .to_string()),
747
748        "go" => Ok("version: 1
749
750workspace:
751  mount: /workspace
752  writable: false
753  exclude_paths:
754    - \".ssh/*\"
755    - \".aws/*\"
756
757image:
758  ref: golang:1.23-bookworm
759
760environment:
761  pass_through:
762    - TERM
763
764package_manager:
765  name: go
766"
767        .to_string()),
768
769        "generic" | "polyglot" => Ok("version: 1
770
771runtime:
772  backend: podman
773  rootless: true
774
775workspace:
776  root: .
777  mount: /workspace
778  writable: true
779  exclude_paths:
780    - \".ssh/*\"
781    - \".aws/*\"
782
783image:
784  ref: ubuntu:24.04
785
786environment:
787  pass_through:
788    - TERM
789  set: {}
790  deny: []
791
792profiles:
793  default:
794    mode: sandbox
795    network: off
796    writable: true
797    no_new_privileges: true
798
799  host:
800    mode: host
801    network: on
802    writable: true
803
804dispatch: {}
805"
806        .to_string()),
807
808        other => Err(SboxError::UnknownPreset {
809            name: other.to_string(),
810        }),
811    }
812}
813
814#[cfg(test)]
815mod tests {
816    use super::render_template;
817
818    #[test]
819    fn renders_node_template_with_package_manager() {
820        let rendered = render_template("node").expect("node preset should exist");
821        assert!(rendered.contains("ref: node:22-bookworm-slim"));
822        assert!(rendered.contains("package_manager:"));
823        assert!(rendered.contains("name: npm"));
824        assert!(!rendered.contains("profiles:"));
825    }
826
827    #[test]
828    fn renders_python_template_with_package_manager() {
829        let rendered = render_template("python").expect("python preset should exist");
830        assert!(rendered.contains("ghcr.io/astral-sh/uv:python3.13-bookworm-slim"));
831        assert!(rendered.contains("name: uv"));
832    }
833
834    #[test]
835    fn renders_rust_template_with_package_manager() {
836        let rendered = render_template("rust").expect("rust preset should exist");
837        assert!(rendered.contains("ref: rust:1-bookworm"));
838        assert!(rendered.contains("name: cargo"));
839    }
840
841    #[test]
842    fn renders_generic_template_with_profiles() {
843        let rendered = render_template("generic").expect("generic preset should exist");
844        assert!(rendered.contains("profiles:"));
845        assert!(!rendered.contains("package_manager:"));
846    }
847
848    use super::detect_lockfile_preset;
849
850    #[test]
851    fn detects_npm_from_package_lock_json() {
852        let dir = tempfile::tempdir().unwrap();
853        std::fs::write(dir.path().join("package-lock.json"), "{}").unwrap();
854        assert_eq!(detect_lockfile_preset(dir.path()), Some("npm"));
855    }
856
857    #[test]
858    fn detects_yarn_from_yarn_lock() {
859        let dir = tempfile::tempdir().unwrap();
860        std::fs::write(dir.path().join("yarn.lock"), "").unwrap();
861        assert_eq!(detect_lockfile_preset(dir.path()), Some("yarn"));
862    }
863
864    #[test]
865    fn detects_uv_over_requirements_txt_when_both_present() {
866        let dir = tempfile::tempdir().unwrap();
867        std::fs::write(dir.path().join("uv.lock"), "").unwrap();
868        std::fs::write(dir.path().join("requirements.txt"), "").unwrap();
869        // uv.lock appears before requirements.txt in the priority list
870        assert_eq!(detect_lockfile_preset(dir.path()), Some("uv"));
871    }
872
873    #[test]
874    fn detects_composer_from_composer_lock() {
875        let dir = tempfile::tempdir().unwrap();
876        std::fs::write(dir.path().join("composer.lock"), "{}").unwrap();
877        assert_eq!(detect_lockfile_preset(dir.path()), Some("composer"));
878    }
879
880    #[test]
881    fn detects_bundler_from_gemfile_lock() {
882        let dir = tempfile::tempdir().unwrap();
883        std::fs::write(dir.path().join("Gemfile.lock"), "").unwrap();
884        assert_eq!(detect_lockfile_preset(dir.path()), Some("bundler"));
885    }
886
887    #[test]
888    fn returns_none_when_no_lockfile_found() {
889        let dir = tempfile::tempdir().unwrap();
890        assert_eq!(detect_lockfile_preset(dir.path()), None);
891    }
892}