use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::error::Result;
use crate::facts::FactValues;
use crate::level::Level;
use crate::registry::RuleRegistry;
use crate::walker::FileIndex;
#[derive(Debug, Clone)]
pub struct Violation {
pub path: Option<PathBuf>,
pub message: String,
pub line: Option<usize>,
pub column: Option<usize>,
}
impl Violation {
pub fn new(message: impl Into<String>) -> Self {
Self {
path: None,
message: message.into(),
line: None,
column: None,
}
}
#[must_use]
pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
self.path = Some(path.into());
self
}
#[must_use]
pub fn with_location(mut self, line: usize, column: usize) -> Self {
self.line = Some(line);
self.column = Some(column);
self
}
}
#[derive(Debug, Clone)]
pub struct RuleResult {
pub rule_id: String,
pub level: Level,
pub policy_url: Option<String>,
pub violations: Vec<Violation>,
pub is_fixable: bool,
}
impl RuleResult {
pub fn passed(&self) -> bool {
self.violations.is_empty()
}
}
#[derive(Debug)]
pub struct Context<'a> {
pub root: &'a Path,
pub index: &'a FileIndex,
pub registry: Option<&'a RuleRegistry>,
pub facts: Option<&'a FactValues>,
pub vars: Option<&'a HashMap<String, String>>,
pub git_tracked: Option<&'a std::collections::HashSet<std::path::PathBuf>>,
pub git_blame: Option<&'a crate::git::BlameCache>,
}
impl Context<'_> {
pub fn is_git_tracked(&self, rel_path: &Path) -> bool {
match self.git_tracked {
Some(set) => set.contains(rel_path),
None => false,
}
}
pub fn dir_has_tracked_files(&self, rel_path: &Path) -> bool {
match self.git_tracked {
Some(set) => crate::git::dir_has_tracked_files(rel_path, set),
None => false,
}
}
}
pub trait Rule: Send + Sync + std::fmt::Debug {
fn id(&self) -> &str;
fn level(&self) -> Level;
fn policy_url(&self) -> Option<&str> {
None
}
fn wants_git_tracked(&self) -> bool {
false
}
fn wants_git_blame(&self) -> bool {
false
}
fn requires_full_index(&self) -> bool {
false
}
fn path_scope(&self) -> Option<&crate::scope::Scope> {
None
}
fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>>;
fn fixer(&self) -> Option<&dyn Fixer> {
None
}
}
#[derive(Debug)]
pub struct FixContext<'a> {
pub root: &'a Path,
pub dry_run: bool,
pub fix_size_limit: Option<u64>,
}
#[derive(Debug, Clone)]
pub enum FixOutcome {
Applied(String),
Skipped(String),
}
pub trait Fixer: Send + Sync + std::fmt::Debug {
fn describe(&self) -> String;
fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome>;
}
#[derive(Debug)]
pub enum ReadForFix {
Bytes(Vec<u8>),
Skipped(FixOutcome),
}
pub fn check_fix_size(
abs: &Path,
display_path: &std::path::Path,
ctx: &FixContext<'_>,
) -> Result<Option<FixOutcome>> {
let Some(limit) = ctx.fix_size_limit else {
return Ok(None);
};
let metadata = std::fs::metadata(abs).map_err(|source| crate::error::Error::Io {
path: abs.to_path_buf(),
source,
})?;
if metadata.len() > limit {
let reason = format!(
"{} is {} bytes; exceeds fix_size_limit ({}). Raise \
`fix_size_limit` in .alint.yml (or set it to `null` to disable) \
to fix files this large.",
display_path.display(),
metadata.len(),
limit,
);
eprintln!("alint: warning: {reason}");
return Ok(Some(FixOutcome::Skipped(reason)));
}
Ok(None)
}
pub fn read_for_fix(
abs: &Path,
display_path: &std::path::Path,
ctx: &FixContext<'_>,
) -> Result<ReadForFix> {
if let Some(outcome) = check_fix_size(abs, display_path, ctx)? {
return Ok(ReadForFix::Skipped(outcome));
}
let bytes = std::fs::read(abs).map_err(|source| crate::error::Error::Io {
path: abs.to_path_buf(),
source,
})?;
Ok(ReadForFix::Bytes(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_index() -> FileIndex {
FileIndex::default()
}
#[test]
fn violation_builder_sets_fields_via_chain() {
let v = Violation::new("trailing whitespace")
.with_path("src/main.rs")
.with_location(12, 4);
assert_eq!(v.message, "trailing whitespace");
assert_eq!(v.path.as_deref(), Some(Path::new("src/main.rs")));
assert_eq!(v.line, Some(12));
assert_eq!(v.column, Some(4));
}
#[test]
fn violation_new_starts_with_no_path_or_location() {
let v = Violation::new("global note");
assert!(v.path.is_none());
assert!(v.line.is_none());
assert!(v.column.is_none());
}
#[test]
fn rule_result_passed_iff_violations_empty() {
let mut r = RuleResult {
rule_id: "x".into(),
level: Level::Error,
policy_url: None,
violations: Vec::new(),
is_fixable: false,
};
assert!(r.passed());
r.violations.push(Violation::new("oops"));
assert!(!r.passed());
}
#[test]
fn context_is_git_tracked_returns_false_outside_repo() {
let idx = empty_index();
let ctx = Context {
root: Path::new("/tmp"),
index: &idx,
registry: None,
facts: None,
vars: None,
git_tracked: None, git_blame: None,
};
assert!(!ctx.is_git_tracked(Path::new("anything.rs")));
assert!(!ctx.dir_has_tracked_files(Path::new("src")));
}
#[test]
fn context_is_git_tracked_consults_set_when_present() {
let mut tracked: std::collections::HashSet<PathBuf> =
std::collections::HashSet::new();
tracked.insert(PathBuf::from("src/main.rs"));
let idx = empty_index();
let ctx = Context {
root: Path::new("/tmp"),
index: &idx,
registry: None,
facts: None,
vars: None,
git_tracked: Some(&tracked),
git_blame: None,
};
assert!(ctx.is_git_tracked(Path::new("src/main.rs")));
assert!(!ctx.is_git_tracked(Path::new("README.md")));
}
#[derive(Debug)]
struct DefaultRule;
impl Rule for DefaultRule {
fn id(&self) -> &'static str {
"default"
}
fn level(&self) -> Level {
Level::Warning
}
fn evaluate(&self, _ctx: &Context<'_>) -> Result<Vec<Violation>> {
Ok(Vec::new())
}
}
#[test]
fn rule_trait_defaults_are_safe_no_ops() {
let r = DefaultRule;
assert_eq!(r.policy_url(), None);
assert!(!r.wants_git_tracked());
assert!(!r.wants_git_blame());
assert!(!r.requires_full_index());
assert!(r.path_scope().is_none());
assert!(r.fixer().is_none());
}
#[test]
fn check_fix_size_returns_none_when_limit_disabled() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("a.txt");
std::fs::write(&f, b"hello").unwrap();
let ctx = FixContext {
root: dir.path(),
dry_run: false,
fix_size_limit: None,
};
let outcome = check_fix_size(&f, Path::new("a.txt"), &ctx).unwrap();
assert!(outcome.is_none());
}
#[test]
fn check_fix_size_skips_over_limit_files() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("big.txt");
std::fs::write(&f, vec![b'x'; 1024]).unwrap();
let ctx = FixContext {
root: dir.path(),
dry_run: false,
fix_size_limit: Some(64),
};
let outcome = check_fix_size(&f, Path::new("big.txt"), &ctx).unwrap();
match outcome {
Some(FixOutcome::Skipped(reason)) => {
assert!(reason.contains("exceeds fix_size_limit"));
assert!(reason.contains("big.txt"));
}
other => panic!("expected Skipped, got {other:?}"),
}
}
#[test]
fn read_for_fix_returns_bytes_when_in_limit() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("a.txt");
std::fs::write(&f, b"hello").unwrap();
let ctx = FixContext {
root: dir.path(),
dry_run: false,
fix_size_limit: Some(1 << 20),
};
match read_for_fix(&f, Path::new("a.txt"), &ctx).unwrap() {
ReadForFix::Bytes(b) => assert_eq!(b, b"hello"),
ReadForFix::Skipped(_) => panic!("expected Bytes, got Skipped"),
}
}
#[test]
fn read_for_fix_returns_skipped_when_over_limit() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("big.txt");
std::fs::write(&f, vec![b'x'; 1024]).unwrap();
let ctx = FixContext {
root: dir.path(),
dry_run: false,
fix_size_limit: Some(64),
};
match read_for_fix(&f, Path::new("big.txt"), &ctx).unwrap() {
ReadForFix::Skipped(FixOutcome::Skipped(_)) => {}
ReadForFix::Skipped(FixOutcome::Applied(_)) => {
panic!("expected Skipped, got Skipped(Applied)")
}
ReadForFix::Bytes(_) => panic!("expected Skipped, got Bytes"),
}
}
#[test]
fn fix_outcome_variants_are_constructible() {
let _applied = FixOutcome::Applied("created LICENSE".into());
let _skipped = FixOutcome::Skipped("already exists".into());
}
}