ferro_cli/commands/
docker_init.rs1use std::fs;
16use std::path::{Path, PathBuf};
17
18use crate::deploy::bin_detect::detect_web_bin;
19use crate::project::{find_project_root, package_name, read_bins, read_deploy_metadata};
20use crate::templates::docker::{
21 dockerignore_template, read_rust_channel, render_dockerfile, DockerContext,
22};
23
24pub(crate) struct RenderedFile {
27 pub relative_path: PathBuf,
28 pub contents: String,
29}
30
31pub(crate) fn print_dry_run(files: &[RenderedFile]) {
32 for f in files {
33 println!("--- {} ---", f.relative_path.display());
34 println!("{}", f.contents);
35 }
36}
37
38pub fn run(force: bool) {
41 run_with(force, None, false);
42}
43
44pub fn run_with(force: bool, ferro_version: Option<String>, dry_run: bool) {
46 if let Err(e) = execute(force, ferro_version.as_deref(), dry_run) {
47 eprintln!("docker:init failed: {e:#}");
48 }
49}
50
51pub fn execute(
54 force: bool,
55 _ferro_version_flag: Option<&str>,
56 dry_run: bool,
57) -> anyhow::Result<()> {
58 let root = find_project_root(None)
59 .map_err(|e| anyhow::anyhow!("could not locate project Cargo.toml: {e}"))?;
60
61 let metadata = read_deploy_metadata(&root)?;
62 let rust_channel = read_rust_channel(&root);
63 let bins: Vec<String> = read_bins(&root).into_iter().map(|b| b.name).collect();
64 let has_frontend = root.join("frontend/package.json").is_file();
65 let copy_dirs_present: Vec<String> = metadata
66 .copy_dirs
67 .iter()
68 .filter(|d| root.join(d).exists())
69 .cloned()
70 .collect();
71
72 let web_bin = detect_web_bin(&root)?;
73 let ctx = DockerContext {
74 rust_channel,
75 has_frontend,
76 bins,
77 web_bin,
78 copy_dirs_present,
79 runtime_apt: metadata.runtime_apt.clone(),
80 };
81
82 let dockerfile = render_dockerfile(&ctx);
85
86 let files: Vec<RenderedFile> = vec![
87 RenderedFile {
88 relative_path: "Dockerfile".into(),
89 contents: dockerfile,
90 },
91 RenderedFile {
92 relative_path: ".dockerignore".into(),
93 contents: dockerignore_template().to_string(),
94 },
95 ];
96
97 if dry_run {
98 print_dry_run(&files);
101 return Ok(());
102 }
103
104 for f in &files {
106 let target = root.join(&f.relative_path);
107 write_if_absent_or_force(&target, &f.contents, force)?;
108 }
109
110 println!(
111 "docker:init wrote Dockerfile and .dockerignore in {}",
112 root.display()
113 );
114
115 let pkg = package_name(&root);
117 print!("{}", docker_init_footer(&pkg));
118 Ok(())
119}
120
121fn docker_init_footer(pkg: &str) -> String {
124 format!(
125 "\nNext steps:\n docker build -t {pkg}:test .\n docker run --rm -p 8080:8080 --env-file .env.production {pkg}:test\n"
126 )
127}
128
129fn write_if_absent_or_force(path: &Path, content: &str, force: bool) -> anyhow::Result<()> {
130 if path.exists() && !force {
131 println!(
132 "skip {}: already exists (use --force to overwrite)",
133 path.display()
134 );
135 return Ok(());
136 }
137 fs::write(path, content)
138 .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display()))?;
139 Ok(())
140}
141
142#[cfg(test)]
143mod footer_tests {
144 use super::*;
145
146 #[test]
147 fn docker_init_footer_contents() {
148 let s = docker_init_footer("myapp");
149 assert!(s.contains("docker build"));
150 assert!(s.contains("docker run"));
151 assert!(s.contains("--env-file .env.production"));
152 assert!(s.contains("myapp:test"));
153 }
154
155 #[test]
156 fn docker_init_footer_line_count() {
157 let s = docker_init_footer("app");
158 let n = s.lines().filter(|l| !l.trim().is_empty()).count();
159 assert!((3..=5).contains(&n), "footer has {n} non-empty lines: {s}");
160 assert!(s.is_ascii(), "footer must be ASCII-only");
161 }
162}