agentlint-cli 0.1.0

A fast, local-first linter for AI coding-agent configuration
Documentation
mod commands;
mod content;
mod structure;

use std::path::Path;

use crate::config::Config;
use crate::diagnostic::sort_diagnostics;
use crate::project::ProjectFacts;
use crate::scanner::SourceFile;
use crate::{Diagnostic, LintReport, Severity};

pub(crate) fn analyze(
    root: &Path,
    files: &[SourceFile],
    project: &ProjectFacts,
    config: &Config,
    minimum_severity: Option<Severity>,
) -> LintReport {
    let mut diagnostics = Vec::new();
    let mut total_tokens = 0;

    for file in files {
        structure::check(root, file, &mut diagnostics);
        content::check_file(root, file, &mut diagnostics);
        commands::check(root, file, project, &mut diagnostics);
        total_tokens += content::approximate_tokens(&file.content);
    }
    let duplicated_tokens = content::check_cross_file(files, &mut diagnostics);

    diagnostics = diagnostics
        .into_iter()
        .filter_map(|diagnostic| config.apply(diagnostic))
        .filter(|diagnostic| {
            minimum_severity.is_none_or(|minimum| diagnostic.severity.rank() >= minimum.rank())
        })
        .collect();
    sort_diagnostics(&mut diagnostics);

    LintReport {
        version: env!("CARGO_PKG_VERSION"),
        root: root.to_path_buf(),
        files_scanned: files.len(),
        estimated_total_tokens: total_tokens,
        estimated_duplicated_tokens: duplicated_tokens,
        diagnostics,
        fixed_files: Vec::new(),
    }
}

pub(super) fn line_column(text: &str, offset: usize) -> (usize, usize) {
    let prefix = &text[..offset.min(text.len())];
    let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1;
    let column = prefix
        .rsplit('\n')
        .next()
        .map_or(1, |part| part.chars().count() + 1);
    (line, column)
}

pub(super) fn push(items: &mut Vec<Diagnostic>, diagnostic: Diagnostic) {
    if !items.iter().any(|existing| {
        existing.rule == diagnostic.rule
            && existing.file == diagnostic.file
            && existing.line == diagnostic.line
            && existing.message == diagnostic.message
    }) {
        items.push(diagnostic);
    }
}