agentlint-cli 0.1.0

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

use anyhow::{Context, Result};
use serde_json::Value;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackageManager {
    Npm,
    Pnpm,
    Yarn,
    Bun,
}

impl PackageManager {
    pub const fn command(self) -> &'static str {
        match self {
            Self::Npm => "npm",
            Self::Pnpm => "pnpm",
            Self::Yarn => "yarn",
            Self::Bun => "bun",
        }
    }
}

#[derive(Debug, Clone, Default)]
pub struct ProjectFacts {
    pub package_manager: Option<PackageManager>,
    pub package_scripts: BTreeSet<String>,
    pub has_python: bool,
    pub has_rust: bool,
    pub has_go: bool,
}

impl ProjectFacts {
    /// Infer conservative tooling facts from repository metadata.
    ///
    /// # Errors
    ///
    /// Returns an error if present package metadata cannot be read.
    pub fn inspect(root: &Path) -> Result<Self> {
        let mut facts = Self {
            package_manager: detect_package_manager(root),
            has_python: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"]
                .iter()
                .any(|name| root.join(name).exists()),
            has_rust: root.join("Cargo.toml").is_file(),
            has_go: root.join("go.mod").is_file(),
            ..Self::default()
        };
        let package_json = root.join("package.json");
        if package_json.is_file() {
            let text = fs::read_to_string(&package_json).context("failed to read package.json")?;
            if let Ok(value) = serde_json::from_str::<Value>(&text) {
                if facts.package_manager.is_none() {
                    facts.package_manager = value
                        .get("packageManager")
                        .and_then(Value::as_str)
                        .and_then(parse_package_manager);
                }
                if let Some(scripts) = value.get("scripts").and_then(Value::as_object) {
                    facts.package_scripts.extend(scripts.keys().cloned());
                }
            }
        }
        Ok(facts)
    }
}

fn detect_package_manager(root: &Path) -> Option<PackageManager> {
    [
        ("pnpm-lock.yaml", PackageManager::Pnpm),
        ("yarn.lock", PackageManager::Yarn),
        ("bun.lock", PackageManager::Bun),
        ("bun.lockb", PackageManager::Bun),
        ("package-lock.json", PackageManager::Npm),
    ]
    .into_iter()
    .find_map(|(name, manager)| root.join(name).is_file().then_some(manager))
}

fn parse_package_manager(value: &str) -> Option<PackageManager> {
    match value.split('@').next()? {
        "npm" => Some(PackageManager::Npm),
        "pnpm" => Some(PackageManager::Pnpm),
        "yarn" => Some(PackageManager::Yarn),
        "bun" => Some(PackageManager::Bun),
        _ => None,
    }
}