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};
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))
}
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`."
);
}
}