1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use std::path::PathBuf;
4
5#[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 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 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 None
132 }
133
134 fn check_file(&self, _ctx: &ScanContext) -> Vec<Violation> {
135 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 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 assert_eq!(violations.len(), 2);
277 }
278}