use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::domain::config::{Config, FileFilter, RelevanceMode, Rule};
use crate::domain::ignore;
use crate::errors::{Error, Result};
use crate::io::configfs::{self, RuleScope};
use crate::io::files;
use crate::io::plugins::PluginResolution;
pub fn retarget_session_scopes(
scopes: &mut BTreeMap<String, RuleScope>,
cwd: &Path,
after: &FileFilter,
) {
for scope in scopes.values_mut() {
if scope.dir == cwd {
scope.files = after.clone();
}
}
}
pub fn known_rules(config: &Config) -> BTreeSet<&str> {
config.rules.iter().map(|r| r.name.as_str()).collect()
}
pub fn reject_unresolved(unresolved: Vec<files::Unresolved>) -> Result<()> {
if unresolved.is_empty() {
return Ok(());
}
Err(Error::Io(
unresolved
.into_iter()
.map(|u| u.message)
.collect::<Vec<_>>()
.join("\n"),
))
}
pub fn target_files(
cwd: &Path,
config: &Config,
scopes: &BTreeMap<String, RuleScope>,
cli_files: &[PathBuf],
) -> Result<BTreeSet<PathBuf>> {
let mut out: BTreeSet<PathBuf> = BTreeSet::new();
for rule in &config.rules {
if matches!(rule.relevance_mode(), RelevanceMode::Never) {
continue;
}
let fallback;
let scope = match scopes.get(&rule.name) {
Some(s) => s,
None => {
fallback = RuleScope {
dir: cwd.to_path_buf(),
files: config.files.clone(),
};
&fallback
}
};
for f in resolve_files(cwd, rule, cli_files, scope, &config.files.exclude)? {
out.insert(f);
}
}
Ok(out)
}
pub fn resolve_files(
cwd: &Path,
rule: &Rule,
cli_files: &[PathBuf],
scope: &RuleScope,
global_exclude: &[String],
) -> Result<Vec<PathBuf>> {
let filter = rule.files.as_ref().unwrap_or(&scope.files);
let scoped_exclude: &[String] = if rule.files.is_some() {
&scope.files.exclude
} else {
&[]
};
if cli_files.is_empty() {
return files::resolve_scoped_excluding(
&scope.dir,
cwd,
filter,
scoped_exclude,
global_exclude,
);
}
let scoped = scope_cli_files(cwd, &scope.dir, cli_files);
let included = files::keep_included(&scope.dir, cwd, &scoped, &filter.include)?;
let mut scoped_denies = filter.exclude.clone();
scoped_denies.extend(scoped_exclude.iter().cloned());
files::drop_excluded(&scope.dir, cwd, &included, &scoped_denies, global_exclude)
}
fn scope_cli_files(cwd: &Path, dir: &Path, cli_files: &[PathBuf]) -> Vec<PathBuf> {
cli_files
.iter()
.filter(|f| {
let abs = if f.is_absolute() {
(*f).clone()
} else {
cwd.join(f)
};
configfs::normalize(&abs).starts_with(dir)
})
.cloned()
.collect()
}
pub fn check(
cwd: &Path,
targets: &BTreeSet<PathBuf>,
known: &BTreeSet<&str>,
plugins: &[PluginResolution],
) -> Result<()> {
let mut problems: Vec<String> = Vec::new();
for rel in targets {
let Some(text) = files::read_text(cwd, rel)? else {
continue;
};
for p in ignore::validate(&text, known) {
problems.push(format!(
" {}:{}: {}",
files::to_slash(rel),
p.line,
p.message
));
}
}
if problems.is_empty() {
Ok(())
} else {
Err(Error::IgnoreDirective {
plugins: unknown_rule_trailer(&problems, plugins),
problems: problems.join("\n"),
})
}
}
fn unknown_rule_trailer(problems: &[String], plugins: &[PluginResolution]) -> String {
if plugins.is_empty() || !problems.iter().any(|p| p.contains("unknown rule")) {
return String::new();
}
let lines: Vec<String> = plugins
.iter()
.map(|p| format!(" {}", p.to_human()))
.collect();
format!(
"\nloaded plugins (a rule a plugin declares is missing when its cached copy \
is older than the pin promises):\n{}\n\
inspect with `llmlint plugins`, then `llmlint plugins clear` (or \
LLMLINT_PLUGIN_REFRESH=1) to refetch.",
lines.join("\n")
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn scope_cli_files_bounds_to_the_scope_dir_relative_and_absolute() {
let tmp = tempdir().unwrap();
let cwd = tmp.path();
let dir = cwd.join("backend");
let abs_in = cwd.join("backend/x.rs"); let abs_out = cwd.join("other/y.rs"); let files = vec![
PathBuf::from("backend/svc.rs"), PathBuf::from("app.rs"), abs_in.clone(),
abs_out,
];
let kept = scope_cli_files(cwd, &dir, &files);
assert_eq!(kept, vec![PathBuf::from("backend/svc.rs"), abs_in]);
}
use crate::domain::config::FileFilter;
fn touch(root: &Path, rel: &str) {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, "x").unwrap();
}
fn rule_named(name: &str, files: Option<FileFilter>) -> Rule {
Rule {
name: name.into(),
description: "true when ok; false otherwise.".into(),
r#override: false,
agent: None,
judges: None,
files,
rationale: None,
relevance: None,
require_line_attribution: None,
}
}
#[test]
fn resolve_files_applies_global_exclude_over_a_rule_include() {
let tmp = tempdir().unwrap();
let cwd = tmp.path();
touch(cwd, "tests/unit.rs");
touch(cwd, "tests/fixtures/big.json");
let rule = rule_named(
"judge_tests",
Some(FileFilter {
include: vec!["**/tests/**".into()],
exclude: vec![],
}),
);
let scope = RuleScope {
dir: cwd.to_path_buf(),
files: FileFilter {
include: vec![],
exclude: vec!["tests/fixtures/**".into()],
},
};
let files = resolve_files(cwd, &rule, &[], &scope, &["tests/fixtures/**".into()]).unwrap();
assert_eq!(files, vec![PathBuf::from("tests/unit.rs")]);
}
#[test]
fn resolve_files_fallback_still_honors_global_exclude() {
let tmp = tempdir().unwrap();
let cwd = tmp.path();
touch(cwd, "src/a.rs");
touch(cwd, "vendored/gen.rs");
let rule = rule_named("r", None);
let scope = RuleScope {
dir: cwd.to_path_buf(),
files: FileFilter {
include: vec!["**/*.rs".into()],
exclude: vec![],
},
};
let files = resolve_files(cwd, &rule, &[], &scope, &["vendored/**".into()]).unwrap();
assert_eq!(files, vec![PathBuf::from("src/a.rs")]);
}
#[test]
fn cli_files_intersect_a_rules_own_globs() {
let tmp = tempdir().unwrap();
let cwd = tmp.path();
touch(cwd, "src/a.rs");
touch(cwd, "src/b.rs");
touch(cwd, "docs/x.md");
let rule = rule_named(
"rust_rule",
Some(FileFilter {
include: vec!["src/**".into()],
exclude: vec![],
}),
);
let scope = RuleScope {
dir: cwd.to_path_buf(),
files: FileFilter::default(),
};
let cli = vec![PathBuf::from("src/a.rs"), PathBuf::from("docs/x.md")];
let files = resolve_files(cwd, &rule, &cli, &scope, &[]).unwrap();
assert_eq!(files, vec![PathBuf::from("src/a.rs")]);
}
#[test]
fn cli_files_with_no_glob_overlap_resolve_to_nothing() {
let tmp = tempdir().unwrap();
let cwd = tmp.path();
touch(cwd, "docs/x.md");
touch(cwd, "src/a.rs");
let rule = rule_named(
"rust_rule",
Some(FileFilter {
include: vec!["src/**".into()],
exclude: vec![],
}),
);
let scope = RuleScope {
dir: cwd.to_path_buf(),
files: FileFilter::default(),
};
let files = resolve_files(cwd, &rule, &[PathBuf::from("docs/x.md")], &scope, &[]).unwrap();
assert!(files.is_empty(), "expected no files, got {files:?}");
}
#[test]
fn cli_files_intersect_the_configs_globs_for_a_rule_without_its_own() {
let tmp = tempdir().unwrap();
let cwd = tmp.path();
touch(cwd, "src/a.rs");
touch(cwd, "README.md");
let rule = rule_named("r", None);
let scope = RuleScope {
dir: cwd.to_path_buf(),
files: FileFilter {
include: vec!["src/**".into()],
exclude: vec![],
},
};
let cli = vec![PathBuf::from("README.md"), PathBuf::from("src/a.rs")];
let files = resolve_files(cwd, &rule, &cli, &scope, &[]).unwrap();
assert_eq!(files, vec![PathBuf::from("src/a.rs")]);
}
#[test]
fn cli_files_are_kept_whole_when_no_include_globs_are_configured() {
let tmp = tempdir().unwrap();
let cwd = tmp.path();
touch(cwd, "README.md");
let rule = rule_named("r", None);
let scope = RuleScope {
dir: cwd.to_path_buf(),
files: FileFilter::default(),
};
let cli = vec![PathBuf::from("README.md")];
let files = resolve_files(cwd, &rule, &cli, &scope, &[]).unwrap();
assert_eq!(files, cli);
}
#[test]
fn a_rules_own_exclude_drops_a_passed_file_it_denies() {
let tmp = tempdir().unwrap();
let cwd = tmp.path();
touch(cwd, "src/a.rs");
touch(cwd, "src/gen.rs");
let rule = rule_named(
"rust_rule",
Some(FileFilter {
include: vec!["src/**".into()],
exclude: vec!["**/gen.rs".into()],
}),
);
let scope = RuleScope {
dir: cwd.to_path_buf(),
files: FileFilter::default(),
};
let cli = vec![PathBuf::from("src/a.rs"), PathBuf::from("src/gen.rs")];
let files = resolve_files(cwd, &rule, &cli, &scope, &[]).unwrap();
assert_eq!(files, vec![PathBuf::from("src/a.rs")]);
}
#[test]
fn scope_cli_files_ancestor_scope_keeps_all_files_under_cwd() {
let tmp = tempdir().unwrap();
let cwd = tmp.path();
let files = vec![PathBuf::from("a.rs"), PathBuf::from("sub/b.rs")];
assert_eq!(scope_cli_files(cwd, cwd, &files), files);
}
}