use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use alint_core::{FileIndex, WalkOptions, walk};
use crate::init::Detection;
use crate::progress::Progress;
use super::proposal::{Proposal, ProposalKind};
#[derive(Debug)]
pub struct Scan {
pub root: PathBuf,
pub index: FileIndex,
pub detection: Detection,
extends_uris: Vec<String>,
pub has_git: bool,
}
impl Scan {
pub fn collect(root: &Path, progress: &Progress) -> Result<Self> {
let phase = progress.phase("Walking repository", None);
let index = walk(
root,
&WalkOptions {
respect_gitignore: true,
extra_ignores: Vec::new(),
},
)
.with_context(|| format!("walking {}", root.display()))?;
let n = index.entries.len();
phase.finish(&format!("Walked {n} entries"));
let detection = crate::init::detect(root, true);
let extends_uris = read_existing_extends(root);
let has_git = alint_core::git::collect_tracked_paths(root).is_some();
Ok(Self {
root: root.to_path_buf(),
index,
detection,
extends_uris,
has_git,
})
}
pub fn config_already_covers(&self, proposal: &Proposal) -> bool {
match &proposal.kind {
ProposalKind::BundledRuleset { uri } => self.has_extends(uri),
ProposalKind::Rule { .. } => false,
}
}
pub fn has_extends(&self, uri: &str) -> bool {
self.extends_uris.iter().any(|e| e == uri)
}
pub fn text_files(&self) -> impl Iterator<Item = &alint_core::FileEntry> + '_ {
self.index.files().filter(|e| !is_likely_binary(&e.path))
}
#[cfg(test)]
pub fn for_test(detection: Detection, index: FileIndex, extends_uris: Vec<String>) -> Self {
Self {
root: PathBuf::from("/fake"),
index,
detection,
extends_uris,
has_git: false,
}
}
}
fn read_existing_extends(root: &Path) -> Vec<String> {
for name in [".alint.yml", ".alint.yaml", "alint.yml", "alint.yaml"] {
let path = root.join(name);
let Ok(body) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(doc) = serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&body) else {
return Vec::new();
};
return collect_extends(&doc);
}
Vec::new()
}
fn collect_extends(doc: &serde_yaml_ng::Value) -> Vec<String> {
let Some(map) = doc.as_mapping() else {
return Vec::new();
};
let Some(extends) = map.get(serde_yaml_ng::Value::String("extends".into())) else {
return Vec::new();
};
let Some(seq) = extends.as_sequence() else {
return Vec::new();
};
seq.iter()
.filter_map(|entry| match entry {
serde_yaml_ng::Value::String(s) => Some(s.clone()),
serde_yaml_ng::Value::Mapping(m) => m
.get(serde_yaml_ng::Value::String("url".into()))
.and_then(|v| v.as_str())
.map(str::to_string),
_ => None,
})
.collect()
}
fn is_likely_binary(path: &Path) -> bool {
matches!(
path.extension().and_then(|s| s.to_str()),
Some(
"png"
| "jpg"
| "jpeg"
| "gif"
| "webp"
| "ico"
| "icns"
| "pdf"
| "zip"
| "tar"
| "gz"
| "tgz"
| "bz2"
| "xz"
| "7z"
| "exe"
| "dll"
| "so"
| "dylib"
| "bin"
| "wasm"
| "ttf"
| "otf"
| "woff"
| "woff2"
| "mp3"
| "mp4"
| "wav"
| "ogg"
| "mov"
| "webm"
| "class"
| "jar"
| "lock"
)
)
}
#[cfg(test)]
mod tests {
use super::*;
fn td() -> tempfile::TempDir {
tempfile::Builder::new()
.prefix("alint-suggest-")
.tempdir()
.unwrap()
}
fn touch(root: &Path, rel: &str, body: &str) {
let path = root.join(rel);
if let Some(p) = path.parent() {
std::fs::create_dir_all(p).unwrap();
}
std::fs::write(path, body).unwrap();
}
#[test]
fn empty_repo_scan_walks_zero_entries() {
let tmp = td();
let scan = Scan::collect(tmp.path(), &Progress::null()).unwrap();
assert_eq!(scan.index.entries.len(), 0);
assert!(!scan.has_git);
assert!(scan.detection.languages.is_empty());
}
#[test]
fn extends_list_reads_string_entries() {
let tmp = td();
touch(
tmp.path(),
".alint.yml",
"version: 1\nextends:\n - alint://bundled/oss-baseline@v1\n - alint://bundled/rust@v1\nrules: []\n",
);
let scan = Scan::collect(tmp.path(), &Progress::null()).unwrap();
assert!(scan.has_extends("alint://bundled/oss-baseline@v1"));
assert!(scan.has_extends("alint://bundled/rust@v1"));
assert!(!scan.has_extends("alint://bundled/node@v1"));
}
#[test]
fn extends_list_reads_mapping_entries() {
let tmp = td();
touch(
tmp.path(),
".alint.yml",
"version: 1\nextends:\n - url: alint://bundled/python@v1\n only: [python-pyproject-toml]\nrules: []\n",
);
let scan = Scan::collect(tmp.path(), &Progress::null()).unwrap();
assert!(scan.has_extends("alint://bundled/python@v1"));
}
#[test]
fn malformed_config_doesnt_error_scan() {
let tmp = td();
touch(tmp.path(), ".alint.yml", "{this is not: valid: yaml::");
let scan = Scan::collect(tmp.path(), &Progress::null()).unwrap();
assert!(scan.extends_uris.is_empty());
}
#[test]
fn binary_filter_skips_common_assets() {
assert!(is_likely_binary(Path::new("logo.png")));
assert!(is_likely_binary(Path::new("dist/app.wasm")));
assert!(!is_likely_binary(Path::new("src/main.rs")));
assert!(!is_likely_binary(Path::new("README.md")));
}
}