Skip to main content

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}