agentlint-cli 0.1.0

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

use anyhow::{Context, Result};
use regex::{Captures, Regex};

use crate::project::ProjectFacts;
use crate::scanner::{FileKind, SourceFile};

/// Apply only deterministic, semantics-preserving fixes.
///
/// # Errors
///
/// Returns an error when a source file cannot be updated or a fix pattern cannot be
/// constructed.
pub fn apply(files: &[SourceFile], project: &ProjectFacts) -> Result<Vec<PathBuf>> {
    let mut changed = Vec::new();
    for file in files {
        let newline = if file.content.contains("\r\n") {
            "\r\n"
        } else {
            "\n"
        };
        let had_final_newline = file.content.ends_with('\n');
        let mut content = normalize_trailing_whitespace(&file.content, newline, had_final_newline);
        content = fix_package_manager(&content, project)?;
        if file.kind == FileKind::McpJson {
            content = format_json(&content, newline, had_final_newline).unwrap_or(content);
        }
        if content != file.content {
            fs::write(&file.absolute_path, content.as_bytes())
                .with_context(|| format!("failed to update {}", file.absolute_path.display()))?;
            changed.push(file.relative_path.clone());
        }
    }
    Ok(changed)
}

fn normalize_trailing_whitespace(text: &str, newline: &str, final_newline: bool) -> String {
    let normalized = text
        .lines()
        .map(str::trim_end)
        .collect::<Vec<_>>()
        .join(newline);
    if final_newline {
        format!("{normalized}{newline}")
    } else {
        normalized
    }
}

fn fix_package_manager(text: &str, project: &ProjectFacts) -> Result<String> {
    let Some(expected) = project.package_manager else {
        return Ok(text.to_owned());
    };
    let expected = expected.command();
    let pattern = Regex::new(r"\b(npm|pnpm|yarn|bun)\s+run\s+([A-Za-z0-9:_-]+)\b")?;
    Ok(pattern
        .replace_all(text, |captures: &Captures<'_>| {
            let actual = &captures[1];
            let script = &captures[2];
            if actual != expected && project.package_scripts.contains(script) {
                format!("{expected} run {script}")
            } else {
                captures[0].to_owned()
            }
        })
        .into_owned())
}

fn format_json(text: &str, newline: &str, final_newline: bool) -> Option<String> {
    if has_repeated_json_key(text) {
        return None;
    }
    let value: serde_json::Value = serde_json::from_str(text).ok()?;
    let mut formatted = serde_json::to_string_pretty(&value).ok()?;
    if newline == "\r\n" {
        formatted = formatted.replace('\n', "\r\n");
    }
    if final_newline {
        formatted.push_str(newline);
    }
    Some(formatted)
}

fn has_repeated_json_key(text: &str) -> bool {
    let pattern = Regex::new(r#"(?m)^[ \t]*\"([^\"\\]+)\"[ \t]*:"#).expect("valid JSON key regex");
    let mut keys = std::collections::HashSet::new();
    pattern
        .captures_iter(text)
        .filter_map(|capture| capture.get(1).map(|value| value.as_str()))
        .any(|key| !keys.insert(key))
}

/// Write the built-in starter configuration to `path`.
///
/// # Errors
///
/// Returns an error if the destination cannot be written.
pub fn create_starter(path: &Path) -> Result<()> {
    fs::write(path, crate::config::starter_yaml())
        .with_context(|| format!("failed to create {}", path.display()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::project::PackageManager;
    use std::collections::BTreeSet;

    #[test]
    fn manager_fix_requires_existing_script() {
        let facts = ProjectFacts {
            package_manager: Some(PackageManager::Pnpm),
            package_scripts: BTreeSet::from(["test".into()]),
            ..ProjectFacts::default()
        };
        assert_eq!(
            fix_package_manager("Run `npm run test`.", &facts).expect("fix"),
            "Run `pnpm run test`."
        );
        assert_eq!(
            fix_package_manager("Run `npm run missing`.", &facts).expect("fix"),
            "Run `npm run missing`."
        );
    }
}