use anyhow::{Context, Result};
use async_trait::async_trait;
use globset::{Glob, GlobSet, GlobSetBuilder};
use serde::Deserialize;
use std::sync::Arc;
use crate::check::{Check, ConfiguredCheck};
use crate::input::{ChangeKind, ChangeSet, SourceTree};
use crate::output::{CheckResult, Finding, Location, Severity};
const DEFAULT_MAX_LINES: usize = 500;
#[derive(Debug, Default)]
pub struct FileSizeCheck;
#[async_trait]
impl Check for FileSizeCheck {
fn id(&self) -> &str {
"file-size"
}
fn description(&self) -> &str {
"flags files exceeding configured line limits"
}
fn configure(&self, config: &toml::Value) -> Result<Arc<dyn ConfiguredCheck>> {
Ok(Arc::new(parse_config(config)?))
}
}
#[async_trait]
impl ConfiguredCheck for ParsedFileSizeConfig {
async fn run(&self, changeset: &ChangeSet, tree: &dyn SourceTree) -> Result<CheckResult> {
let mut findings = Vec::new();
for changed_file in &changeset.changed_files {
if matches!(changed_file.kind, ChangeKind::Deleted) {
continue;
}
if let Some(exclude_globs) = &self.exclude_globs {
if exclude_globs.is_match(&changed_file.path) {
continue;
}
}
let Ok(contents) = tree.read_file(&changed_file.path) else {
continue;
};
let Ok(contents) = std::str::from_utf8(&contents) else {
continue;
};
let line_count = contents.lines().count();
if line_count <= self.max_lines {
continue;
}
if !file_grew_in_change(changed_file, changeset) {
continue;
}
let growth_message = changeset
.file_line_deltas
.get(&changed_file.path)
.map(|delta| {
format!(
" File grew by +{} / -{} lines in this change.",
delta.added_lines, delta.removed_lines
)
})
.unwrap_or_default();
findings.push(Finding {
severity: Severity::Warning,
message: format!(
"file has {line_count} lines, exceeding configured max_lines={}.{}",
self.max_lines, growth_message
),
location: Some(Location {
path: changed_file.path.clone(),
line: Some((self.max_lines.saturating_add(1)) as u32),
column: Some(1),
}),
remediation: Some(
"Split the file or refactor into smaller modules to reduce line count."
.to_owned(),
),
suggested_fix: None,
});
}
Ok(CheckResult {
check_id: "file-size".to_owned(),
findings,
})
}
}
fn file_grew_in_change(changed_file: &crate::input::ChangedFile, changeset: &ChangeSet) -> bool {
if matches!(changed_file.kind, ChangeKind::Added) {
return true;
}
let Some(delta) = changeset.file_line_deltas.get(&changed_file.path) else {
return false;
};
delta.added_lines > delta.removed_lines
}
#[derive(Debug, Deserialize)]
struct FileSizeConfig {
#[serde(default)]
max_lines: Option<i64>,
#[serde(default)]
exclude_globs: Option<Vec<String>>,
}
struct ParsedFileSizeConfig {
max_lines: usize,
exclude_globs: Option<GlobSet>,
}
fn parse_config(config: &toml::Value) -> Result<ParsedFileSizeConfig> {
let parsed: FileSizeConfig = config
.clone()
.try_into()
.context("invalid file-size check config")?;
let max_lines = match parsed.max_lines {
Some(value) => {
usize::try_from(value).context("`max_lines` must be a non-negative integer")?
}
None => DEFAULT_MAX_LINES,
};
Ok(ParsedFileSizeConfig {
max_lines,
exclude_globs: parse_exclude_globs(parsed.exclude_globs.as_deref())?,
})
}
fn parse_exclude_globs(patterns: Option<&[String]>) -> Result<Option<GlobSet>> {
let Some(patterns) = patterns else {
return Ok(None);
};
if patterns.is_empty() {
return Ok(None);
}
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
let glob = Glob::new(pattern)
.with_context(|| format!("invalid `exclude_globs` pattern: {pattern}"))?;
builder.add(glob);
}
let globset = builder
.build()
.context("failed to compile `exclude_globs` patterns")?;
Ok(Some(globset))
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use tempfile::tempdir;
use crate::check::Check;
use crate::input::{ChangeKind, ChangeSet, ChangedFile, FileLineDelta};
use crate::source_tree::LocalSourceTree;
use super::FileSizeCheck;
#[tokio::test]
async fn flags_files_over_limit() {
let temp = tempdir().expect("create temp dir");
fs::write(temp.path().join("big.rs"), "a\nb\nc\n").expect("write file");
let check = FileSizeCheck;
let tree = LocalSourceTree::new(temp.path()).expect("create tree");
let result = check
.run(
&ChangeSet::new(vec![ChangedFile {
path: Path::new("big.rs").to_path_buf(),
kind: ChangeKind::Modified,
old_path: None,
}])
.with_file_line_delta(
Path::new("big.rs").to_path_buf(),
FileLineDelta {
added_lines: 2,
removed_lines: 0,
},
),
&tree,
&toml::Value::Table(toml::toml! { max_lines = 2 }),
)
.await
.expect("run check");
assert_eq!(result.findings.len(), 1);
assert!(result.findings[0].message.contains("max_lines=2"));
}
#[tokio::test]
async fn ignores_files_within_limit() {
let temp = tempdir().expect("create temp dir");
fs::write(temp.path().join("small.rs"), "a\nb\n").expect("write file");
let check = FileSizeCheck;
let tree = LocalSourceTree::new(temp.path()).expect("create tree");
let result = check
.run(
&ChangeSet::new(vec![ChangedFile {
path: Path::new("small.rs").to_path_buf(),
kind: ChangeKind::Modified,
old_path: None,
}]),
&tree,
&toml::Value::Table(toml::toml! { max_lines = 5 }),
)
.await
.expect("run check");
assert!(result.findings.is_empty());
}
#[tokio::test]
async fn ignores_oversized_file_when_net_lines_do_not_increase() {
let temp = tempdir().expect("create temp dir");
fs::write(temp.path().join("big.rs"), "a\nb\nc\n").expect("write file");
let check = FileSizeCheck;
let tree = LocalSourceTree::new(temp.path()).expect("create tree");
let result = check
.run(
&ChangeSet::new(vec![ChangedFile {
path: Path::new("big.rs").to_path_buf(),
kind: ChangeKind::Modified,
old_path: None,
}])
.with_file_line_delta(
Path::new("big.rs").to_path_buf(),
FileLineDelta {
added_lines: 1,
removed_lines: 2,
},
),
&tree,
&toml::Value::Table(toml::toml! { max_lines = 2 }),
)
.await
.expect("run check");
assert!(result.findings.is_empty());
}
#[tokio::test]
async fn excludes_configured_paths() {
let temp = tempdir().expect("create temp dir");
fs::write(temp.path().join("package-lock.json"), "a\nb\nc\n").expect("write file");
let check = FileSizeCheck;
let tree = LocalSourceTree::new(temp.path()).expect("create tree");
let result = check
.run(
&ChangeSet::new(vec![ChangedFile {
path: Path::new("package-lock.json").to_path_buf(),
kind: ChangeKind::Modified,
old_path: None,
}]),
&tree,
&toml::Value::Table(toml::toml! {
max_lines = 2
exclude_globs = ["**/package-lock.json"]
}),
)
.await
.expect("run check");
assert!(result.findings.is_empty());
}
}