use crate::deploy::bin_detect::detect_web_bin;
use crate::doctor::check::{CheckCategory, CheckResult, DoctorCheck};
use crate::project::{read_bins, read_deploy_metadata};
use crate::templates::docker::{read_rust_channel, render_dockerfile, DockerContext};
use std::fs;
use std::path::Path;
const NAME: &str = "docker_template_drift";
pub struct DockerTemplateDriftCheck;
impl DoctorCheck for DockerTemplateDriftCheck {
fn name(&self) -> &'static str {
NAME
}
fn category(&self) -> CheckCategory {
CheckCategory::Deploy
}
fn run(&self, root: &Path) -> CheckResult {
check_impl(root)
}
}
pub(crate) fn check_impl(root: &Path) -> CheckResult {
let dockerfile = root.join("Dockerfile");
if !dockerfile.is_file() {
return CheckResult::ok(NAME, "skipped (Dockerfile absent)");
}
let committed = match fs::read_to_string(&dockerfile) {
Ok(s) => s,
Err(e) => return CheckResult::error(NAME, format!("read failed: {e}")),
};
let metadata = match read_deploy_metadata(root) {
Ok(m) => m,
Err(e) => return CheckResult::error(NAME, format!("metadata: {e}")),
};
let bins: Vec<String> = read_bins(root).into_iter().map(|b| b.name).collect();
let web_bin = match detect_web_bin(root) {
Ok(w) => w,
Err(e) => return CheckResult::error(NAME, format!("web_bin: {e}")),
};
let copy_dirs_present: Vec<String> = metadata
.copy_dirs
.iter()
.filter(|d| root.join(d.as_str()).exists())
.cloned()
.collect();
let ctx = DockerContext {
rust_channel: read_rust_channel(root),
has_frontend: root.join("frontend/package.json").is_file(),
bins,
web_bin,
copy_dirs_present,
runtime_apt: metadata.runtime_apt,
};
let rendered = render_dockerfile(&ctx);
if rendered.trim_end() == committed.trim_end() {
CheckResult::ok(NAME, "Dockerfile matches scaffolder output")
} else {
CheckResult::warn(NAME, "Dockerfile has drifted from scaffolder")
.with_details("run `ferro docker:init --dry-run` to inspect the delta")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::doctor::check::CheckStatus;
use std::fs;
use tempfile::TempDir;
fn write(p: &Path, body: &str) {
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(p, body).unwrap();
}
fn minimal_cargo_toml(name: &str) -> String {
format!(
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"{name}\"\npath = \"src/main.rs\"\n"
)
}
#[test]
fn name_and_category() {
assert_eq!(DockerTemplateDriftCheck.name(), NAME);
assert_eq!(DockerTemplateDriftCheck.category(), CheckCategory::Deploy);
}
#[test]
fn docker_template_drift_ok_when_dockerfile_absent() {
let td = TempDir::new().unwrap();
write(&td.path().join("Cargo.toml"), &minimal_cargo_toml("sample"));
let r = check_impl(td.path());
assert_eq!(r.status, CheckStatus::Ok);
assert!(r.message.contains("skipped"));
}
#[test]
fn docker_template_drift_ok_on_matching_fixture() {
use crate::templates::docker::DockerContext;
let td = TempDir::new().unwrap();
write(
&td.path().join("Cargo.toml"),
"[package]\nname = \"sample\"\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"sample\"\npath = \"src/main.rs\"\n",
);
let ctx = DockerContext {
rust_channel: "stable".to_string(),
has_frontend: false,
bins: vec!["sample".to_string()],
web_bin: "sample".to_string(),
copy_dirs_present: vec![],
runtime_apt: vec![],
};
let rendered = render_dockerfile(&ctx);
write(&td.path().join("Dockerfile"), &rendered);
let r = check_impl(td.path());
assert_eq!(
r.status,
CheckStatus::Ok,
"matching Dockerfile must not warn: {}",
r.message
);
assert!(r.message.contains("matches"));
}
#[test]
fn docker_template_drift_warn_on_mutation() {
use crate::templates::docker::DockerContext;
let td = TempDir::new().unwrap();
write(
&td.path().join("Cargo.toml"),
"[package]\nname = \"sample\"\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"sample\"\npath = \"src/main.rs\"\n",
);
let ctx = DockerContext {
rust_channel: "stable".to_string(),
has_frontend: false,
bins: vec!["sample".to_string()],
web_bin: "sample".to_string(),
copy_dirs_present: vec![],
runtime_apt: vec![],
};
let mut rendered = render_dockerfile(&ctx);
rendered.push_str("# spurious comment added by hand\n");
write(&td.path().join("Dockerfile"), &rendered);
let r = check_impl(td.path());
assert_eq!(
r.status,
CheckStatus::Warn,
"mutated Dockerfile must warn: {}",
r.message
);
assert!(r.message.contains("drifted"));
assert!(r.details.as_ref().unwrap().contains("docker:init"));
}
}