1use std::path::{Path, PathBuf};
18
19use harn_hostlib::ast::Language;
20
21use crate::engine::CompiledRule;
22use crate::error::RulesError;
23
24#[derive(Debug, Clone)]
26pub struct SourceFile {
27 pub path: PathBuf,
29 pub language: Language,
31 pub source: String,
33}
34
35impl SourceFile {
36 pub fn detect(path: impl Into<PathBuf>, source: impl Into<String>) -> Option<Self> {
39 let path = path.into();
40 let language = Language::detect(&path, None)?;
41 Some(SourceFile {
42 path,
43 language,
44 source: source.into(),
45 })
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum FileChange {
53 Edit {
55 path: PathBuf,
57 contents: String,
59 },
60 Create {
62 path: PathBuf,
64 contents: String,
66 },
67 Delete {
69 path: PathBuf,
71 },
72}
73
74impl FileChange {
75 pub fn path(&self) -> &Path {
77 match self {
78 FileChange::Edit { path, .. }
79 | FileChange::Create { path, .. }
80 | FileChange::Delete { path } => path,
81 }
82 }
83}
84
85pub trait ScanningRecipe {
87 type Acc: Default;
89
90 fn scan(&self, file: &SourceFile, acc: &mut Self::Acc) -> Result<(), RulesError>;
93
94 fn generate(
97 &self,
98 files: &[SourceFile],
99 acc: &Self::Acc,
100 ) -> Result<Vec<FileChange>, RulesError>;
101}
102
103#[derive(Debug, Clone)]
105pub struct RecipeRun {
106 pub changes: Vec<FileChange>,
108}
109
110impl RecipeRun {
111 pub fn edits(&self) -> impl Iterator<Item = &FileChange> {
113 self.changes
114 .iter()
115 .filter(|c| matches!(c, FileChange::Edit { .. }))
116 }
117
118 pub fn creations(&self) -> impl Iterator<Item = &FileChange> {
120 self.changes
121 .iter()
122 .filter(|c| matches!(c, FileChange::Create { .. }))
123 }
124}
125
126pub fn run_recipe<R: ScanningRecipe>(
130 recipe: &R,
131 mut files: Vec<SourceFile>,
132) -> Result<RecipeRun, RulesError> {
133 files.sort_by(|a, b| a.path.cmp(&b.path));
134
135 let mut acc = R::Acc::default();
136 for file in &files {
137 recipe.scan(file, &mut acc)?;
138 }
139
140 let mut changes = recipe.generate(&files, &acc)?;
141 changes.sort_by(|a, b| a.path().cmp(b.path()));
142 Ok(RecipeRun { changes })
143}
144
145pub struct RuleRecipe<'a> {
149 pub rule: &'a CompiledRule,
151}
152
153impl ScanningRecipe for RuleRecipe<'_> {
154 type Acc = ();
155
156 fn scan(&self, _file: &SourceFile, _acc: &mut ()) -> Result<(), RulesError> {
157 Ok(())
158 }
159
160 fn generate(&self, files: &[SourceFile], _acc: &()) -> Result<Vec<FileChange>, RulesError> {
161 let mut changes = Vec::new();
162 for file in files {
163 if file.language != self.rule.language() {
164 continue;
165 }
166 let result = self.rule.apply(&file.source)?;
167 if result.changed {
168 changes.push(FileChange::Edit {
169 path: file.path.clone(),
170 contents: result.rewritten,
171 });
172 }
173 }
174 Ok(changes)
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::model::Rule;
182
183 fn ts(path: &str, source: &str) -> SourceFile {
184 SourceFile {
185 path: PathBuf::from(path),
186 language: Language::TypeScript,
187 source: source.to_string(),
188 }
189 }
190
191 struct CallCounter;
195 impl ScanningRecipe for CallCounter {
196 type Acc = usize;
197 fn scan(&self, file: &SourceFile, acc: &mut usize) -> Result<(), RulesError> {
198 *acc += file.source.matches("()").count();
199 Ok(())
200 }
201 fn generate(
202 &self,
203 _files: &[SourceFile],
204 acc: &usize,
205 ) -> Result<Vec<FileChange>, RulesError> {
206 if *acc == 0 {
207 return Ok(vec![]);
208 }
209 Ok(vec![FileChange::Create {
210 path: PathBuf::from("report.txt"),
211 contents: format!("calls: {acc}\n"),
212 }])
213 }
214 }
215
216 #[test]
217 fn recipe_accumulates_then_generates_a_new_file() {
218 let files = vec![ts("a.ts", "foo();\n"), ts("b.ts", "bar(); baz();\n")];
219 let run = run_recipe(&CallCounter, files).unwrap();
220 assert_eq!(run.changes.len(), 1);
221 assert_eq!(
222 run.changes[0],
223 FileChange::Create {
224 path: PathBuf::from("report.txt"),
225 contents: "calls: 3\n".into(),
226 }
227 );
228 }
229
230 #[test]
231 fn scan_runs_in_path_sorted_order() {
232 struct OrderRecorder;
235 impl ScanningRecipe for OrderRecorder {
236 type Acc = Vec<String>;
237 fn scan(&self, file: &SourceFile, acc: &mut Vec<String>) -> Result<(), RulesError> {
238 acc.push(file.path.to_string_lossy().to_string());
239 Ok(())
240 }
241 fn generate(
242 &self,
243 _f: &[SourceFile],
244 acc: &Vec<String>,
245 ) -> Result<Vec<FileChange>, RulesError> {
246 Ok(vec![FileChange::Create {
247 path: PathBuf::from("order.txt"),
248 contents: acc.join(","),
249 }])
250 }
251 }
252 let files = vec![ts("z.ts", ""), ts("a.ts", ""), ts("m.ts", "")];
253 let run = run_recipe(&OrderRecorder, files).unwrap();
254 match &run.changes[0] {
255 FileChange::Create { contents, .. } => assert_eq!(contents, "a.ts,m.ts,z.ts"),
256 other => panic!("expected create, got {other:?}"),
257 }
258 }
259
260 #[test]
261 fn rule_recipe_edits_matching_files_only() {
262 let rule = crate::engine::CompiledRule::compile(
263 &Rule::from_toml_str(
264 r#"
265 id = "rename-foo"
266 language = "typescript"
267 safety = "behavior-preserving"
268 fix = "bar()"
269 [rule]
270 pattern = "foo()"
271 "#,
272 )
273 .unwrap(),
274 )
275 .unwrap();
276 let files = vec![
277 ts("a.ts", "foo();\n"),
278 ts("b.ts", "noMatch();\n"),
279 SourceFile {
280 path: PathBuf::from("c.rs"),
281 language: Language::Rust,
282 source: "fn f() { foo(); }".into(),
283 },
284 ];
285 let run = run_recipe(&RuleRecipe { rule: &rule }, files).unwrap();
286 assert_eq!(run.changes.len(), 1);
288 assert_eq!(run.changes[0].path(), Path::new("a.ts"));
289 }
290}