Skip to main content

code_baseline/rules/
file_presence.rs

1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use std::path::PathBuf;
4
5/// Ensures that specific files exist (or do not exist) in the project.
6///
7/// Unlike other rules, this doesn't scan file content — it checks whether
8/// required files are present and forbidden files are absent. Useful for
9/// enforcing project conventions like "every project must have a .env.example"
10/// or ".env files should not be committed."
11///
12/// The `required_files` config field lists relative paths that must exist.
13/// The `forbidden_files` config field lists relative paths that must NOT exist.
14/// The rule emits one violation per missing required file or present forbidden file.
15#[derive(Debug)]
16pub struct FilePresenceRule {
17    id: String,
18    severity: Severity,
19    message: String,
20    suggest: Option<String>,
21    required_files: Vec<String>,
22    forbidden_files: Vec<String>,
23}
24
25impl FilePresenceRule {
26    pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
27        if config.required_files.is_empty() && config.forbidden_files.is_empty() {
28            return Err(RuleBuildError::MissingField(
29                config.id.clone(),
30                "required_files or forbidden_files",
31            ));
32        }
33
34        Ok(Self {
35            id: config.id.clone(),
36            severity: config.severity,
37            message: config.message.clone(),
38            suggest: config.suggest.clone(),
39            required_files: config.required_files.clone(),
40            forbidden_files: config.forbidden_files.clone(),
41        })
42    }
43
44    /// Check which required files are missing and which forbidden files exist.
45    /// Returns violations for each missing required file or present forbidden file.
46    pub fn check_paths(&self, root_paths: &[PathBuf]) -> Vec<Violation> {
47        let mut violations = Vec::new();
48
49        for required in &self.required_files {
50            let exists = root_paths.iter().any(|root| {
51                let check_path = if root.is_dir() {
52                    root.join(required)
53                } else {
54                    // If root is a file, check relative to its parent
55                    root.parent()
56                        .map(|p| p.join(required))
57                        .unwrap_or_else(|| PathBuf::from(required))
58                };
59                check_path.exists()
60            });
61
62            if !exists {
63                let msg = if self.message.is_empty() {
64                    format!("Required file '{}' is missing", required)
65                } else {
66                    format!("{}: '{}'", self.message, required)
67                };
68
69                violations.push(Violation {
70                    rule_id: self.id.clone(),
71                    severity: self.severity,
72                    file: PathBuf::from(required),
73                    line: None,
74                    column: None,
75                    message: msg,
76                    suggest: self.suggest.clone(),
77                    source_line: None,
78                    fix: None,
79                });
80            }
81        }
82
83        for forbidden in &self.forbidden_files {
84            let exists = root_paths.iter().any(|root| {
85                let check_path = if root.is_dir() {
86                    root.join(forbidden)
87                } else {
88                    root.parent()
89                        .map(|p| p.join(forbidden))
90                        .unwrap_or_else(|| PathBuf::from(forbidden))
91                };
92                check_path.exists()
93            });
94
95            if exists {
96                let msg = if self.message.is_empty() {
97                    format!("Forbidden file '{}' must not exist", forbidden)
98                } else {
99                    format!("{}: '{}'", self.message, forbidden)
100                };
101
102                violations.push(Violation {
103                    rule_id: self.id.clone(),
104                    severity: self.severity,
105                    file: PathBuf::from(forbidden),
106                    line: None,
107                    column: None,
108                    message: msg,
109                    suggest: self.suggest.clone(),
110                    source_line: None,
111                    fix: None,
112                });
113            }
114        }
115
116        violations
117    }
118}
119
120impl Rule for FilePresenceRule {
121    fn id(&self) -> &str {
122        &self.id
123    }
124
125    fn severity(&self) -> Severity {
126        self.severity
127    }
128
129    fn file_glob(&self) -> Option<&str> {
130        // File presence rules don't scan files — they check for existence
131        None
132    }
133
134    fn check_file(&self, _ctx: &ScanContext) -> Vec<Violation> {
135        // File presence checking is done via check_paths, not check_file
136        Vec::new()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use tempfile::TempDir;
144    use std::fs;
145
146    fn make_rule(files: Vec<&str>) -> FilePresenceRule {
147        let config = RuleConfig {
148            id: "test-file-presence".into(),
149            severity: Severity::Error,
150            message: "required file missing".into(),
151            suggest: Some("create the required file".into()),
152            required_files: files.into_iter().map(|s| s.to_string()).collect(),
153            ..Default::default()
154        };
155        FilePresenceRule::new(&config).unwrap()
156    }
157
158    #[test]
159    fn file_exists_no_violation() {
160        let dir = TempDir::new().unwrap();
161        fs::write(dir.path().join(".env.example"), "").unwrap();
162        let rule = make_rule(vec![".env.example"]);
163        let violations = rule.check_paths(&[dir.path().to_path_buf()]);
164        assert!(violations.is_empty());
165    }
166
167    #[test]
168    fn file_missing_one_violation() {
169        let dir = TempDir::new().unwrap();
170        let rule = make_rule(vec![".env.example"]);
171        let violations = rule.check_paths(&[dir.path().to_path_buf()]);
172        assert_eq!(violations.len(), 1);
173        assert!(violations[0].message.contains(".env.example"));
174    }
175
176    #[test]
177    fn multiple_files_partial_missing() {
178        let dir = TempDir::new().unwrap();
179        fs::write(dir.path().join("README.md"), "# Hello").unwrap();
180        let rule = make_rule(vec!["README.md", "LICENSE", ".env.example"]);
181        let violations = rule.check_paths(&[dir.path().to_path_buf()]);
182        assert_eq!(violations.len(), 2);
183    }
184
185    #[test]
186    fn nested_file_exists() {
187        let dir = TempDir::new().unwrap();
188        fs::create_dir_all(dir.path().join("src/lib")).unwrap();
189        fs::write(dir.path().join("src/lib/index.ts"), "").unwrap();
190        let rule = make_rule(vec!["src/lib/index.ts"]);
191        let violations = rule.check_paths(&[dir.path().to_path_buf()]);
192        assert!(violations.is_empty());
193    }
194
195    #[test]
196    fn missing_both_fields_error() {
197        let config = RuleConfig {
198            id: "test".into(),
199            severity: Severity::Error,
200            message: "test".into(),
201            ..Default::default()
202        };
203        let err = FilePresenceRule::new(&config).unwrap_err();
204        assert!(matches!(err, RuleBuildError::MissingField(_, _)));
205    }
206
207    fn make_forbidden_rule(files: Vec<&str>) -> FilePresenceRule {
208        let config = RuleConfig {
209            id: "test-forbidden".into(),
210            severity: Severity::Error,
211            message: "".into(),
212            forbidden_files: files.into_iter().map(|s| s.to_string()).collect(),
213            ..Default::default()
214        };
215        FilePresenceRule::new(&config).unwrap()
216    }
217
218    #[test]
219    fn forbidden_file_absent_no_violation() {
220        let dir = TempDir::new().unwrap();
221        let rule = make_forbidden_rule(vec![".env"]);
222        let violations = rule.check_paths(&[dir.path().to_path_buf()]);
223        assert!(violations.is_empty());
224    }
225
226    #[test]
227    fn forbidden_file_present_violation() {
228        let dir = TempDir::new().unwrap();
229        fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
230        let rule = make_forbidden_rule(vec![".env"]);
231        let violations = rule.check_paths(&[dir.path().to_path_buf()]);
232        assert_eq!(violations.len(), 1);
233        assert!(violations[0].message.contains(".env"));
234    }
235
236    #[test]
237    fn forbidden_multiple_some_present() {
238        let dir = TempDir::new().unwrap();
239        fs::write(dir.path().join(".env"), "").unwrap();
240        fs::write(dir.path().join(".env.local"), "").unwrap();
241        let rule = make_forbidden_rule(vec![".env", ".env.local", ".env.production"]);
242        let violations = rule.check_paths(&[dir.path().to_path_buf()]);
243        assert_eq!(violations.len(), 2);
244    }
245
246    #[test]
247    fn forbidden_only_allows_construction() {
248        let config = RuleConfig {
249            id: "test".into(),
250            severity: Severity::Error,
251            message: "test".into(),
252            forbidden_files: vec![".env".into()],
253            ..Default::default()
254        };
255        assert!(FilePresenceRule::new(&config).is_ok());
256    }
257
258    #[test]
259    fn mixed_required_and_forbidden() {
260        let dir = TempDir::new().unwrap();
261        // README exists (required, satisfied), .env exists (forbidden, violation)
262        fs::write(dir.path().join("README.md"), "# Hello").unwrap();
263        fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
264
265        let config = RuleConfig {
266            id: "test-mixed".into(),
267            severity: Severity::Error,
268            message: "".into(),
269            required_files: vec!["README.md".into(), "LICENSE".into()],
270            forbidden_files: vec![".env".into()],
271            ..Default::default()
272        };
273        let rule = FilePresenceRule::new(&config).unwrap();
274        let violations = rule.check_paths(&[dir.path().to_path_buf()]);
275        // LICENSE missing + .env present = 2 violations
276        assert_eq!(violations.len(), 2);
277    }
278}