Skip to main content

agentlint/
fix.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use regex::{Captures, Regex};
6
7use crate::project::ProjectFacts;
8use crate::scanner::{FileKind, SourceFile};
9
10/// Apply only deterministic, semantics-preserving fixes.
11///
12/// # Errors
13///
14/// Returns an error when a source file cannot be updated or a fix pattern cannot be
15/// constructed.
16pub fn apply(files: &[SourceFile], project: &ProjectFacts) -> Result<Vec<PathBuf>> {
17    let mut changed = Vec::new();
18    for file in files {
19        let newline = if file.content.contains("\r\n") {
20            "\r\n"
21        } else {
22            "\n"
23        };
24        let had_final_newline = file.content.ends_with('\n');
25        let mut content = normalize_trailing_whitespace(&file.content, newline, had_final_newline);
26        content = fix_package_manager(&content, project)?;
27        if file.kind == FileKind::McpJson {
28            content = format_json(&content, newline, had_final_newline).unwrap_or(content);
29        }
30        if content != file.content {
31            fs::write(&file.absolute_path, content.as_bytes())
32                .with_context(|| format!("failed to update {}", file.absolute_path.display()))?;
33            changed.push(file.relative_path.clone());
34        }
35    }
36    Ok(changed)
37}
38
39fn normalize_trailing_whitespace(text: &str, newline: &str, final_newline: bool) -> String {
40    let normalized = text
41        .lines()
42        .map(str::trim_end)
43        .collect::<Vec<_>>()
44        .join(newline);
45    if final_newline {
46        format!("{normalized}{newline}")
47    } else {
48        normalized
49    }
50}
51
52fn fix_package_manager(text: &str, project: &ProjectFacts) -> Result<String> {
53    let Some(expected) = project.package_manager else {
54        return Ok(text.to_owned());
55    };
56    let expected = expected.command();
57    let pattern = Regex::new(r"\b(npm|pnpm|yarn|bun)\s+run\s+([A-Za-z0-9:_-]+)\b")?;
58    Ok(pattern
59        .replace_all(text, |captures: &Captures<'_>| {
60            let actual = &captures[1];
61            let script = &captures[2];
62            if actual != expected && project.package_scripts.contains(script) {
63                format!("{expected} run {script}")
64            } else {
65                captures[0].to_owned()
66            }
67        })
68        .into_owned())
69}
70
71fn format_json(text: &str, newline: &str, final_newline: bool) -> Option<String> {
72    if has_repeated_json_key(text) {
73        return None;
74    }
75    let value: serde_json::Value = serde_json::from_str(text).ok()?;
76    let mut formatted = serde_json::to_string_pretty(&value).ok()?;
77    if newline == "\r\n" {
78        formatted = formatted.replace('\n', "\r\n");
79    }
80    if final_newline {
81        formatted.push_str(newline);
82    }
83    Some(formatted)
84}
85
86fn has_repeated_json_key(text: &str) -> bool {
87    let pattern = Regex::new(r#"(?m)^[ \t]*\"([^\"\\]+)\"[ \t]*:"#).expect("valid JSON key regex");
88    let mut keys = std::collections::HashSet::new();
89    pattern
90        .captures_iter(text)
91        .filter_map(|capture| capture.get(1).map(|value| value.as_str()))
92        .any(|key| !keys.insert(key))
93}
94
95/// Write the built-in starter configuration to `path`.
96///
97/// # Errors
98///
99/// Returns an error if the destination cannot be written.
100pub fn create_starter(path: &Path) -> Result<()> {
101    fs::write(path, crate::config::starter_yaml())
102        .with_context(|| format!("failed to create {}", path.display()))
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::project::PackageManager;
109    use std::collections::BTreeSet;
110
111    #[test]
112    fn manager_fix_requires_existing_script() {
113        let facts = ProjectFacts {
114            package_manager: Some(PackageManager::Pnpm),
115            package_scripts: BTreeSet::from(["test".into()]),
116            ..ProjectFacts::default()
117        };
118        assert_eq!(
119            fix_package_manager("Run `npm run test`.", &facts).expect("fix"),
120            "Run `pnpm run test`."
121        );
122        assert_eq!(
123            fix_package_manager("Run `npm run missing`.", &facts).expect("fix"),
124            "Run `npm run missing`."
125        );
126    }
127}