Skip to main content

ferro_cli/commands/
docker_init.rs

1//! `ferro docker:init` — generate a production-ready Dockerfile and static
2//! `.dockerignore` from project metadata. Phase 122.2 §3.
3//!
4//! Phase 127 Plan 04: `--dry-run` renders every output file to memory and
5//! prints it to stdout without touching the filesystem (D-17, D-18). The
6//! "Next steps" footer (D-13, D-14) is printed after a successful
7//! non-dry-run invocation and is suppressed in dry-run (D-16). Render
8//! errors remain hard errors in both modes (D-19).
9//!
10//! Phase 130: the dual-manifest pattern is retired. Docker builds read the
11//! project `Cargo.toml` directly; ferro developers who need to point at an
12//! unpublished local checkout maintain an uncommitted `[patch.crates-io]`
13//! block by hand.
14
15use 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, resolve_ferro_version,
22    DockerContext,
23};
24
25/// One rendered output file carried in memory between the render and persist
26/// phases. Enables `--dry-run` to print everything without writing anything.
27pub(crate) struct RenderedFile {
28    pub relative_path: PathBuf,
29    pub contents: String,
30}
31
32pub(crate) fn print_dry_run(files: &[RenderedFile]) {
33    for f in files {
34        println!("--- {} ---", f.relative_path.display());
35        println!("{}", f.contents);
36    }
37}
38
39/// Entry point used by `main.rs`. Returns a process-style exit: prints errors
40/// to stderr and returns without panicking so clap stays happy.
41pub fn run(force: bool) {
42    run_with(force, None, false);
43}
44
45/// Full entry point supporting the `--ferro-version` override and `--dry-run`.
46pub fn run_with(force: bool, ferro_version: Option<String>, dry_run: bool) {
47    if let Err(e) = execute(force, ferro_version.as_deref(), dry_run) {
48        eprintln!("docker:init failed: {e:#}");
49    }
50}
51
52/// Library-level entry point used by integration tests. Returns `Result`
53/// instead of printing to stderr, so tests can assert on failures.
54pub fn execute(force: bool, ferro_version_flag: Option<&str>, dry_run: bool) -> anyhow::Result<()> {
55    let root = find_project_root(None)
56        .map_err(|e| anyhow::anyhow!("could not locate project Cargo.toml: {e}"))?;
57
58    let metadata = read_deploy_metadata(&root)?;
59    let rust_channel = read_rust_channel(&root);
60    let bins: Vec<String> = read_bins(&root).into_iter().map(|b| b.name).collect();
61    let has_frontend = root.join("frontend/package.json").is_file();
62    let copy_dirs_present: Vec<String> = metadata
63        .copy_dirs
64        .iter()
65        .filter(|d| root.join(d).exists())
66        .cloned()
67        .collect();
68
69    let web_bin = detect_web_bin(&root)?;
70    let ferro_version = ferro_version_flag
71        .map(|s| s.to_string())
72        .unwrap_or_else(|| resolve_ferro_version(&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        ferro_version,
81    };
82
83    // Render everything to memory first. Any render error is a hard error in
84    // every mode, including --dry-run (D-19).
85    let dockerfile = render_dockerfile(&ctx);
86
87    let files: Vec<RenderedFile> = vec![
88        RenderedFile {
89            relative_path: "Dockerfile".into(),
90            contents: dockerfile,
91        },
92        RenderedFile {
93            relative_path: ".dockerignore".into(),
94            contents: dockerignore_template().to_string(),
95        },
96    ];
97
98    if dry_run {
99        // D-16, D-17, D-18: print every rendered file, write nothing, suppress
100        // the "Next steps" footer.
101        print_dry_run(&files);
102        return Ok(());
103    }
104
105    // Persist. Template outputs honor --force.
106    for f in &files {
107        let target = root.join(&f.relative_path);
108        write_if_absent_or_force(&target, &f.contents, force)?;
109    }
110
111    println!(
112        "docker:init wrote Dockerfile and .dockerignore in {}",
113        root.display()
114    );
115
116    // D-13, D-14: cargo-style "Next steps" footer.
117    let pkg = package_name(&root);
118    print!("{}", docker_init_footer(&pkg));
119    Ok(())
120}
121
122/// Build the cargo-style "Next steps" footer for `docker:init`. Pure string —
123/// the `print_*` wrapper exists so tests can assert on the content directly.
124fn docker_init_footer(pkg: &str) -> String {
125    format!(
126        "\nNext steps:\n  docker build -t {pkg}:test .\n  docker run --rm -p 8080:8080 --env-file .env.production {pkg}:test\n"
127    )
128}
129
130fn write_if_absent_or_force(path: &Path, content: &str, force: bool) -> anyhow::Result<()> {
131    if path.exists() && !force {
132        println!(
133            "skip {}: already exists (use --force to overwrite)",
134            path.display()
135        );
136        return Ok(());
137    }
138    fs::write(path, content)
139        .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display()))?;
140    Ok(())
141}
142
143#[cfg(test)]
144mod footer_tests {
145    use super::*;
146
147    #[test]
148    fn docker_init_footer_contents() {
149        let s = docker_init_footer("myapp");
150        assert!(s.contains("docker build"));
151        assert!(s.contains("docker run"));
152        assert!(s.contains("--env-file .env.production"));
153        assert!(s.contains("myapp:test"));
154    }
155
156    #[test]
157    fn docker_init_footer_line_count() {
158        let s = docker_init_footer("app");
159        let n = s.lines().filter(|l| !l.trim().is_empty()).count();
160        assert!((3..=5).contains(&n), "footer has {n} non-empty lines: {s}");
161        assert!(s.is_ascii(), "footer must be ASCII-only");
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use std::fs;
169    use tempfile::TempDir;
170
171    /// Phase 156 Plan 04: assert that the rendered Dockerfile pins to the
172    /// Cargo.lock-derived ferro-rs version, not the binary's own version.
173    #[test]
174    fn dockerfile_pins_to_cargo_lock_ferro_version() {
175        let tmp = TempDir::new().unwrap();
176        // Minimal project fixture: Cargo.toml + Cargo.lock + frontend/package.json
177        // + a Cargo.toml [[bin]] entry so read_bins returns something usable.
178        fs::write(
179            tmp.path().join("Cargo.toml"),
180            "[package]\nname = \"smoke\"\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"smoke\"\npath = \"src/main.rs\"\n",
181        )
182        .unwrap();
183        fs::write(
184            tmp.path().join("Cargo.lock"),
185            "[[package]]\nname = \"ferro-rs\"\nversion = \"9.9.9\"\n",
186        )
187        .unwrap();
188        fs::create_dir_all(tmp.path().join("src")).unwrap();
189        fs::write(tmp.path().join("src/main.rs"), "fn main(){}").unwrap();
190        fs::create_dir_all(tmp.path().join("frontend")).unwrap();
191        fs::write(tmp.path().join("frontend/package.json"), "{}").unwrap();
192
193        // Construct the DockerContext directly via the same code path as `execute`,
194        // bypassing find_project_root which depends on the current process CWD.
195        let bins: Vec<String> = vec!["smoke".to_string()];
196        let ctx = DockerContext {
197            rust_channel: read_rust_channel(tmp.path()),
198            has_frontend: tmp.path().join("frontend/package.json").is_file(),
199            bins,
200            web_bin: "smoke".to_string(),
201            copy_dirs_present: vec![],
202            runtime_apt: vec![],
203            ferro_version: resolve_ferro_version(tmp.path()),
204        };
205        let out = render_dockerfile(&ctx);
206        assert!(
207            out.contains("--version 9.9.9"),
208            "expected the rendered Dockerfile to pin ferro-cli to 9.9.9 (from Cargo.lock); got:\n{out}"
209        );
210        assert!(out.contains("AS types-gen"));
211    }
212
213    #[test]
214    fn dockerfile_falls_back_to_env_version_when_no_cargo_lock() {
215        let tmp = TempDir::new().unwrap();
216        fs::create_dir_all(tmp.path().join("frontend")).unwrap();
217        fs::write(tmp.path().join("frontend/package.json"), "{}").unwrap();
218        let ctx = DockerContext {
219            rust_channel: read_rust_channel(tmp.path()),
220            has_frontend: true,
221            bins: vec!["smoke".to_string()],
222            web_bin: "smoke".to_string(),
223            copy_dirs_present: vec![],
224            runtime_apt: vec![],
225            ferro_version: resolve_ferro_version(tmp.path()),
226        };
227        let out = render_dockerfile(&ctx);
228        let expected = format!("--version {}", env!("CARGO_PKG_VERSION"));
229        assert!(out.contains(&expected), "expected '{expected}' in:\n{out}");
230    }
231}