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
10pub 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
95pub 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}