agentlint-cli 0.1.0

A fast, local-first linter for AI coding-agent configuration
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

use anyhow::{Context, Result, bail};
use serde::Deserialize;

use crate::rules;
use crate::{Diagnostic, Severity};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SeveritySetting {
    Off,
    Info,
    #[serde(alias = "warn")]
    Warning,
    Error,
}

impl SeveritySetting {
    pub const fn severity(self) -> Option<Severity> {
        match self {
            Self::Off => None,
            Self::Info => Some(Severity::Info),
            Self::Warning => Some(Severity::Warning),
            Self::Error => Some(Severity::Error),
        }
    }
}

#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
    pub rules: BTreeMap<String, SeveritySetting>,
    pub ignore: Vec<String>,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            rules: BTreeMap::new(),
            ignore: vec![
                ".git/**".into(),
                "node_modules/**".into(),
                "vendor/**".into(),
                "dist/**".into(),
                "build/**".into(),
                "target/**".into(),
                ".next/**".into(),
                ".cache/**".into(),
                "venv/**".into(),
                ".venv/**".into(),
            ],
        }
    }
}

impl Config {
    /// Load an explicit config or discover one at the repository root.
    ///
    /// # Errors
    ///
    /// Returns an error for unreadable files, malformed syntax, unsupported fields,
    /// or unknown rule identifiers.
    pub fn load(root: &Path, explicit: Option<&Path>) -> Result<Self> {
        let path = if let Some(path) = explicit {
            Some(if path.is_absolute() {
                path.to_path_buf()
            } else {
                root.join(path)
            })
        } else {
            [".agentlint.json", ".agentlint.yaml", ".agentlint.yml"]
                .iter()
                .map(|name| root.join(name))
                .find(|candidate| candidate.is_file())
        };

        let Some(path) = path else {
            return Ok(Self::default());
        };
        let text = fs::read_to_string(&path)
            .with_context(|| format!("failed to read configuration {}", path.display()))?;
        let parsed: Self = match path.extension().and_then(|value| value.to_str()) {
            Some("json") => serde_json::from_str(&text)
                .with_context(|| format!("invalid JSON configuration {}", path.display()))?,
            Some("yaml" | "yml") => serde_yaml::from_str(&text)
                .with_context(|| format!("invalid YAML configuration {}", path.display()))?,
            _ => bail!("configuration must use .json, .yaml, or .yml"),
        };
        for id in parsed.rules.keys() {
            if rules::definition(id).is_none() {
                bail!("configuration references unknown rule `{id}`");
            }
        }
        Ok(parsed.with_default_ignores())
    }

    fn with_default_ignores(mut self) -> Self {
        let defaults = Self::default().ignore;
        for pattern in defaults {
            if !self.ignore.contains(&pattern) {
                self.ignore.push(pattern);
            }
        }
        self
    }

    pub fn severity(&self, rule: &str) -> Option<Severity> {
        self.rules.get(rule).map_or_else(
            || Some(rules::default_severity(rule)),
            |value| value.severity(),
        )
    }

    pub fn apply(&self, mut diagnostic: Diagnostic) -> Option<Diagnostic> {
        diagnostic.severity = self.severity(&diagnostic.rule)?;
        Some(diagnostic)
    }
}

pub fn starter_yaml() -> &'static str {
    r"# agentlint configuration
rules:
  AGL301: warning
  AGL401: error
  AGL502: warning
ignore:
  - examples/**
"
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn disabled_rule_has_no_severity() {
        let mut config = Config::default();
        config.rules.insert("AGL301".into(), SeveritySetting::Off);
        assert_eq!(config.severity("AGL301"), None);
    }
}