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, DockerContext,
22};
23
24/// One rendered output file carried in memory between the render and persist
25/// phases. Enables `--dry-run` to print everything without writing anything.
26pub(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
38/// Entry point used by `main.rs`. Returns a process-style exit: prints errors
39/// to stderr and returns without panicking so clap stays happy.
40pub fn run(force: bool) {
41    run_with(force, None, false);
42}
43
44/// Full entry point supporting the `--ferro-version` override and `--dry-run`.
45pub 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
51/// Library-level entry point used by integration tests. Returns `Result`
52/// instead of printing to stderr, so tests can assert on failures.
53pub 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    // Render everything to memory first. Any render error is a hard error in
83    // every mode, including --dry-run (D-19).
84    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        // D-16, D-17, D-18: print every rendered file, write nothing, suppress
99        // the "Next steps" footer.
100        print_dry_run(&files);
101        return Ok(());
102    }
103
104    // Persist. Template outputs honor --force.
105    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    // D-13, D-14: cargo-style "Next steps" footer.
116    let pkg = package_name(&root);
117    print!("{}", docker_init_footer(&pkg));
118    Ok(())
119}
120
121/// Build the cargo-style "Next steps" footer for `docker:init`. Pure string —
122/// the `print_*` wrapper exists so tests can assert on the content directly.
123fn 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}