1#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum Formatter {
8 Jetbrains,
9 Command(String),
10}
11
12pub fn resolve_formatter(abs_path: &str) -> Formatter {
15 let ext = std::path::Path::new(abs_path)
16 .extension()
17 .and_then(|e| e.to_str())
18 .map(str::to_ascii_lowercase)
19 .unwrap_or_default();
20 builtin_default(&ext)
21}
22
23fn builtin_default(ext: &str) -> Formatter {
25 match ext {
26 "rs" => Formatter::Command("rustfmt {file}".to_string()),
27 _ => Formatter::Jetbrains,
28 }
29}
30
31pub fn command_label(template: &str) -> &str {
33 template.split_whitespace().next().unwrap_or("formatter")
34}
35
36pub fn build_argv(template: &str, abs_path: &str) -> Vec<String> {
41 let mut argv: Vec<String> = Vec::new();
42 let mut saw_placeholder = false;
43 for tok in template.split_whitespace() {
44 if tok == "{file}" {
45 argv.push(abs_path.to_string());
46 saw_placeholder = true;
47 } else if tok.contains("{file}") {
48 argv.push(tok.replace("{file}", abs_path));
49 saw_placeholder = true;
50 } else {
51 argv.push(tok.to_string());
52 }
53 }
54 if !saw_placeholder {
55 argv.push(abs_path.to_string());
56 }
57 argv
58}
59
60pub fn run_command_formatter(
69 template: &str,
70 abs_path: &str,
71 project_root: &str,
72) -> Result<(), String> {
73 run_command_formatter_with_timeout(
74 template,
75 abs_path,
76 project_root,
77 std::time::Duration::from_secs(30),
78 )
79}
80
81fn run_command_formatter_with_timeout(
82 template: &str,
83 abs_path: &str,
84 project_root: &str,
85 timeout: std::time::Duration,
86) -> Result<(), String> {
87 wait_for_command_formatter(
88 spawn_command_formatter(template, abs_path, project_root)?,
89 timeout,
90 )
91}
92
93fn spawn_command_formatter(
94 template: &str,
95 abs_path: &str,
96 project_root: &str,
97) -> Result<crate::core::process_capture::CapturedChild, String> {
98 use std::process::{Command, Stdio};
99
100 let argv = build_argv(template, abs_path);
101 let (bin, rest) = argv
102 .split_first()
103 .ok_or_else(|| "INVALID_TARGET: empty formatter template".to_string())?;
104 let mut command = Command::new(bin);
105 command
106 .args(rest)
107 .current_dir(project_root)
108 .stdin(Stdio::null());
109 crate::core::process_capture::spawn_capture(&mut command)
110}
111
112fn wait_for_command_formatter(
113 child: crate::core::process_capture::CapturedChild,
114 timeout: std::time::Duration,
115) -> Result<(), String> {
116 let bin = child.program().to_owned();
117 let captured = crate::core::process_capture::wait_for_capture(child, timeout)?;
118 if captured.timed_out {
119 return Err(format!(
120 "formatter '{bin}' timed out after {}s",
121 timeout.as_secs()
122 ));
123 }
124 if !captured.output.status.success() {
125 let code = captured
126 .output
127 .status
128 .code()
129 .map_or_else(|| "signal".to_string(), |code| code.to_string());
130 let stderr = String::from_utf8_lossy(&captured.output.stderr);
131 return Err(format!("{bin} exited {code}: {}", stderr.trim()));
132 }
133 Ok(())
134}
135
136pub fn blake3_of(abs_path: &str) -> Result<String, String> {
138 let bytes = std::fs::read(abs_path).map_err(|e| format!("FILE_NOT_FOUND: {abs_path}: {e}"))?;
139 Ok(crate::core::hasher::hash_hex(&bytes))
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 fn wait_for_path(path: &std::path::Path, timeout: std::time::Duration) -> bool {
147 let deadline = std::time::Instant::now() + timeout;
148 while !path.exists() && std::time::Instant::now() < deadline {
149 std::thread::sleep(std::time::Duration::from_millis(5));
150 }
151 path.exists()
152 }
153
154 fn assert_descendant_stopped(release: &std::path::Path, survived: &std::path::Path) {
155 std::fs::write(release, "release").unwrap();
156 assert!(
157 !wait_for_path(survived, std::time::Duration::from_secs(2)),
158 "formatter descendant performed work after cleanup"
159 );
160 }
161
162 #[cfg(unix)]
167 fn spawn_fresh_script(
168 template: &str,
169 abs_path: &str,
170 project_root: &str,
171 ) -> Result<crate::core::process_capture::CapturedChild, String> {
172 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
173 loop {
174 match spawn_command_formatter(template, abs_path, project_root) {
175 Err(error)
176 if error.contains("Text file busy") && std::time::Instant::now() < deadline =>
177 {
178 std::thread::sleep(std::time::Duration::from_millis(20));
179 }
180 result => return result,
181 }
182 }
183 }
184
185 #[test]
186 fn rs_defaults_to_rustfmt() {
187 let f = resolve_formatter("/x/a.rs");
188 assert!(matches!(f, Formatter::Command(ref t) if t == "rustfmt {file}"));
189 }
190
191 #[test]
192 fn md_and_unknown_and_no_ext_default_to_jetbrains() {
193 assert!(matches!(resolve_formatter("/x/a.md"), Formatter::Jetbrains));
194 assert!(matches!(
195 resolve_formatter("/x/a.txt"),
196 Formatter::Jetbrains
197 ));
198 assert!(matches!(
199 resolve_formatter("/x/README"),
200 Formatter::Jetbrains
201 ));
202 }
203
204 #[test]
205 fn extension_is_case_insensitive() {
206 assert!(matches!(
207 resolve_formatter("/x/A.RS"),
208 Formatter::Command(_)
209 ));
210 }
211
212 #[test]
213 fn command_label_is_first_token() {
214 assert_eq!(command_label("rustfmt {file}"), "rustfmt");
215 assert_eq!(command_label("ruff format {file}"), "ruff");
216 assert_eq!(command_label(""), "formatter");
217 }
218
219 #[test]
220 fn argv_substitutes_placeholder() {
221 assert_eq!(
222 build_argv("rustfmt {file}", "/x/a.rs"),
223 vec!["rustfmt".to_string(), "/x/a.rs".to_string()]
224 );
225 assert_eq!(
226 build_argv("ruff format {file}", "/x/a.py"),
227 vec![
228 "ruff".to_string(),
229 "format".to_string(),
230 "/x/a.py".to_string()
231 ]
232 );
233 }
234
235 #[test]
236 fn argv_appends_path_when_no_placeholder() {
237 assert_eq!(
238 build_argv("gofmt -w", "/x/a.go"),
239 vec!["gofmt".to_string(), "-w".to_string(), "/x/a.go".to_string()]
240 );
241 }
242
243 #[test]
244 fn argv_path_with_spaces_stays_one_arg() {
245 let argv = build_argv("rustfmt {file}", "/x/my dir/a.rs");
246 assert_eq!(
247 argv,
248 vec!["rustfmt".to_string(), "/x/my dir/a.rs".to_string()]
249 );
250 }
251
252 #[test]
253 fn blake3_detects_change() {
254 let dir = tempfile::tempdir().unwrap();
255 let f = dir.path().join("a.txt");
256 std::fs::write(&f, "one").unwrap();
257 let p = f.to_str().unwrap();
258 let h1 = blake3_of(p).unwrap();
259 let h2 = blake3_of(p).unwrap();
260 assert_eq!(h1, h2, "same content → same hash");
261 std::fs::write(&f, "two").unwrap();
262 assert_ne!(
263 h1,
264 blake3_of(p).unwrap(),
265 "changed content → different hash"
266 );
267 }
268
269 #[test]
270 fn blake3_missing_file_errors() {
271 assert!(blake3_of("/no/such/file.xyz").is_err());
272 }
273
274 #[test]
275 fn run_command_missing_binary_errors() {
276 let dir = tempfile::tempdir().unwrap();
277 let f = dir.path().join("a.rs");
278 std::fs::write(&f, "fn x(){}\n").unwrap();
279 let err = run_command_formatter(
280 "definitely-not-a-formatter-binary {file}",
281 f.to_str().unwrap(),
282 dir.path().to_str().unwrap(),
283 )
284 .unwrap_err();
285 assert!(err.contains("not found"), "got: {err}");
286 }
287
288 #[test]
289 fn run_command_nonzero_exit_errors() {
290 let dir = tempfile::tempdir().unwrap();
291 #[cfg(unix)]
292 let (template, formatter) = {
293 let formatter = dir.path().join("fail-formatter");
294 std::fs::write(&formatter, "#!/bin/sh\nexit 7\n").unwrap();
295 ("sh {file}", formatter)
296 };
297 #[cfg(windows)]
298 let (template, formatter) = {
299 let formatter = dir.path().join("fail-formatter.cmd");
300 std::fs::write(&formatter, "@echo off\r\nexit /B 7\r\n").unwrap();
301 ("cmd.exe /D /S /C {file}", formatter)
302 };
303 let err = run_command_formatter(
304 template,
305 formatter.to_str().unwrap(),
306 dir.path().to_str().unwrap(),
307 )
308 .unwrap_err();
309 assert!(err.contains("exited"), "got: {err}");
310 }
311
312 #[cfg(windows)]
313 #[test]
314 fn command_formatter_timeout_closes_descendant_pipes() {
315 use std::time::Duration;
316
317 let dir = tempfile::tempdir().unwrap();
318 let formatter = dir.path().join("stall-formatter.ps1");
319 let descendant = dir.path().join("descendant.ps1");
320 let ready = dir.path().join("descendant.ready");
321 let release = dir.path().join("descendant.release");
322 let survived = dir.path().join("descendant.survived");
323 let ps_quote = |path: &std::path::Path| path.display().to_string().replace('\'', "''");
324 std::fs::write(
325 &descendant,
326 format!(
327 "$ErrorActionPreference = 'Stop'\r\nSet-Content -LiteralPath '{}' -Value ready\r\nwhile (-not (Test-Path -LiteralPath '{}')) {{ Start-Sleep -Milliseconds 10 }}\r\nSet-Content -LiteralPath '{}' -Value survived\r\n",
328 ps_quote(&ready),
329 ps_quote(&release),
330 ps_quote(&survived)
331 ),
332 )
333 .unwrap();
334 std::fs::write(
335 &formatter,
336 format!(
337 "$ErrorActionPreference = 'Stop'\r\nStart-Process powershell.exe -NoNewWindow -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}')\r\nwhile ($true) {{ Start-Sleep -Seconds 1 }}\r\n",
338 ps_quote(&descendant)
339 ),
340 )
341 .unwrap();
342 let process = spawn_command_formatter(
343 "powershell.exe -NoProfile -ExecutionPolicy Bypass -File {file}",
344 formatter.to_str().unwrap(),
345 dir.path().to_str().unwrap(),
346 )
347 .expect("formatter process must spawn");
348 assert!(
352 wait_for_path(&ready, Duration::from_secs(30)),
353 "formatter descendant must start"
354 );
355 let error = wait_for_command_formatter(process, Duration::from_millis(250))
356 .expect_err("stalled formatter tree must fail closed");
357
358 assert!(error.contains("timed out"), "got: {error}");
359 assert_descendant_stopped(&release, &survived);
360 }
361
362 #[cfg(unix)]
363 #[test]
364 fn command_formatter_times_out_and_reaps_a_stalled_child() {
365 use std::os::unix::fs::PermissionsExt;
366 use std::time::Duration;
367
368 let dir = tempfile::tempdir().unwrap();
369 let formatter = dir.path().join("stall-formatter");
370 std::fs::write(
371 &formatter,
372 "#!/bin/sh\n(printf ready > \"${0}.ready\"; while [ ! -e \"${0}.release\" ]; do sleep 0.01; done; printf survived > \"${0}.survived\") &\nwait\n",
373 )
374 .unwrap();
375 let mut permissions = std::fs::metadata(&formatter).unwrap().permissions();
376 permissions.set_mode(0o755);
377 std::fs::set_permissions(&formatter, permissions).unwrap();
378 let source = dir.path().join("a.rs");
379 std::fs::write(&source, "fn x() {}\n").unwrap();
380 let ready = formatter.with_file_name("stall-formatter.ready");
381 let release = formatter.with_file_name("stall-formatter.release");
382 let survived = formatter.with_file_name("stall-formatter.survived");
383 let template = format!("{} {{file}}", formatter.display());
384 let source_path = source.to_str().unwrap().to_owned();
385 let project_root = dir.path().to_str().unwrap().to_owned();
386 let process = spawn_fresh_script(&template, &source_path, &project_root)
387 .expect("formatter process must spawn");
388
389 assert!(
390 wait_for_path(&ready, Duration::from_secs(10)),
391 "formatter descendant must start"
392 );
393 let error = wait_for_command_formatter(process, Duration::from_millis(250))
394 .expect_err("stalled formatter must fail closed");
395
396 assert!(error.contains("timed out"), "unexpected error: {error}");
397 assert_descendant_stopped(&release, &survived);
398 }
399
400 #[cfg(unix)]
401 #[test]
402 fn command_formatter_cleans_descendant_after_parent_exits() {
403 use std::os::unix::fs::PermissionsExt;
404 use std::time::Duration;
405
406 let dir = tempfile::tempdir().unwrap();
407 let formatter = dir.path().join("background-formatter");
408 std::fs::write(
409 &formatter,
410 "#!/bin/sh\n(printf ready > \"${0}.ready\"; while [ ! -e \"${0}.release\" ]; do sleep 0.01; done; printf survived > \"${0}.survived\") &\nexit 0\n",
411 )
412 .unwrap();
413 let mut permissions = std::fs::metadata(&formatter).unwrap().permissions();
414 permissions.set_mode(0o755);
415 std::fs::set_permissions(&formatter, permissions).unwrap();
416 let source = dir.path().join("a.rs");
417 std::fs::write(&source, "fn x() {}\n").unwrap();
418
419 let process = spawn_fresh_script(
420 &format!("{} {{file}}", formatter.display()),
421 source.to_str().unwrap(),
422 dir.path().to_str().unwrap(),
423 )
424 .expect("formatter process must spawn");
425 let ready = formatter.with_file_name("background-formatter.ready");
426 let release = formatter.with_file_name("background-formatter.release");
427 let survived = formatter.with_file_name("background-formatter.survived");
428 assert!(
429 wait_for_path(&ready, Duration::from_secs(10)),
430 "formatter descendant must start"
431 );
432
433 wait_for_command_formatter(process, Duration::from_secs(1))
434 .expect("successful formatter must clean up its background process");
435 assert_descendant_stopped(&release, &survived);
436 }
437
438 #[test]
439 fn run_rustfmt_formats_and_reports_change() {
440 if std::process::Command::new("rustfmt")
442 .arg("--version")
443 .output()
444 .is_err()
445 {
446 eprintln!("SKIP: rustfmt not in PATH");
447 return;
448 }
449 let dir = tempfile::tempdir().unwrap();
450 let f = dir.path().join("a.rs");
451 std::fs::write(&f, "fn x( ){let y=1;}\n").unwrap(); let p = f.to_str().unwrap();
453 let before = blake3_of(p).unwrap();
454 run_command_formatter("rustfmt {file}", p, dir.path().to_str().unwrap()).unwrap();
455 let after = blake3_of(p).unwrap();
456 assert_ne!(
457 before, after,
458 "rustfmt should have changed the drifted file"
459 );
460
461 let before2 = blake3_of(p).unwrap();
463 run_command_formatter("rustfmt {file}", p, dir.path().to_str().unwrap()).unwrap();
464 assert_eq!(
465 before2,
466 blake3_of(p).unwrap(),
467 "second run should be unchanged"
468 );
469 }
470}