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
79fn 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
106fn 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 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 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
169const 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
193const 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 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 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 let svc_indent = *service_indent.get_or_insert(indent);
260
261 if indent == svc_indent && trimmed.ends_with(':') && !trimmed.contains(' ') {
263 current_service = trimmed.trim_end_matches(':').to_string();
264 continue;
265 }
266
267 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 if candidates.len() == 1 {
291 return Some(candidates.remove(0).1);
292 }
293
294 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 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 let found_dockerfile = detect_dockerfile(&cwd);
312 let found_compose_image = detect_compose_image(&cwd);
313
314 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 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 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 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 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 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 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 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 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 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
635fn 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
661fn 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 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}