Skip to main content

ferro_cli/doctor/checks/
copy_dirs_dockerignore_collision.rs

1//! Deploy preflight (Phase 128 D-04): flag `copy_dirs` entries that
2//! `.dockerignore` would silently exclude from the Docker build context.
3
4use crate::doctor::check::{CheckCategory, CheckResult, DoctorCheck};
5use crate::project::read_deploy_metadata;
6use std::fs;
7use std::path::Path;
8
9pub struct CopyDirsDockerignoreCollisionCheck;
10
11const NAME: &str = "copy_dirs_dockerignore_collision";
12
13impl DoctorCheck for CopyDirsDockerignoreCollisionCheck {
14    fn name(&self) -> &'static str {
15        NAME
16    }
17    fn run(&self, root: &Path) -> CheckResult {
18        check_impl(root)
19    }
20    fn category(&self) -> CheckCategory {
21        CheckCategory::Deploy
22    }
23}
24
25pub(crate) fn check_impl(root: &Path) -> CheckResult {
26    let dockerignore = root.join(".dockerignore");
27    if !dockerignore.is_file() {
28        return CheckResult::ok(NAME, "skipped (.dockerignore absent)");
29    }
30    let metadata = match read_deploy_metadata(root) {
31        Ok(m) => m,
32        Err(_) => return CheckResult::ok(NAME, "skipped (deploy metadata absent)"),
33    };
34    if metadata.copy_dirs.is_empty() {
35        return CheckResult::ok(NAME, "no copy_dirs declared");
36    }
37    let ignore_content = match fs::read_to_string(&dockerignore) {
38        Ok(s) => s,
39        Err(e) => return CheckResult::error(NAME, format!("failed to read .dockerignore: {e}")),
40    };
41    let ignore_lines: Vec<&str> = ignore_content
42        .lines()
43        .map(str::trim)
44        .filter(|l| !l.is_empty() && !l.starts_with('#') && !l.starts_with('!'))
45        .collect();
46
47    let mut collisions: Vec<String> = Vec::new();
48    for entry in &metadata.copy_dirs {
49        let entry_head = entry.split('/').next().unwrap_or(entry);
50        for line in &ignore_lines {
51            let stripped = line.trim_end_matches('/');
52            if stripped == entry_head || stripped == entry.as_str() {
53                collisions.push(format!("'{entry}' excluded by .dockerignore rule '{line}'"));
54                break;
55            }
56        }
57    }
58
59    if collisions.is_empty() {
60        CheckResult::ok(NAME, "copy_dirs entries not excluded by .dockerignore")
61    } else {
62        CheckResult::error(
63            NAME,
64            format!(
65                "{} copy_dirs entries collide with .dockerignore",
66                collisions.len()
67            ),
68        )
69        .with_details(collisions.join("; "))
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::doctor::check::CheckStatus;
77    use tempfile::TempDir;
78
79    fn write(p: &Path, body: &str) {
80        if let Some(parent) = p.parent() {
81            fs::create_dir_all(parent).unwrap();
82        }
83        fs::write(p, body).unwrap();
84    }
85
86    #[test]
87    fn name_and_category() {
88        assert_eq!(CopyDirsDockerignoreCollisionCheck.name(), NAME);
89        assert_eq!(
90            CopyDirsDockerignoreCollisionCheck.category(),
91            CheckCategory::Deploy
92        );
93    }
94
95    #[test]
96    fn skipped_when_dockerignore_absent() {
97        let td = TempDir::new().unwrap();
98        write(
99            &td.path().join("Cargo.toml"),
100            "[package]\nname=\"x\"\nversion=\"0.1.0\"\n[package.metadata.ferro.deploy]\ncopy_dirs=[\"data\"]\n",
101        );
102        let r = check_impl(td.path());
103        assert_eq!(r.status, CheckStatus::Ok);
104        assert!(r.message.contains("skipped"));
105    }
106
107    #[test]
108    fn errors_when_copy_dir_collides() {
109        let td = TempDir::new().unwrap();
110        write(
111            &td.path().join("Cargo.toml"),
112            "[package]\nname=\"x\"\nversion=\"0.1.0\"\n[package.metadata.ferro.deploy]\ncopy_dirs=[\"data\"]\n",
113        );
114        write(
115            &td.path().join(".dockerignore"),
116            "# comment\ntarget/\ndata/\n*.log\n",
117        );
118        let r = check_impl(td.path());
119        assert_eq!(r.status, CheckStatus::Error);
120        assert!(r.details.as_ref().unwrap().contains("data"));
121        assert!(r.details.as_ref().unwrap().contains("data/"));
122    }
123
124    #[test]
125    fn ok_when_no_collision() {
126        let td = TempDir::new().unwrap();
127        write(
128            &td.path().join("Cargo.toml"),
129            "[package]\nname=\"x\"\nversion=\"0.1.0\"\n[package.metadata.ferro.deploy]\ncopy_dirs=[\"migrations\"]\n",
130        );
131        write(&td.path().join(".dockerignore"), "target/\ndata/\n");
132        let r = check_impl(td.path());
133        assert_eq!(r.status, CheckStatus::Ok);
134    }
135
136    #[test]
137    fn negation_lines_ignored() {
138        let td = TempDir::new().unwrap();
139        write(
140            &td.path().join("Cargo.toml"),
141            "[package]\nname=\"x\"\nversion=\"0.1.0\"\n[package.metadata.ferro.deploy]\ncopy_dirs=[\"data\"]\n",
142        );
143        write(&td.path().join(".dockerignore"), "!data/\n");
144        let r = check_impl(td.path());
145        assert_eq!(r.status, CheckStatus::Ok);
146    }
147}