1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use crate::error::Result;
5use crate::facts::FactValues;
6use crate::level::Level;
7use crate::registry::RuleRegistry;
8use crate::walker::FileIndex;
9
10#[derive(Debug, Clone)]
12pub struct Violation {
13 pub path: Option<PathBuf>,
14 pub message: String,
15 pub line: Option<usize>,
16 pub column: Option<usize>,
17}
18
19impl Violation {
20 pub fn new(message: impl Into<String>) -> Self {
21 Self {
22 path: None,
23 message: message.into(),
24 line: None,
25 column: None,
26 }
27 }
28
29 #[must_use]
30 pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
31 self.path = Some(path.into());
32 self
33 }
34
35 #[must_use]
36 pub fn with_location(mut self, line: usize, column: usize) -> Self {
37 self.line = Some(line);
38 self.column = Some(column);
39 self
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct RuleResult {
46 pub rule_id: String,
47 pub level: Level,
48 pub policy_url: Option<String>,
49 pub violations: Vec<Violation>,
50 pub is_fixable: bool,
54}
55
56impl RuleResult {
57 pub fn passed(&self) -> bool {
58 self.violations.is_empty()
59 }
60}
61
62#[derive(Debug)]
81pub struct Context<'a> {
82 pub root: &'a Path,
83 pub index: &'a FileIndex,
84 pub registry: Option<&'a RuleRegistry>,
85 pub facts: Option<&'a FactValues>,
86 pub vars: Option<&'a HashMap<String, String>>,
87 pub git_tracked: Option<&'a std::collections::HashSet<std::path::PathBuf>>,
88 pub git_blame: Option<&'a crate::git::BlameCache>,
89}
90
91impl Context<'_> {
92 pub fn is_git_tracked(&self, rel_path: &Path) -> bool {
99 match self.git_tracked {
100 Some(set) => set.contains(rel_path),
101 None => false,
102 }
103 }
104
105 pub fn dir_has_tracked_files(&self, rel_path: &Path) -> bool {
110 match self.git_tracked {
111 Some(set) => crate::git::dir_has_tracked_files(rel_path, set),
112 None => false,
113 }
114 }
115}
116
117pub trait Rule: Send + Sync + std::fmt::Debug {
119 fn id(&self) -> &str;
120 fn level(&self) -> Level;
121 fn policy_url(&self) -> Option<&str> {
122 None
123 }
124 fn wants_git_tracked(&self) -> bool {
132 false
133 }
134
135 fn wants_git_blame(&self) -> bool {
142 false
143 }
144
145 fn requires_full_index(&self) -> bool {
159 false
160 }
161
162 fn path_scope(&self) -> Option<&crate::scope::Scope> {
175 None
176 }
177
178 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>>;
179
180 fn fixer(&self) -> Option<&dyn Fixer> {
186 None
187 }
188}
189
190#[derive(Debug)]
192pub struct FixContext<'a> {
193 pub root: &'a Path,
194 pub dry_run: bool,
197 pub fix_size_limit: Option<u64>,
201}
202
203#[derive(Debug, Clone)]
205pub enum FixOutcome {
206 Applied(String),
210 Skipped(String),
214}
215
216pub trait Fixer: Send + Sync + std::fmt::Debug {
218 fn describe(&self) -> String;
221
222 fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome>;
224}
225
226#[derive(Debug)]
236pub enum ReadForFix {
237 Bytes(Vec<u8>),
238 Skipped(FixOutcome),
239}
240
241pub fn check_fix_size(
250 abs: &Path,
251 display_path: &std::path::Path,
252 ctx: &FixContext<'_>,
253) -> Result<Option<FixOutcome>> {
254 let Some(limit) = ctx.fix_size_limit else {
255 return Ok(None);
256 };
257 let metadata = std::fs::metadata(abs).map_err(|source| crate::error::Error::Io {
258 path: abs.to_path_buf(),
259 source,
260 })?;
261 if metadata.len() > limit {
262 let reason = format!(
263 "{} is {} bytes; exceeds fix_size_limit ({}). Raise \
264 `fix_size_limit` in .alint.yml (or set it to `null` to disable) \
265 to fix files this large.",
266 display_path.display(),
267 metadata.len(),
268 limit,
269 );
270 eprintln!("alint: warning: {reason}");
271 return Ok(Some(FixOutcome::Skipped(reason)));
272 }
273 Ok(None)
274}
275
276pub fn read_for_fix(
281 abs: &Path,
282 display_path: &std::path::Path,
283 ctx: &FixContext<'_>,
284) -> Result<ReadForFix> {
285 if let Some(outcome) = check_fix_size(abs, display_path, ctx)? {
286 return Ok(ReadForFix::Skipped(outcome));
287 }
288 let bytes = std::fs::read(abs).map_err(|source| crate::error::Error::Io {
289 path: abs.to_path_buf(),
290 source,
291 })?;
292 Ok(ReadForFix::Bytes(bytes))
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 fn empty_index() -> FileIndex {
300 FileIndex::default()
301 }
302
303 #[test]
304 fn violation_builder_sets_fields_via_chain() {
305 let v = Violation::new("trailing whitespace")
306 .with_path("src/main.rs")
307 .with_location(12, 4);
308 assert_eq!(v.message, "trailing whitespace");
309 assert_eq!(v.path.as_deref(), Some(Path::new("src/main.rs")));
310 assert_eq!(v.line, Some(12));
311 assert_eq!(v.column, Some(4));
312 }
313
314 #[test]
315 fn violation_new_starts_with_no_path_or_location() {
316 let v = Violation::new("global note");
317 assert!(v.path.is_none());
318 assert!(v.line.is_none());
319 assert!(v.column.is_none());
320 }
321
322 #[test]
323 fn rule_result_passed_iff_violations_empty() {
324 let mut r = RuleResult {
325 rule_id: "x".into(),
326 level: Level::Error,
327 policy_url: None,
328 violations: Vec::new(),
329 is_fixable: false,
330 };
331 assert!(r.passed());
332 r.violations.push(Violation::new("oops"));
333 assert!(!r.passed());
334 }
335
336 #[test]
337 fn context_is_git_tracked_returns_false_outside_repo() {
338 let idx = empty_index();
339 let ctx = Context {
340 root: Path::new("/tmp"),
341 index: &idx,
342 registry: None,
343 facts: None,
344 vars: None,
345 git_tracked: None, git_blame: None,
347 };
348 assert!(!ctx.is_git_tracked(Path::new("anything.rs")));
349 assert!(!ctx.dir_has_tracked_files(Path::new("src")));
350 }
351
352 #[test]
353 fn context_is_git_tracked_consults_set_when_present() {
354 let mut tracked: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
355 tracked.insert(PathBuf::from("src/main.rs"));
356 let idx = empty_index();
357 let ctx = Context {
358 root: Path::new("/tmp"),
359 index: &idx,
360 registry: None,
361 facts: None,
362 vars: None,
363 git_tracked: Some(&tracked),
364 git_blame: None,
365 };
366 assert!(ctx.is_git_tracked(Path::new("src/main.rs")));
367 assert!(!ctx.is_git_tracked(Path::new("README.md")));
368 }
369
370 #[derive(Debug)]
374 struct DefaultRule;
375
376 impl Rule for DefaultRule {
377 fn id(&self) -> &'static str {
378 "default"
379 }
380 fn level(&self) -> Level {
381 Level::Warning
382 }
383 fn evaluate(&self, _ctx: &Context<'_>) -> Result<Vec<Violation>> {
384 Ok(Vec::new())
385 }
386 }
387
388 #[test]
389 fn rule_trait_defaults_are_safe_no_ops() {
390 let r = DefaultRule;
391 assert_eq!(r.policy_url(), None);
392 assert!(!r.wants_git_tracked());
393 assert!(!r.wants_git_blame());
394 assert!(!r.requires_full_index());
395 assert!(r.path_scope().is_none());
396 assert!(r.fixer().is_none());
397 }
398
399 #[test]
400 fn check_fix_size_returns_none_when_limit_disabled() {
401 let dir = tempfile::tempdir().unwrap();
402 let f = dir.path().join("a.txt");
403 std::fs::write(&f, b"hello").unwrap();
404 let ctx = FixContext {
405 root: dir.path(),
406 dry_run: false,
407 fix_size_limit: None,
408 };
409 let outcome = check_fix_size(&f, Path::new("a.txt"), &ctx).unwrap();
410 assert!(outcome.is_none());
411 }
412
413 #[test]
414 fn check_fix_size_skips_over_limit_files() {
415 let dir = tempfile::tempdir().unwrap();
416 let f = dir.path().join("big.txt");
417 std::fs::write(&f, vec![b'x'; 1024]).unwrap();
418 let ctx = FixContext {
419 root: dir.path(),
420 dry_run: false,
421 fix_size_limit: Some(64),
422 };
423 let outcome = check_fix_size(&f, Path::new("big.txt"), &ctx).unwrap();
424 match outcome {
425 Some(FixOutcome::Skipped(reason)) => {
426 assert!(reason.contains("exceeds fix_size_limit"));
427 assert!(reason.contains("big.txt"));
428 }
429 other => panic!("expected Skipped, got {other:?}"),
430 }
431 }
432
433 #[test]
434 fn read_for_fix_returns_bytes_when_in_limit() {
435 let dir = tempfile::tempdir().unwrap();
436 let f = dir.path().join("a.txt");
437 std::fs::write(&f, b"hello").unwrap();
438 let ctx = FixContext {
439 root: dir.path(),
440 dry_run: false,
441 fix_size_limit: Some(1 << 20),
442 };
443 match read_for_fix(&f, Path::new("a.txt"), &ctx).unwrap() {
444 ReadForFix::Bytes(b) => assert_eq!(b, b"hello"),
445 ReadForFix::Skipped(_) => panic!("expected Bytes, got Skipped"),
446 }
447 }
448
449 #[test]
450 fn read_for_fix_returns_skipped_when_over_limit() {
451 let dir = tempfile::tempdir().unwrap();
452 let f = dir.path().join("big.txt");
453 std::fs::write(&f, vec![b'x'; 1024]).unwrap();
454 let ctx = FixContext {
455 root: dir.path(),
456 dry_run: false,
457 fix_size_limit: Some(64),
458 };
459 match read_for_fix(&f, Path::new("big.txt"), &ctx).unwrap() {
460 ReadForFix::Skipped(FixOutcome::Skipped(_)) => {}
461 ReadForFix::Skipped(FixOutcome::Applied(_)) => {
462 panic!("expected Skipped, got Skipped(Applied)")
463 }
464 ReadForFix::Bytes(_) => panic!("expected Skipped, got Bytes"),
465 }
466 }
467
468 #[test]
469 fn fix_outcome_variants_are_constructible() {
470 let _applied = FixOutcome::Applied("created LICENSE".into());
472 let _skipped = FixOutcome::Skipped("already exists".into());
473 }
474}