#[cfg(feature = "filesystem")]
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
#[cfg(feature = "filesystem")]
use std::path::PathBuf;
#[cfg(feature = "filesystem")]
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
#[cfg(feature = "filesystem")]
use rayon::iter::ParallelBridge;
#[cfg(feature = "filesystem")]
use rayon::prelude::*;
#[cfg(feature = "filesystem")]
use rust_i18n::t;
use crate::config::LintConfig;
use crate::diagnostics::Diagnostic;
#[cfg(feature = "filesystem")]
use crate::diagnostics::{ConfigError, CoreError, LintResult, ValidationError, ValidationOutcome};
use crate::file_types::{FileType, detect_file_type};
#[cfg(feature = "filesystem")]
use crate::file_utils;
use crate::parsers::frontmatter::normalize_line_endings;
use crate::registry::ValidatorRegistry;
#[cfg(feature = "filesystem")]
use crate::rules::project_level::run_project_level_checks;
#[cfg(feature = "filesystem")]
use crate::schemas;
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub diagnostics: Vec<Diagnostic>,
pub files_checked: usize,
pub validation_time_ms: Option<u64>,
pub validator_factories_registered: usize,
}
impl ValidationResult {
pub fn new(diagnostics: Vec<Diagnostic>, files_checked: usize) -> Self {
Self {
diagnostics,
files_checked,
validation_time_ms: None,
validator_factories_registered: 0,
}
}
pub fn with_timing(mut self, ms: u64) -> Self {
self.validation_time_ms = Some(ms);
self
}
pub fn with_validator_factories_registered(mut self, count: usize) -> Self {
self.validator_factories_registered = count;
self
}
}
#[derive(Default)]
pub(crate) struct CompiledFilesConfig {
include_as_memory: Vec<glob::Pattern>,
include_as_generic: Vec<glob::Pattern>,
exclude: Vec<glob::Pattern>,
}
impl CompiledFilesConfig {
fn is_empty(&self) -> bool {
self.include_as_memory.is_empty()
&& self.include_as_generic.is_empty()
&& self.exclude.is_empty()
}
}
#[cfg(feature = "filesystem")]
fn compile_patterns_with_diagnostics(
patterns: &[String],
config_file: &Path,
) -> (Vec<glob::Pattern>, Vec<Diagnostic>) {
let mut compiled = Vec::with_capacity(patterns.len());
let mut diagnostics = Vec::new();
for p in patterns {
let normalized = p.replace('\\', "/");
match glob::Pattern::new(&normalized) {
Ok(pat) => compiled.push(pat),
Err(e) => {
diagnostics.push(
Diagnostic::warning(
config_file.to_path_buf(),
1,
0,
"config::glob",
t!(
"rules.invalid_glob_pattern",
pattern = p,
error = e.to_string()
),
)
.with_suggestion(t!("rules.invalid_glob_pattern_suggestion")),
);
}
}
}
(compiled, diagnostics)
}
fn compile_patterns_lenient(patterns: &[String]) -> Vec<glob::Pattern> {
patterns
.iter()
.filter_map(|p| {
let normalized = p.replace('\\', "/");
glob::Pattern::new(&normalized).ok()
})
.collect()
}
fn compile_files_config(files: &crate::config::FilesConfig) -> CompiledFilesConfig {
CompiledFilesConfig {
include_as_memory: compile_patterns_lenient(&files.include_as_memory),
include_as_generic: compile_patterns_lenient(&files.include_as_generic),
exclude: compile_patterns_lenient(&files.exclude),
}
}
#[cfg(feature = "filesystem")]
fn compile_files_config_with_diagnostics(
files: &crate::config::FilesConfig,
config_file: &Path,
) -> (CompiledFilesConfig, Vec<Diagnostic>) {
let mut all_diagnostics = Vec::new();
let (include_as_memory, diags) =
compile_patterns_with_diagnostics(&files.include_as_memory, config_file);
all_diagnostics.extend(diags);
let (include_as_generic, diags) =
compile_patterns_with_diagnostics(&files.include_as_generic, config_file);
all_diagnostics.extend(diags);
let (exclude, diags) = compile_patterns_with_diagnostics(&files.exclude, config_file);
all_diagnostics.extend(diags);
(
CompiledFilesConfig {
include_as_memory,
include_as_generic,
exclude,
},
all_diagnostics,
)
}
const FILES_MATCH_OPTIONS: glob::MatchOptions = glob::MatchOptions {
case_sensitive: true,
require_literal_separator: true,
require_literal_leading_dot: false,
};
fn resolve_with_compiled(
path: &Path,
root_dir: Option<&Path>,
compiled: &CompiledFilesConfig,
) -> FileType {
if compiled.is_empty() {
return detect_file_type(path);
}
let rel_path = if let Some(root) = root_dir {
normalize_rel_path(path, root)
} else {
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string()
};
for pattern in &compiled.exclude {
if pattern.matches_with(&rel_path, FILES_MATCH_OPTIONS) {
return FileType::Unknown;
}
}
for pattern in &compiled.include_as_memory {
if pattern.matches_with(&rel_path, FILES_MATCH_OPTIONS) {
return FileType::ClaudeMd;
}
}
for pattern in &compiled.include_as_generic {
if pattern.matches_with(&rel_path, FILES_MATCH_OPTIONS) {
return FileType::GenericMarkdown;
}
}
detect_file_type(path)
}
pub fn resolve_file_type(path: &Path, config: &LintConfig) -> FileType {
let files = config.files_config();
if files.include_as_memory.is_empty()
&& files.include_as_generic.is_empty()
&& files.exclude.is_empty()
{
return detect_file_type(path);
}
let compiled = compile_files_config(files);
resolve_with_compiled(path, config.root_dir().map(|p| p.as_path()), &compiled)
}
#[cfg(feature = "filesystem")]
pub fn validate_file(path: &Path, config: &LintConfig) -> LintResult<ValidationOutcome> {
let mut registry = ValidatorRegistry::with_defaults();
for name in &config.rules().disabled_validators {
registry.disable_validator_owned(name);
}
validate_file_with_registry(path, config, ®istry)
}
#[cfg(feature = "filesystem")]
pub fn validate_file_with_registry(
path: &Path,
config: &LintConfig,
registry: &ValidatorRegistry,
) -> LintResult<ValidationOutcome> {
let file_type = resolve_file_type(path, config);
validate_file_with_type(path, file_type, config, registry)
}
#[cfg(feature = "filesystem")]
fn validate_file_with_type(
path: &Path,
file_type: FileType,
config: &LintConfig,
registry: &ValidatorRegistry,
) -> LintResult<ValidationOutcome> {
if file_type == FileType::Unknown {
return Ok(ValidationOutcome::Skipped);
}
let raw_content = match file_utils::safe_read_file(path) {
Ok(content) => content,
Err(CoreError::File(file_error)) => {
return Ok(ValidationOutcome::IoError(file_error));
}
Err(other) => return Err(other),
};
let content = normalize_line_endings(&raw_content);
let validators = registry.validators_for(file_type);
let disabled = &config.rules().disabled_validators;
let mut diagnostics = Vec::new();
if disabled.is_empty() {
for validator in validators {
diagnostics.extend(validator.validate(path, &content, config));
}
} else {
let disabled_set: HashSet<&str> = disabled.iter().map(|s| s.as_str()).collect();
for validator in validators {
if disabled_set.contains(validator.name()) {
continue;
}
diagnostics.extend(validator.validate(path, &content, config));
}
}
Ok(ValidationOutcome::Success(diagnostics))
}
pub fn validate_content(
path: &Path,
content: &str,
config: &LintConfig,
registry: &ValidatorRegistry,
) -> Vec<Diagnostic> {
let file_type = resolve_file_type(path, config);
if file_type == FileType::Unknown {
return vec![];
}
let content = normalize_line_endings(content);
let validators = registry.validators_for(file_type);
let disabled = &config.rules().disabled_validators;
let mut diagnostics = Vec::new();
if disabled.is_empty() {
for validator in validators {
diagnostics.extend(validator.validate(path, &content, config));
}
} else {
let disabled_set: HashSet<&str> = disabled.iter().map(|s| s.as_str()).collect();
for validator in validators {
if disabled_set.contains(validator.name()) {
continue;
}
diagnostics.extend(validator.validate(path, &content, config));
}
}
diagnostics
}
#[cfg(feature = "filesystem")]
pub fn validate_project(path: &Path, config: &LintConfig) -> LintResult<ValidationResult> {
let mut registry = ValidatorRegistry::with_defaults();
for name in &config.rules().disabled_validators {
registry.disable_validator_owned(name);
}
validate_project_with_registry(path, config, ®istry)
}
#[cfg(feature = "filesystem")]
struct ExcludePattern {
pattern: glob::Pattern,
dir_only_prefix: Option<String>,
allow_probe: bool,
}
fn normalize_rel_path(entry_path: &Path, root: &Path) -> String {
let rel_path = entry_path.strip_prefix(root).unwrap_or(entry_path);
let path_str = rel_path.to_string_lossy().replace('\\', "/");
match path_str.strip_prefix("./") {
Some(stripped) => stripped.to_string(),
None => path_str,
}
}
#[cfg(feature = "filesystem")]
fn compile_exclude_patterns(excludes: &[String]) -> LintResult<Vec<ExcludePattern>> {
excludes
.iter()
.map(|pattern| {
let normalized = pattern.replace('\\', "/");
let (glob_str, dir_only_prefix) = if let Some(prefix) = normalized.strip_suffix('/') {
(format!("{}/**", prefix), Some(prefix.to_string()))
} else {
(normalized.clone(), None)
};
let allow_probe = dir_only_prefix.is_some() || glob_str.contains("**");
let compiled = glob::Pattern::new(&glob_str).map_err(|e| {
CoreError::Config(ConfigError::InvalidExcludePattern {
pattern: pattern.clone(),
message: e.to_string(),
})
})?;
Ok(ExcludePattern {
pattern: compiled,
dir_only_prefix,
allow_probe,
})
})
.collect()
}
#[cfg(feature = "filesystem")]
fn should_prune_dir(rel_dir: &str, exclude_patterns: &[ExcludePattern]) -> bool {
if rel_dir.is_empty() {
return false;
}
let probe = format!("{}/__agnix_probe__", rel_dir.trim_end_matches('/'));
exclude_patterns
.iter()
.any(|p| p.pattern.matches(rel_dir) || (p.allow_probe && p.pattern.matches(&probe)))
}
#[cfg(feature = "filesystem")]
fn is_excluded_file(path_str: &str, exclude_patterns: &[ExcludePattern]) -> bool {
exclude_patterns
.iter()
.any(|p| p.pattern.matches(path_str) && p.dir_only_prefix.as_deref() != Some(path_str))
}
#[cfg(feature = "filesystem")]
pub fn validate_project_rules(root: &Path, config: &LintConfig) -> LintResult<Vec<Diagnostic>> {
use ignore::WalkBuilder;
use std::sync::Arc;
let root_dir = resolve_validation_root(root)?;
let mut config = config.clone();
config.set_root_dir(root_dir.clone());
let exclude_patterns = Arc::new(compile_exclude_patterns(config.exclude())?);
let walk_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let root_path = root_dir.clone();
let mut agents_md_paths: Vec<PathBuf> = Vec::new();
let mut instruction_file_paths: Vec<PathBuf> = Vec::new();
let max_files = config.max_files_to_validate();
for (files_seen, entry) in WalkBuilder::new(&walk_root)
.hidden(false)
.git_ignore(true)
.git_exclude(false)
.filter_entry({
let exclude_patterns = Arc::clone(&exclude_patterns);
let root_path = root_path.clone();
move |entry| {
let entry_path = entry.path();
if entry_path == root_path {
return true;
}
if entry.file_type().is_some_and(|ft| ft.is_dir()) {
let rel_path = normalize_rel_path(entry_path, &root_path);
return !should_prune_dir(&rel_path, exclude_patterns.as_slice());
}
true
}
})
.build()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_file())
.enumerate()
{
if let Some(limit) = max_files {
if files_seen >= limit {
return Err(CoreError::Validation(ValidationError::TooManyFiles {
count: files_seen,
limit,
}));
}
}
let file_path = entry.path().to_path_buf();
let path_str = normalize_rel_path(&file_path, &root_path);
if is_excluded_file(&path_str, exclude_patterns.as_slice()) {
continue;
}
if file_path.file_name().and_then(|n| n.to_str()) == Some("AGENTS.md") {
agents_md_paths.push(file_path.clone());
}
if schemas::cross_platform::is_instruction_file(&file_path) {
instruction_file_paths.push(file_path);
}
}
agents_md_paths.sort();
instruction_file_paths.sort();
Ok(run_project_level_checks(
&agents_md_paths,
&instruction_file_paths,
&config,
&root_dir,
))
}
#[cfg(feature = "filesystem")]
pub fn validate_project_with_registry(
path: &Path,
config: &LintConfig,
registry: &ValidatorRegistry,
) -> LintResult<ValidationResult> {
use ignore::WalkBuilder;
use std::sync::Arc;
use std::time::Instant;
let validation_start = Instant::now();
let root_dir = resolve_validation_root(path)?;
let mut config = config.clone();
config.set_root_dir(root_dir.clone());
if config.get_import_cache().is_none() {
let import_cache: crate::parsers::ImportCache =
std::sync::Arc::new(std::sync::RwLock::new(HashMap::new()));
config.set_import_cache(import_cache);
}
let exclude_patterns = compile_exclude_patterns(config.exclude())?;
let exclude_patterns = Arc::new(exclude_patterns);
let config_file = root_dir.join(".agnix.toml");
let (compiled_files_inner, config_diags) =
compile_files_config_with_diagnostics(config.files_config(), &config_file);
let compiled_files = Arc::new(compiled_files_inner);
let root_path = root_dir.clone();
let walk_root = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let files_checked = Arc::new(AtomicUsize::new(0));
let limit_exceeded = Arc::new(AtomicBool::new(false));
let max_files = config.max_files_to_validate();
let (mut diagnostics, mut agents_md_paths, mut instruction_file_paths) =
WalkBuilder::new(&walk_root)
.hidden(false)
.git_ignore(true)
.git_exclude(false)
.filter_entry({
let exclude_patterns = Arc::clone(&exclude_patterns);
let root_path = root_path.clone();
move |entry| {
let entry_path = entry.path();
if entry_path == root_path {
return true;
}
if entry.file_type().is_some_and(|ft| ft.is_dir()) {
let rel_path = normalize_rel_path(entry_path, &root_path);
return !should_prune_dir(&rel_path, exclude_patterns.as_slice());
}
true
}
})
.build()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_file())
.filter(|entry| {
let entry_path = entry.path();
let path_str = normalize_rel_path(entry_path, &root_path);
!is_excluded_file(&path_str, exclude_patterns.as_slice())
})
.map(|entry| entry.path().to_path_buf())
.par_bridge()
.fold(
|| {
(
Vec::<Diagnostic>::new(),
Vec::<PathBuf>::new(),
Vec::<PathBuf>::new(),
)
},
|(mut diags, mut agents, mut instructions), file_path| {
if limit_exceeded.load(Ordering::SeqCst) {
return (diags, agents, instructions);
}
let file_type =
resolve_with_compiled(&file_path, Some(&root_path), &compiled_files);
if file_type != FileType::Unknown {
let count = files_checked.fetch_add(1, Ordering::SeqCst) + 1;
if let Some(limit) = max_files {
if count > limit {
limit_exceeded.store(true, Ordering::SeqCst);
return (diags, agents, instructions);
}
}
}
if file_path.file_name().and_then(|n| n.to_str()) == Some("AGENTS.md") {
agents.push(file_path.clone());
}
if schemas::cross_platform::is_instruction_file(&file_path) {
instructions.push(file_path.clone());
}
match validate_file_with_type(&file_path, file_type, &config, registry) {
Ok(ValidationOutcome::Success(file_diagnostics)) => {
diags.extend(file_diagnostics);
}
Ok(ValidationOutcome::IoError(file_error)) => {
diags.push(
Diagnostic::error(
file_path,
0,
0,
"file::read",
t!("rules.file_read_error", error = file_error.to_string()),
)
.with_suggestion(t!("rules.file_read_error_suggestion")),
);
}
Ok(ValidationOutcome::Skipped) => {
}
Err(e) => {
diags.push(
Diagnostic::error(
file_path,
0,
0,
"file::read",
t!("rules.file_read_error", error = e.to_string()),
)
.with_suggestion(t!("rules.file_read_error_suggestion")),
);
}
}
(diags, agents, instructions)
},
)
.reduce(
|| (Vec::new(), Vec::new(), Vec::new()),
|(mut d1, mut a1, mut i1), (d2, a2, i2)| {
d1.extend(d2);
a1.extend(a2);
i1.extend(i2);
(d1, a1, i1)
},
);
diagnostics.extend(config_diags);
if limit_exceeded.load(Ordering::Relaxed) {
if let Some(limit) = max_files {
return Err(CoreError::Validation(ValidationError::TooManyFiles {
count: files_checked.load(Ordering::Relaxed),
limit,
}));
}
}
{
agents_md_paths.sort();
instruction_file_paths.sort();
diagnostics.extend(run_project_level_checks(
&agents_md_paths,
&instruction_file_paths,
&config,
&root_dir,
));
}
diagnostics.sort_by(|a, b| {
a.level
.cmp(&b.level)
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.line.cmp(&b.line))
.then_with(|| a.rule.cmp(&b.rule))
});
let files_checked = files_checked.load(Ordering::Relaxed);
let elapsed_ms = validation_start.elapsed().as_millis().min(u64::MAX as u128) as u64;
let validator_factories_registered = registry.total_validator_count();
Ok(ValidationResult::new(diagnostics, files_checked)
.with_timing(elapsed_ms)
.with_validator_factories_registered(validator_factories_registered))
}
#[cfg(feature = "filesystem")]
fn resolve_validation_root(path: &Path) -> LintResult<PathBuf> {
let metadata = match path.metadata() {
Ok(m) => m,
Err(_) => {
return Err(CoreError::Validation(ValidationError::RootNotFound {
path: path.to_path_buf(),
}));
}
};
let candidate = if metadata.is_file() {
path.parent().unwrap_or(Path::new("."))
} else {
path
};
Ok(std::fs::canonicalize(candidate).unwrap_or_else(|_| candidate.to_path_buf()))
}
#[cfg(test)]
mod validate_content_tests {
use super::*;
use crate::config::LintConfig;
use crate::registry::ValidatorRegistry;
#[test]
fn returns_diagnostics_for_known_file_type() {
let config = LintConfig::default();
let registry = ValidatorRegistry::with_defaults();
let path = Path::new("CLAUDE.md");
let content = "<unclosed>";
let diags = validate_content(path, content, &config, ®istry);
assert!(
!diags.is_empty(),
"Should find diagnostics for unclosed XML tag"
);
}
#[test]
fn returns_empty_for_unknown_file_type() {
let config = LintConfig::default();
let registry = ValidatorRegistry::with_defaults();
let path = Path::new("main.rs");
let diags = validate_content(path, "", &config, ®istry);
assert!(
diags.is_empty(),
"Unknown file type should produce no diagnostics"
);
}
#[test]
fn returns_empty_for_empty_content_with_known_type() {
let config = LintConfig::default();
let registry = ValidatorRegistry::with_defaults();
let path = Path::new("CLAUDE.md");
let diags = validate_content(path, "", &config, ®istry);
assert!(
diags.is_empty(),
"Empty content for a known file type should not produce diagnostics"
);
}
#[test]
fn respects_tool_filter() {
let config = LintConfig::builder()
.tools(vec!["cursor".to_string()])
.build()
.unwrap();
let registry = ValidatorRegistry::with_defaults();
let path = Path::new("CLAUDE.md");
let content = "# Project\n\nSome instructions.";
let _ = validate_content(path, content, &config, ®istry);
}
#[test]
fn crlf_content_produces_same_diagnostics_as_lf() {
let config = LintConfig::default();
let registry = ValidatorRegistry::with_defaults();
let path = Path::new("skill.md");
let lf_content =
"---\nname: test-skill\ndescription: A test\n---\n\n# Instructions\n\n<unclosed>\n";
let crlf_content = "---\r\nname: test-skill\r\ndescription: A test\r\n---\r\n\r\n# Instructions\r\n\r\n<unclosed>\r\n";
let lf_diags = validate_content(path, lf_content, &config, ®istry);
let crlf_diags = validate_content(path, crlf_content, &config, ®istry);
assert_eq!(
lf_diags.len(),
crlf_diags.len(),
"CRLF and LF content should produce the same number of diagnostics.\nLF: {:?}\nCRLF: {:?}",
lf_diags
.iter()
.map(|d| (&d.rule, d.line, d.column))
.collect::<Vec<_>>(),
crlf_diags
.iter()
.map(|d| (&d.rule, d.line, d.column))
.collect::<Vec<_>>(),
);
for (lf_d, crlf_d) in lf_diags.iter().zip(crlf_diags.iter()) {
assert_eq!(
lf_d.rule, crlf_d.rule,
"Same rules should fire for LF and CRLF content"
);
assert_eq!(
lf_d.line, crlf_d.line,
"Line numbers should match between LF and CRLF for rule {}",
lf_d.rule
);
assert_eq!(
lf_d.column, crlf_d.column,
"Column numbers should match between LF and CRLF for rule {}",
lf_d.rule
);
}
}
#[test]
fn lf_validation_is_stable() {
let config = LintConfig::default();
let registry = ValidatorRegistry::with_defaults();
let path = Path::new("CLAUDE.md");
let content = "# Project\n\nInstructions here.\n";
let diags1 = validate_content(path, content, &config, ®istry);
let diags2 = validate_content(path, content, &config, ®istry);
assert_eq!(
diags1.len(),
diags2.len(),
"Repeated validation of LF content should be stable"
);
}
#[test]
fn crlf_validation_is_idempotent() {
let config = LintConfig::default();
let registry = ValidatorRegistry::with_defaults();
let path = Path::new("skill.md");
let crlf_content =
"---\r\nname: test-skill\r\ndescription: A test\r\n---\r\n\r\n# Instructions\r\n";
let diags1 = validate_content(path, crlf_content, &config, ®istry);
let diags2 = validate_content(path, crlf_content, &config, ®istry);
assert_eq!(
diags1.len(),
diags2.len(),
"Repeated validation of CRLF content should be stable"
);
for (d1, d2) in diags1.iter().zip(diags2.iter()) {
assert_eq!(d1.rule, d2.rule);
assert_eq!(d1.line, d2.line);
assert_eq!(d1.column, d2.column);
}
}
#[test]
fn lone_cr_content_produces_same_diagnostics_as_lf() {
let config = LintConfig::default();
let registry = ValidatorRegistry::with_defaults();
let path = Path::new("skill.md");
let lf_content = "---\nname: test-skill\ndescription: A test\n---\n\n# Instructions\n";
let cr_content = "---\rname: test-skill\rdescription: A test\r---\r\r# Instructions\r";
let lf_diags = validate_content(path, lf_content, &config, ®istry);
let cr_diags = validate_content(path, cr_content, &config, ®istry);
assert_eq!(
lf_diags.len(),
cr_diags.len(),
"Lone-CR and LF content should produce the same number of diagnostics.\nLF: {:?}\nCR: {:?}",
lf_diags
.iter()
.map(|d| (&d.rule, d.line, d.column))
.collect::<Vec<_>>(),
cr_diags
.iter()
.map(|d| (&d.rule, d.line, d.column))
.collect::<Vec<_>>(),
);
for (lf_d, cr_d) in lf_diags.iter().zip(cr_diags.iter()) {
assert_eq!(lf_d.rule, cr_d.rule);
assert_eq!(lf_d.line, cr_d.line);
assert_eq!(lf_d.column, cr_d.column);
}
}
}
#[cfg(all(test, feature = "filesystem"))]
mod tests {
use super::*;
#[test]
fn test_should_prune_dir_with_globbed_patterns() {
let patterns =
compile_exclude_patterns(&vec!["target/**".to_string(), "**/target/**".to_string()])
.unwrap();
assert!(
should_prune_dir("target", &patterns),
"Expected target/** to prune target directory"
);
assert!(
should_prune_dir("sub/target", &patterns),
"Expected **/target/** to prune nested target directory"
);
}
#[test]
fn test_should_prune_dir_for_bare_pattern() {
let patterns = compile_exclude_patterns(&vec!["target".to_string()]).unwrap();
assert!(
should_prune_dir("target", &patterns),
"Bare pattern should prune directory"
);
assert!(
!should_prune_dir("sub/target", &patterns),
"Bare pattern should not prune nested directories"
);
}
#[test]
fn test_should_prune_dir_for_trailing_slash_pattern() {
let patterns = compile_exclude_patterns(&vec!["target/".to_string()]).unwrap();
assert!(
should_prune_dir("target", &patterns),
"Trailing slash pattern should prune directory"
);
}
#[test]
fn test_should_not_prune_root_dir() {
let patterns = compile_exclude_patterns(&vec!["target/**".to_string()]).unwrap();
assert!(
!should_prune_dir("", &patterns),
"Root directory should never be pruned"
);
}
#[test]
fn test_should_not_prune_dir_for_single_level_glob() {
let patterns = compile_exclude_patterns(&vec!["target/*".to_string()]).unwrap();
assert!(
!should_prune_dir("target", &patterns),
"Single-level glob should not prune directory"
);
}
#[test]
fn test_dir_only_pattern_does_not_exclude_file_named_dir() {
let patterns = compile_exclude_patterns(&vec!["target/".to_string()]).unwrap();
assert!(
!is_excluded_file("target", &patterns),
"Directory-only pattern should not exclude a file named target"
);
}
#[test]
fn test_dir_only_pattern_excludes_files_under_dir() {
let patterns = compile_exclude_patterns(&vec!["target/".to_string()]).unwrap();
assert!(
is_excluded_file("target/file.txt", &patterns),
"Directory-only pattern should exclude files under target/"
);
}
#[test]
fn test_compile_exclude_patterns_invalid_pattern_returns_error() {
let result = compile_exclude_patterns(&vec!["[".to_string()]);
assert!(matches!(
result,
Err(CoreError::Config(ConfigError::InvalidExcludePattern { .. }))
));
}
#[test]
fn compile_patterns_with_diagnostics_all_valid() {
let patterns = vec!["*.md".to_string(), "src/**/*.rs".to_string()];
let config_file = Path::new(".agnix.toml");
let (compiled, diags) = compile_patterns_with_diagnostics(&patterns, config_file);
assert_eq!(compiled.len(), 2, "All valid patterns should compile");
assert!(
diags.is_empty(),
"No diagnostics expected for valid patterns, got: {diags:?}"
);
}
#[test]
fn compile_patterns_with_diagnostics_invalid_pattern() {
let patterns = vec!["[invalid".to_string()];
let config_file = Path::new(".agnix.toml");
let (compiled, diags) = compile_patterns_with_diagnostics(&patterns, config_file);
assert!(
compiled.is_empty(),
"Invalid pattern should not produce a compiled pattern"
);
assert_eq!(
diags.len(),
1,
"Expected exactly one diagnostic for the invalid pattern"
);
assert_eq!(
diags[0].level,
crate::DiagnosticLevel::Warning,
"Invalid glob diagnostic should be Warning level"
);
assert_eq!(
diags[0].rule, "config::glob",
"Invalid glob diagnostic should use rule config::glob"
);
assert!(
diags[0].suggestion.is_some(),
"Diagnostic should include a suggestion"
);
assert!(
diags[0].message.contains("[invalid"),
"diagnostic message should include the pattern"
);
}
#[test]
fn compile_patterns_with_diagnostics_mixed_valid_and_invalid() {
let patterns = vec![
"*.md".to_string(),
"[bad".to_string(),
"src/**/*.rs".to_string(),
"[also-bad".to_string(),
];
let config_file = Path::new(".agnix.toml");
let (compiled, diags) = compile_patterns_with_diagnostics(&patterns, config_file);
assert_eq!(
compiled.len(),
2,
"Only valid patterns should compile, got {}",
compiled.len()
);
assert_eq!(
diags.len(),
2,
"Expected 2 diagnostics for 2 invalid patterns, got {}",
diags.len()
);
for d in &diags {
assert_eq!(d.rule, "config::glob");
assert_eq!(d.level, crate::DiagnosticLevel::Warning);
}
}
#[test]
fn compile_patterns_with_diagnostics_empty_input() {
let patterns: Vec<String> = vec![];
let config_file = Path::new(".agnix.toml");
let (compiled, diags) = compile_patterns_with_diagnostics(&patterns, config_file);
assert!(compiled.is_empty());
assert!(diags.is_empty());
}
#[test]
fn compile_files_config_with_diagnostics_aggregates_all_lists() {
use crate::config::FilesConfig;
let files = FilesConfig {
include_as_memory: vec!["*.md".to_string(), "[bad-memory".to_string()],
include_as_generic: vec!["[bad-generic".to_string()],
exclude: vec!["valid/**".to_string(), "[bad-exclude".to_string()],
};
let config_file = Path::new(".agnix.toml");
let (compiled, diags) = compile_files_config_with_diagnostics(&files, config_file);
assert_eq!(compiled.include_as_memory.len(), 1);
assert_eq!(compiled.include_as_generic.len(), 0);
assert_eq!(compiled.exclude.len(), 1);
assert_eq!(
diags.len(),
3,
"Expected 3 diagnostics from all 3 pattern lists, got: {diags:?}"
);
for d in &diags {
assert_eq!(d.rule, "config::glob");
}
}
#[test]
fn crlf_file_on_disk_produces_same_diagnostics_as_lf() {
use crate::diagnostics::ValidationOutcome;
let temp = tempfile::TempDir::new().unwrap();
let lf_path = temp.path().join("skill_lf.md");
let crlf_path = temp.path().join("skill_crlf.md");
let lf_content =
"---\nname: test-skill\ndescription: A test\n---\n\n# Instructions\n\n<unclosed>\n";
let crlf_content = "---\r\nname: test-skill\r\ndescription: A test\r\n---\r\n\r\n# Instructions\r\n\r\n<unclosed>\r\n";
std::fs::write(&lf_path, lf_content).unwrap();
std::fs::write(&crlf_path, crlf_content).unwrap();
let config = LintConfig::default();
let lf_outcome = validate_file(&lf_path, &config).unwrap();
let crlf_outcome = validate_file(&crlf_path, &config).unwrap();
let lf_diags = match lf_outcome {
ValidationOutcome::Success(d) => d,
other => panic!("Expected Success, got {other:?}"),
};
let crlf_diags = match crlf_outcome {
ValidationOutcome::Success(d) => d,
other => panic!("Expected Success, got {other:?}"),
};
assert_eq!(
lf_diags.len(),
crlf_diags.len(),
"On-disk CRLF file should produce same diagnostic count as LF file.\nLF: {:?}\nCRLF: {:?}",
lf_diags
.iter()
.map(|d| (&d.rule, d.line, d.column))
.collect::<Vec<_>>(),
crlf_diags
.iter()
.map(|d| (&d.rule, d.line, d.column))
.collect::<Vec<_>>(),
);
for (lf_d, crlf_d) in lf_diags.iter().zip(crlf_diags.iter()) {
assert_eq!(lf_d.rule, crlf_d.rule, "Same rules should fire");
assert_eq!(
lf_d.line, crlf_d.line,
"Line numbers should match for rule {}",
lf_d.rule
);
assert_eq!(
lf_d.column, crlf_d.column,
"Column numbers should match for rule {}",
lf_d.rule
);
}
}
}