1use std::io::Write;
9use std::path::Path;
10use std::process::{Command, Stdio};
11use std::time::{Duration, Instant};
12
13use crate::spec::FormatterSpec;
14
15pub const FORMAT_TIMEOUT: Duration = Duration::from_secs(2);
22
23#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum FormatError {
31 NotFound { program: String },
34 Failed { program: String, stderr: String },
37 TimedOut { program: String },
39 NotUtf8 { program: String },
43 Io { program: String, message: String },
46}
47
48impl FormatError {
49 pub fn message(&self) -> String {
51 match self {
52 Self::NotFound { program } => format!("formatter not found: {program}"),
53 Self::Failed { program, stderr } => {
54 let first = stderr.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
55 if first.is_empty() {
56 format!("{program} failed")
57 } else {
58 format!("{program}: {first}")
59 }
60 }
61 Self::TimedOut { program } => {
62 format!("{program} timed out after {}s", FORMAT_TIMEOUT.as_secs())
63 }
64 Self::NotUtf8 { program } => format!("{program} produced invalid UTF-8"),
65 Self::Io { program, message } => format!("{program}: {message}"),
66 }
67 }
68
69 pub fn is_noteworthy(&self) -> bool {
76 !matches!(self, Self::NotFound { .. })
77 }
78}
79
80pub fn run(spec: &FormatterSpec, input: &str, path: Option<&Path>) -> Result<String, FormatError> {
85 let program = spec.program.to_string();
86 let mut command = Command::new(spec.program);
87 command.args(spec.args);
88 if let (Some(flag), Some(p)) = (spec.filename_flag, path) {
89 command.arg(format!("{flag}={}", p.display()));
90 }
91 command
92 .stdin(Stdio::piped())
93 .stdout(Stdio::piped())
94 .stderr(Stdio::piped());
95
96 let mut child = match command.spawn() {
97 Ok(c) => c,
98 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
99 return Err(FormatError::NotFound { program });
100 }
101 Err(e) => {
102 return Err(FormatError::Io {
103 program,
104 message: e.to_string(),
105 });
106 }
107 };
108
109 if let Some(mut stdin) = child.stdin.take() {
112 let _ = stdin.write_all(input.as_bytes());
117 }
118
119 let started = Instant::now();
121 loop {
122 match child.try_wait() {
123 Ok(Some(_)) => break,
124 Ok(None) => {
125 if started.elapsed() >= FORMAT_TIMEOUT {
126 let _ = child.kill();
127 let _ = child.wait();
128 return Err(FormatError::TimedOut { program });
129 }
130 std::thread::sleep(Duration::from_millis(5));
131 }
132 Err(e) => {
133 return Err(FormatError::Io {
134 program,
135 message: e.to_string(),
136 });
137 }
138 }
139 }
140
141 let output = match child.wait_with_output() {
142 Ok(o) => o,
143 Err(e) => {
144 return Err(FormatError::Io {
145 program,
146 message: e.to_string(),
147 });
148 }
149 };
150 if !output.status.success() {
151 return Err(FormatError::Failed {
152 program,
153 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
154 });
155 }
156 String::from_utf8(output.stdout).map_err(|_| FormatError::NotUtf8 { program })
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[cfg(unix)]
177 fn fake(name: &str, body: &str) -> (tempfile::TempDir, FormatterSpec) {
178 use std::os::unix::fs::PermissionsExt;
179 let dir = tempfile::tempdir().expect("tempdir");
180 let path = dir.path().join(name);
181 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write script");
182 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
183 let script: &'static str = Box::leak(path.to_string_lossy().into_owned().into_boxed_str());
191 let args: &'static [&'static str] = Box::leak(vec![script].into_boxed_slice());
192 (
193 dir,
194 FormatterSpec {
195 program: "/bin/sh",
196 args,
197 filename_flag: None,
198 },
199 )
200 }
201
202 #[cfg(unix)]
203 #[test]
204 fn a_successful_run_returns_stdout() {
205 let (_d, spec) = fake("ok", "sed 's/a/b/g'");
206 assert_eq!(run(&spec, "aaa\n", None).unwrap(), "bbb\n");
207 }
208
209 #[test]
210 fn a_missing_program_is_reported_as_not_found_and_is_not_noteworthy() {
211 let spec = FormatterSpec {
212 program: "definitely-not-a-real-formatter-xyz",
213 args: &[],
214 filename_flag: None,
215 };
216 let err = run(&spec, "x", None).unwrap_err();
217 assert!(matches!(err, FormatError::NotFound { .. }));
218 assert!(
219 !err.is_noteworthy(),
220 "an uninstalled tool must not nag on every save"
221 );
222 }
223
224 #[cfg(unix)]
225 #[test]
226 fn a_non_zero_exit_carries_stderr_to_the_user() {
227 let (_d, spec) = fake("bad", "echo 'syntax error on line 3' >&2; exit 1");
228 let err = run(&spec, "x", None).unwrap_err();
229 match &err {
230 FormatError::Failed { stderr, .. } => {
231 assert!(stderr.contains("syntax error on line 3"))
232 }
233 other => panic!("expected Failed, got {other:?}"),
234 }
235 assert!(err.is_noteworthy());
236 assert!(err.message().contains("syntax error on line 3"));
237 }
238
239 #[cfg(unix)]
240 #[test]
241 fn a_hanging_formatter_is_killed_at_the_timeout() {
242 let (_d, spec) = fake("hang", "sleep 30");
243 let started = Instant::now();
244 let err = run(&spec, "x", None).unwrap_err();
245 assert!(matches!(err, FormatError::TimedOut { .. }));
246 assert!(
247 started.elapsed() < FORMAT_TIMEOUT + Duration::from_secs(2),
248 "must not wait for the child to finish on its own"
249 );
250 }
251
252 #[cfg(unix)]
253 #[test]
254 fn invalid_utf8_output_is_refused_rather_than_spliced() {
255 let (_d, spec) = fake("garbage", "printf '\\377\\376'");
259 assert!(matches!(
260 run(&spec, "x", None).unwrap_err(),
261 FormatError::NotUtf8 { .. }
262 ));
263 }
264
265 #[cfg(unix)]
266 #[test]
267 fn the_filename_flag_is_passed_when_the_spec_asks_for_it() {
268 let (_d, mut spec) = fake("echoargs", "cat >/dev/null; echo \"$1\"");
269 spec.filename_flag = Some("--name");
270 let out = run(&spec, "x", Some(Path::new("/tmp/a.ts"))).unwrap();
271 assert_eq!(out.trim(), "--name=/tmp/a.ts");
272 }
273}