Skip to main content

ferro_cli/doctor/checks/
docker_template_drift.rs

1//! Doctor check: flag when the committed `Dockerfile` has drifted from what
2//! the current scaffolder would generate.
3//!
4//! Severity is `Warn`, not `Error` — hand-editing the Dockerfile is
5//! legitimate. The check exists to inform, not to block. See Phase 131
6//! research open question 4 for rationale.
7
8use crate::deploy::bin_detect::detect_web_bin;
9use crate::doctor::check::{CheckCategory, CheckResult, DoctorCheck};
10use crate::project::{read_bins, read_deploy_metadata};
11use crate::templates::docker::{read_rust_channel, render_dockerfile, DockerContext};
12use std::fs;
13use std::path::Path;
14
15const NAME: &str = "docker_template_drift";
16
17pub struct DockerTemplateDriftCheck;
18
19impl DoctorCheck for DockerTemplateDriftCheck {
20    fn name(&self) -> &'static str {
21        NAME
22    }
23
24    fn category(&self) -> CheckCategory {
25        CheckCategory::Deploy
26    }
27
28    fn run(&self, root: &Path) -> CheckResult {
29        check_impl(root)
30    }
31}
32
33pub(crate) fn check_impl(root: &Path) -> CheckResult {
34    let dockerfile = root.join("Dockerfile");
35
36    if !dockerfile.is_file() {
37        return CheckResult::ok(NAME, "skipped (Dockerfile absent)");
38    }
39
40    let committed = match fs::read_to_string(&dockerfile) {
41        Ok(s) => s,
42        Err(e) => return CheckResult::error(NAME, format!("read failed: {e}")),
43    };
44
45    let metadata = match read_deploy_metadata(root) {
46        Ok(m) => m,
47        Err(e) => return CheckResult::error(NAME, format!("metadata: {e}")),
48    };
49
50    let bins: Vec<String> = read_bins(root).into_iter().map(|b| b.name).collect();
51
52    let web_bin = match detect_web_bin(root) {
53        Ok(w) => w,
54        Err(e) => return CheckResult::error(NAME, format!("web_bin: {e}")),
55    };
56
57    let copy_dirs_present: Vec<String> = metadata
58        .copy_dirs
59        .iter()
60        .filter(|d| root.join(d.as_str()).exists())
61        .cloned()
62        .collect();
63
64    let ctx = DockerContext {
65        rust_channel: read_rust_channel(root),
66        has_frontend: root.join("frontend/package.json").is_file(),
67        bins,
68        web_bin,
69        copy_dirs_present,
70        runtime_apt: metadata.runtime_apt,
71    };
72
73    let rendered = render_dockerfile(&ctx);
74
75    if rendered.trim_end() == committed.trim_end() {
76        CheckResult::ok(NAME, "Dockerfile matches scaffolder output")
77    } else {
78        CheckResult::warn(NAME, "Dockerfile has drifted from scaffolder")
79            .with_details("run `ferro docker:init --dry-run` to inspect the delta")
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use crate::doctor::check::CheckStatus;
87    use std::fs;
88    use tempfile::TempDir;
89
90    fn write(p: &Path, body: &str) {
91        if let Some(parent) = p.parent() {
92            fs::create_dir_all(parent).unwrap();
93        }
94        fs::write(p, body).unwrap();
95    }
96
97    fn minimal_cargo_toml(name: &str) -> String {
98        format!(
99            "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"{name}\"\npath = \"src/main.rs\"\n"
100        )
101    }
102
103    #[test]
104    fn name_and_category() {
105        assert_eq!(DockerTemplateDriftCheck.name(), NAME);
106        assert_eq!(DockerTemplateDriftCheck.category(), CheckCategory::Deploy);
107    }
108
109    #[test]
110    fn docker_template_drift_ok_when_dockerfile_absent() {
111        let td = TempDir::new().unwrap();
112        write(&td.path().join("Cargo.toml"), &minimal_cargo_toml("sample"));
113        let r = check_impl(td.path());
114        assert_eq!(r.status, CheckStatus::Ok);
115        assert!(r.message.contains("skipped"));
116    }
117
118    #[test]
119    fn docker_template_drift_ok_on_matching_fixture() {
120        use crate::templates::docker::DockerContext;
121
122        // Build a minimal project that matches the freshly-rendered Dockerfile.
123        let td = TempDir::new().unwrap();
124        write(
125            &td.path().join("Cargo.toml"),
126            "[package]\nname = \"sample\"\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"sample\"\npath = \"src/main.rs\"\n",
127        );
128
129        // Render a Dockerfile and write it to the tempdir so the check can
130        // verify it matches.
131        let ctx = DockerContext {
132            rust_channel: "stable".to_string(),
133            has_frontend: false,
134            bins: vec!["sample".to_string()],
135            web_bin: "sample".to_string(),
136            copy_dirs_present: vec![],
137            runtime_apt: vec![],
138        };
139        let rendered = render_dockerfile(&ctx);
140        write(&td.path().join("Dockerfile"), &rendered);
141
142        let r = check_impl(td.path());
143        assert_eq!(
144            r.status,
145            CheckStatus::Ok,
146            "matching Dockerfile must not warn: {}",
147            r.message
148        );
149        assert!(r.message.contains("matches"));
150    }
151
152    #[test]
153    fn docker_template_drift_warn_on_mutation() {
154        use crate::templates::docker::DockerContext;
155
156        let td = TempDir::new().unwrap();
157        write(
158            &td.path().join("Cargo.toml"),
159            "[package]\nname = \"sample\"\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"sample\"\npath = \"src/main.rs\"\n",
160        );
161
162        // Render the correct Dockerfile, then append a spurious comment.
163        let ctx = DockerContext {
164            rust_channel: "stable".to_string(),
165            has_frontend: false,
166            bins: vec!["sample".to_string()],
167            web_bin: "sample".to_string(),
168            copy_dirs_present: vec![],
169            runtime_apt: vec![],
170        };
171        let mut rendered = render_dockerfile(&ctx);
172        rendered.push_str("# spurious comment added by hand\n");
173        write(&td.path().join("Dockerfile"), &rendered);
174
175        let r = check_impl(td.path());
176        assert_eq!(
177            r.status,
178            CheckStatus::Warn,
179            "mutated Dockerfile must warn: {}",
180            r.message
181        );
182        assert!(r.message.contains("drifted"));
183        assert!(r.details.as_ref().unwrap().contains("docker:init"));
184    }
185}