cabin_fmt/lib.rs
1//! `clang-format` runner used by Cabin's developer-tools
2//! commands.
3//!
4//! This crate keeps formatting-specific executable resolution,
5//! command-line construction, and exit-status handling outside
6//! `cabin`, mirroring the crate boundaries used by
7//! `cabin-tidy`.
8//!
9//! Crate boundaries:
10//!
11//! - the crate owns formatter executable resolution and the
12//! `clang-format` command-line shape;
13//! - it accepts typed inputs ([`FormatRequest`]) and emits
14//! typed outcomes ([`FormatReport`]);
15//! - it never walks the filesystem looking for sources — that
16//! job belongs to `cabin-source-discovery`;
17//! - it never reads Cabin's configuration files — the
18//! orchestration layer threads any config-derived inputs
19//! through the typed `FormatRequest`.
20
21#![deny(missing_docs)]
22
23use std::collections::BTreeSet;
24use std::ffi::OsString;
25use std::path::{Path, PathBuf};
26use std::process::Command;
27
28use thiserror::Error;
29
30/// Environment variable users can set to override the
31/// `clang-format` executable Cabin invokes.
32///
33/// Precedence: when `CABIN_FMT` is set and non-empty, its value
34/// is the absolute path / command name Cabin uses verbatim;
35/// otherwise `clang-format` is resolved against `PATH` by the
36/// child process spawn.
37///
38/// Aliased from [`cabin_env`], the single source of truth for
39/// every `CABIN_*` environment-variable name.
40pub(crate) use cabin_env::CABIN_FMT as CABIN_FMT_ENV;
41
42/// Default executable name Cabin spawns when [`CABIN_FMT_ENV`]
43/// is not set.
44pub(crate) const DEFAULT_FORMATTER_EXECUTABLE: &str = "clang-format";
45
46/// Operation mode the runner should perform.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum FormatMode {
49 /// Rewrite each file in place (`clang-format -i`).
50 Write,
51 /// Verify each file is already formatted (`clang-format
52 /// --dry-run -Werror`). Files are *not* modified.
53 Check,
54}
55
56/// Input for [`run_formatter`].
57///
58/// Callers translate CLI flags into this typed shape and hand
59/// it to the runner; the runner is responsible for spawning
60/// the formatter and translating its exit status into a typed
61/// outcome.
62#[derive(Debug, Clone)]
63pub struct FormatRequest {
64 /// Absolute path or bare command name of the formatter
65 /// executable. Typically the value
66 /// [`resolve_formatter_executable`] returns.
67 pub executable: OsString,
68
69 /// Absolute paths to the files the formatter should
70 /// process. An empty `files` list is a valid no-op: the
71 /// runner returns a report with zero files processed and
72 /// does not spawn a subprocess.
73 pub files: Vec<PathBuf>,
74
75 /// Operation mode.
76 pub mode: FormatMode,
77}
78
79/// Per-mode outcome of [`run_formatter`].
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum FormatReport {
82 /// Write-mode succeeded. Every supplied file was
83 /// processed by the formatter; whether or not it was
84 /// actually modified is not reported (real `clang-format
85 /// -i` does not advertise that distinction either).
86 Wrote {
87 /// Total number of files passed to the formatter.
88 files_processed: usize,
89 },
90 /// Check-mode succeeded. Every supplied file was already
91 /// formatted.
92 Clean {
93 /// Total number of files inspected.
94 files_inspected: usize,
95 },
96 /// Check-mode reported at least one file that would be
97 /// reformatted.
98 NeedsFormatting {
99 /// Total number of files inspected.
100 files_inspected: usize,
101 /// Formatter stderr, trimmed of trailing whitespace.
102 /// Carries the per-file `would be reformatted` lines (or
103 /// any other diagnostic clang-format emitted under
104 /// `--dry-run -Werror`) so the orchestration layer can
105 /// pass them through to the user — matching `cargo fmt
106 /// --check`, which forwards rustfmt's diff verbatim.
107 stderr: String,
108 },
109}
110
111/// Errors surfaced by the runner.
112#[derive(Debug, Error)]
113pub enum FormatError {
114 /// The formatter executable was not found on the host.
115 /// Surfaces the executable name the runner attempted to
116 /// spawn and an actionable hint.
117 #[error(
118 "{executable} was not found on PATH.\n install `clang-format` (LLVM toolchain) and re-run, or set `{env}=/path/to/clang-format` to a specific binary"
119 )]
120 ExecutableNotFound {
121 /// Executable Cabin tried to spawn.
122 executable: String,
123 /// Name of the override env var.
124 env: &'static str,
125 },
126
127 /// Spawning the formatter failed with an I/O error other
128 /// than "not found". Wraps the underlying error so the
129 /// caller can render it verbatim.
130 #[error("failed to invoke {executable}: {source}")]
131 SpawnFailed {
132 /// Executable Cabin tried to spawn.
133 executable: String,
134 /// Underlying spawn error.
135 #[source]
136 source: std::io::Error,
137 },
138
139 /// The formatter ran but reported a non-zero exit status
140 /// outside the documented "check-mode signals a diff"
141 /// contract. Captured stderr (if any) is preserved so the
142 /// CLI can show it to the user.
143 #[error("{executable} exited with status {status}{}", display_stderr(stderr))]
144 InvocationFailed {
145 /// Executable Cabin tried to spawn.
146 executable: String,
147 /// The reported exit status. `None` when the process
148 /// was killed by a signal; the OS-specific code (if
149 /// any) is included in the message via the formatter.
150 status: ExitStatusKind,
151 /// Captured stderr, trimmed of trailing whitespace.
152 /// Empty when the formatter produced no stderr.
153 stderr: String,
154 },
155}
156
157/// Exit-status classification, shared with `cabin-tidy` so the two
158/// external-tool runners report process outcomes the same way.
159pub use cabin_core::ExitStatusKind;
160
161fn display_stderr(stderr: &str) -> String {
162 if stderr.is_empty() {
163 String::new()
164 } else {
165 format!("\n{stderr}")
166 }
167}
168
169/// Resolve the formatter executable Cabin should spawn.
170///
171/// Reads `CABIN_FMT` via the supplied env lookup closure; if
172/// the value is set and non-empty, it is used verbatim.
173/// Otherwise `DEFAULT_FORMATTER_EXECUTABLE` is returned and
174/// the spawn relies on `PATH`.
175///
176/// The closure interface keeps the function pure: tests pass
177/// a fake env without touching the process environment.
178pub fn resolve_formatter_executable<F>(env: F) -> OsString
179where
180 F: Fn(&str) -> Option<OsString>,
181{
182 if let Some(value) = env(CABIN_FMT_ENV)
183 && !value.is_empty()
184 {
185 return value;
186 }
187 OsString::from(DEFAULT_FORMATTER_EXECUTABLE)
188}
189
190/// Run `clang-format` over the requested files.
191///
192/// Returns a typed [`FormatReport`] when the formatter produced
193/// a recognized outcome; otherwise returns a typed
194/// [`FormatError`] that the CLI can render through its
195/// diagnostic chain.
196///
197/// # Errors
198/// Returns [`FormatError::ExecutableNotFound`] when spawning the
199/// formatter fails with `ErrorKind::NotFound`, and
200/// [`FormatError::SpawnFailed`] for any other spawn I/O error.
201/// Returns [`FormatError::InvocationFailed`] when the formatter
202/// exits unsuccessfully: in [`FormatMode::Write`] on any
203/// non-success status, and in [`FormatMode::Check`] on any exit
204/// status that is neither success (clean) nor code `1` (needs
205/// formatting).
206pub fn run_formatter(request: &FormatRequest) -> Result<FormatReport, FormatError> {
207 if request.files.is_empty() {
208 return Ok(match request.mode {
209 FormatMode::Write => FormatReport::Wrote { files_processed: 0 },
210 FormatMode::Check => FormatReport::Clean { files_inspected: 0 },
211 });
212 }
213
214 // Deterministic order keeps the produced command line
215 // byte-stable for very-verbose echoes and snapshot tests.
216 // We dedupe by absolute path while preserving sort order.
217 let files: BTreeSet<&Path> = request.files.iter().map(PathBuf::as_path).collect();
218 let files: Vec<&Path> = files.into_iter().collect();
219
220 let mut cmd = Command::new(&request.executable);
221 // `clang-format` discovers `.clang-format` from the first
222 // file's directory upward. Passing `--style=file`
223 // explicitly mirrors the documented behavior Cabin
224 // promises so a user who points `CABIN_FMT` at a
225 // wrapper sees the same discovery rule.
226 cmd.arg("--style=file");
227 match request.mode {
228 FormatMode::Write => {
229 cmd.arg("-i");
230 }
231 FormatMode::Check => {
232 cmd.arg("--dry-run").arg("-Werror");
233 }
234 }
235 for path in &files {
236 cmd.arg(path);
237 }
238
239 let output = match cmd.output() {
240 Ok(output) => output,
241 Err(err) => {
242 let executable = request.executable.to_string_lossy().into_owned();
243 if err.kind() == std::io::ErrorKind::NotFound {
244 return Err(FormatError::ExecutableNotFound {
245 executable,
246 env: CABIN_FMT_ENV,
247 });
248 }
249 return Err(FormatError::SpawnFailed {
250 executable,
251 source: err,
252 });
253 }
254 };
255
256 let status = output.status;
257 let stderr = String::from_utf8_lossy(&output.stderr)
258 .trim_end()
259 .to_owned();
260
261 match request.mode {
262 FormatMode::Write => {
263 if status.success() {
264 Ok(FormatReport::Wrote {
265 files_processed: files.len(),
266 })
267 } else {
268 Err(FormatError::InvocationFailed {
269 executable: request.executable.to_string_lossy().into_owned(),
270 status: cabin_core::exit_status_kind(status),
271 stderr,
272 })
273 }
274 }
275 FormatMode::Check => {
276 if status.success() {
277 return Ok(FormatReport::Clean {
278 files_inspected: files.len(),
279 });
280 }
281 // `clang-format --dry-run -Werror` exits with code
282 // 1 when any input would be reformatted; that is
283 // not an *error* for our purposes, it is the
284 // documented "check failed" signal. Anything else
285 // is a hard error.
286 if status.code() == Some(1) {
287 return Ok(FormatReport::NeedsFormatting {
288 files_inspected: files.len(),
289 stderr,
290 });
291 }
292 Err(FormatError::InvocationFailed {
293 executable: request.executable.to_string_lossy().into_owned(),
294 status: cabin_core::exit_status_kind(status),
295 stderr,
296 })
297 }
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 fn env_returning<'a>(
306 pairs: &'a [(&'a str, &'a str)],
307 ) -> impl Fn(&str) -> Option<OsString> + 'a {
308 move |key| {
309 pairs
310 .iter()
311 .find(|(k, _)| *k == key)
312 .map(|(_, v)| OsString::from(*v))
313 }
314 }
315
316 #[test]
317 fn default_executable_is_clang_format_when_env_unset() {
318 let resolved = resolve_formatter_executable(|_| None);
319 assert_eq!(resolved, OsString::from(DEFAULT_FORMATTER_EXECUTABLE));
320 }
321
322 #[test]
323 fn env_override_wins() {
324 let env = env_returning(&[(CABIN_FMT_ENV, "/opt/llvm/bin/clang-format")]);
325 let resolved = resolve_formatter_executable(env);
326 assert_eq!(resolved, OsString::from("/opt/llvm/bin/clang-format"));
327 }
328
329 #[test]
330 fn empty_env_value_falls_back_to_default() {
331 let env = env_returning(&[(CABIN_FMT_ENV, "")]);
332 let resolved = resolve_formatter_executable(env);
333 assert_eq!(resolved, OsString::from(DEFAULT_FORMATTER_EXECUTABLE));
334 }
335
336 #[test]
337 fn empty_files_is_a_clean_no_op() {
338 let req = FormatRequest {
339 executable: OsString::from("this-binary-should-not-be-invoked"),
340 files: Vec::new(),
341 mode: FormatMode::Check,
342 };
343 let report = run_formatter(&req).unwrap();
344 assert!(matches!(report, FormatReport::Clean { files_inspected: 0 }));
345
346 let req = FormatRequest {
347 mode: FormatMode::Write,
348 ..req
349 };
350 let report = run_formatter(&req).unwrap();
351 assert!(matches!(report, FormatReport::Wrote { files_processed: 0 }));
352 }
353
354 #[test]
355 fn missing_executable_yields_actionable_error() {
356 // Use a path we know does not exist.
357 let req = FormatRequest {
358 executable: OsString::from("/no-such/clang-format-binary"),
359 files: vec![PathBuf::from("/no-such/file.cc")],
360 mode: FormatMode::Write,
361 };
362 let err = run_formatter(&req).unwrap_err();
363 match err {
364 FormatError::ExecutableNotFound { executable, env } => {
365 assert_eq!(executable, "/no-such/clang-format-binary");
366 assert_eq!(env, CABIN_FMT_ENV);
367 }
368 other => panic!("unexpected error: {other:?}"),
369 }
370 }
371}