Skip to main content

resq_cli/commands/
format.rs

1/*
2 * Copyright 2026 ResQ
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! `resq format` — polyglot formatter with a shared implementation that
18//! powers both the standalone command and the pre-commit format steps.
19//!
20//! Design: each language exports `format_<lang>(root, files, check)` —
21//! pre-commit calls these on its staged file list and then restages; the
22//! CLI wrapper (`resq format`) calls them on an empty list, which tells
23//! each formatter to operate on the whole project.
24
25use anyhow::Result;
26use clap::Parser;
27use std::path::{Path, PathBuf};
28use std::process::{Command, Stdio};
29
30/// Outcome of a per-language format step.
31#[derive(Debug, PartialEq, Eq)]
32pub enum FormatOutcome {
33    /// The formatter ran and made no changes (or found no issues in `--check`).
34    Clean,
35    /// The formatter ran and either rewrote files or — in `--check` — found issues.
36    /// Returns the original stderr (if any) for reporting.
37    Formatted,
38    /// Skipped: either no matching files or the required tool isn't installed.
39    Skipped(String),
40    /// Formatter exited with a non-zero status unexpectedly.
41    Failed(String),
42}
43
44impl FormatOutcome {
45    /// `true` iff the step should be treated as a pass for pre-commit gating.
46    #[must_use]
47    pub fn passed(&self) -> bool {
48        matches!(self, Self::Clean | Self::Formatted | Self::Skipped(_))
49    }
50}
51
52/// Arguments for the `format` command.
53#[derive(Parser, Debug)]
54pub struct FormatArgs {
55    /// Language to format. If omitted, runs every detected language.
56    #[arg(long, value_parser = ["rust", "ts", "python", "cpp", "csharp", "all"])]
57    pub language: Option<String>,
58
59    /// Report issues without rewriting files. Exits non-zero if any found.
60    #[arg(long)]
61    pub check: bool,
62}
63
64fn has_cmd(cmd: &str) -> bool {
65    Command::new("which")
66        .arg(cmd)
67        .stdout(Stdio::null())
68        .stderr(Stdio::null())
69        .status()
70        .map(|s| s.success())
71        .unwrap_or(false)
72}
73
74fn find_root() -> PathBuf {
75    crate::utils::find_project_root()
76}
77
78/// Format Rust files via `cargo fmt` (runs against the whole workspace
79/// when `files` is empty).
80///
81/// # Errors
82/// Never — failures are reported via `FormatOutcome::Failed(stderr)`.
83#[allow(clippy::unnecessary_wraps)]
84pub fn format_rust(root: &Path, files: &[String], check: bool) -> Result<FormatOutcome> {
85    let workspace_mode = files.is_empty();
86    if workspace_mode && !root.join("Cargo.toml").exists() {
87        return Ok(FormatOutcome::Skipped("no Cargo.toml".into()));
88    }
89    if !workspace_mode && !files.iter().any(|f| f.ends_with(".rs")) {
90        return Ok(FormatOutcome::Skipped("no .rs files".into()));
91    }
92    if !has_cmd("cargo") {
93        return Ok(FormatOutcome::Skipped("cargo not on PATH".into()));
94    }
95    let mut cmd = Command::new("cargo");
96    cmd.current_dir(root).arg("fmt").arg("--all");
97    if check {
98        cmd.args(["--", "--check"]);
99    }
100    let out = cmd.stdout(Stdio::null()).stderr(Stdio::piped()).output();
101    finalize(out, check)
102}
103
104/// Format JS/TS/JSON/CSS files via Biome (preferring `biome` over `bunx --bun biome`).
105///
106/// # Errors
107/// Never — failures are reported via `FormatOutcome::Failed(stderr)`.
108#[allow(clippy::unnecessary_wraps)]
109pub fn format_ts(root: &Path, files: &[String], check: bool) -> Result<FormatOutcome> {
110    const EXTS: &[&str] = &[".ts", ".tsx", ".js", ".jsx", ".json", ".css"];
111    let workspace_mode = files.is_empty();
112    if workspace_mode
113        && !root.join("package.json").exists()
114        && !root.join("biome.json").exists()
115        && !root.join("biome.jsonc").exists()
116    {
117        return Ok(FormatOutcome::Skipped(
118            "no package.json / biome config".into(),
119        ));
120    }
121    if !workspace_mode && !files.iter().any(|f| EXTS.iter().any(|e| f.ends_with(e))) {
122        return Ok(FormatOutcome::Skipped("no TS/JS files".into()));
123    }
124    let (cmd, prefix) = if has_cmd("biome") {
125        ("biome", Vec::<&str>::new())
126    } else if has_cmd("bunx") {
127        ("bunx", vec!["--bun", "biome"])
128    } else {
129        return Ok(FormatOutcome::Skipped("biome / bunx not on PATH".into()));
130    };
131    let mut args: Vec<String> = prefix.iter().map(|s| (*s).to_string()).collect();
132    args.push("format".into());
133    if !check {
134        args.push("--write".into());
135    }
136    if workspace_mode {
137        args.push(".".into());
138    } else {
139        args.extend(files.iter().cloned());
140    }
141    let out = Command::new(cmd)
142        .args(&args)
143        .current_dir(root)
144        .stdout(Stdio::null())
145        .stderr(Stdio::piped())
146        .output();
147    finalize(out, check)
148}
149
150/// Format Python files via `ruff format`.
151///
152/// # Errors
153/// Never — failures are reported via `FormatOutcome::Failed(stderr)`.
154#[allow(clippy::unnecessary_wraps)]
155pub fn format_python(root: &Path, files: &[String], check: bool) -> Result<FormatOutcome> {
156    let workspace_mode = files.is_empty();
157    if workspace_mode
158        && !root.join("pyproject.toml").exists()
159        && !root.join("setup.py").exists()
160        && !root.join("setup.cfg").exists()
161    {
162        return Ok(FormatOutcome::Skipped("no Python project markers".into()));
163    }
164    if !workspace_mode && !files.iter().any(|f| f.ends_with(".py")) {
165        return Ok(FormatOutcome::Skipped("no .py files".into()));
166    }
167    if !has_cmd("ruff") {
168        return Ok(FormatOutcome::Skipped("ruff not on PATH".into()));
169    }
170    let mut cmd = Command::new("ruff");
171    cmd.current_dir(root).arg("format");
172    if check {
173        cmd.arg("--check");
174    }
175    if workspace_mode {
176        cmd.arg(".");
177    } else {
178        cmd.args(files);
179    }
180    let out = cmd.stdout(Stdio::null()).stderr(Stdio::piped()).output();
181    finalize(out, check)
182}
183
184/// Format C/C++ files via `clang-format`.
185///
186/// # Errors
187/// Never — failures are reported via `FormatOutcome::Failed(stderr)`.
188#[allow(clippy::unnecessary_wraps)]
189pub fn format_cpp(root: &Path, files: &[String], check: bool) -> Result<FormatOutcome> {
190    const EXTS: &[&str] = &[".cpp", ".cc", ".h", ".hpp"];
191    let workspace_mode = files.is_empty();
192    let targets: Vec<String> = if workspace_mode {
193        // Discover .cpp/.cc/.h/.hpp under the workspace root, depth-limited.
194        walkdir::WalkDir::new(root)
195            .max_depth(10)
196            .into_iter()
197            .filter_map(Result::ok)
198            .filter(|e| e.file_type().is_file())
199            .filter_map(|e| e.path().to_str().map(String::from))
200            .filter(|p| EXTS.iter().any(|ext| p.ends_with(ext)))
201            .filter(|p| !p.contains("/target/") && !p.contains("/node_modules/"))
202            .collect()
203    } else {
204        files
205            .iter()
206            .filter(|f| EXTS.iter().any(|e| f.ends_with(e)))
207            .cloned()
208            .collect()
209    };
210    if targets.is_empty() {
211        return Ok(FormatOutcome::Skipped("no C/C++ files".into()));
212    }
213    if !has_cmd("clang-format") {
214        return Ok(FormatOutcome::Skipped("clang-format not on PATH".into()));
215    }
216    let mut cmd = Command::new("clang-format");
217    cmd.current_dir(root);
218    if check {
219        cmd.args(["--dry-run", "--Werror"]);
220    } else {
221        cmd.arg("-i");
222    }
223    cmd.args(&targets);
224    let out = cmd.stdout(Stdio::null()).stderr(Stdio::piped()).output();
225    finalize(out, check)
226}
227
228/// Format C# via `dotnet format`.
229///
230/// # Errors
231/// Never — failures are reported via `FormatOutcome::Failed(stderr)`.
232#[allow(clippy::unnecessary_wraps)]
233pub fn format_csharp(root: &Path, files: &[String], check: bool) -> Result<FormatOutcome> {
234    let workspace_mode = files.is_empty();
235    if !workspace_mode && !files.iter().any(|f| f.ends_with(".cs")) {
236        return Ok(FormatOutcome::Skipped("no .cs files".into()));
237    }
238    if !has_cmd("dotnet") {
239        return Ok(FormatOutcome::Skipped("dotnet not on PATH".into()));
240    }
241    let sln = root.join("libs/dotnet/ResQ.Packages.sln");
242    if !sln.exists() {
243        return Ok(FormatOutcome::Skipped("no ResQ.Packages.sln".into()));
244    }
245    let mut cmd = Command::new("dotnet");
246    cmd.current_dir(root).args([
247        "format",
248        "libs/dotnet/ResQ.Packages.sln",
249        "--verbosity",
250        "quiet",
251    ]);
252    if check {
253        cmd.arg("--verify-no-changes");
254    }
255    let out = cmd.stdout(Stdio::null()).stderr(Stdio::piped()).output();
256    finalize(out, check)
257}
258
259fn finalize(out: std::io::Result<std::process::Output>, check: bool) -> Result<FormatOutcome> {
260    let Ok(output) = out else {
261        return Ok(FormatOutcome::Failed("process spawn failed".into()));
262    };
263    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
264    if output.status.success() {
265        Ok(if check {
266            FormatOutcome::Clean
267        } else {
268            FormatOutcome::Formatted
269        })
270    } else if check {
271        // In --check mode, a non-zero exit is the expected signal for
272        // "would-be-formatted". Record it as Failed so callers can surface
273        // the stderr, but the CLI wrapper treats this as the --check signal.
274        Ok(FormatOutcome::Failed(stderr))
275    } else {
276        Ok(FormatOutcome::Failed(stderr))
277    }
278}
279
280/// Executes the `format` command.
281///
282/// # Errors
283/// Returns an error only if the argument validation fails. Per-language
284/// failures are reported on stderr and accumulate into the CLI exit code.
285pub async fn run(args: FormatArgs) -> Result<()> {
286    let root = find_root();
287    let langs: &[&str] = match args.language.as_deref() {
288        None | Some("all") => &["rust", "ts", "python", "cpp", "csharp"],
289        Some("rust") => &["rust"],
290        Some("ts") => &["ts"],
291        Some("python") => &["python"],
292        Some("cpp") => &["cpp"],
293        Some("csharp") => &["csharp"],
294        Some(other) => anyhow::bail!("Unknown --language '{other}'"),
295    };
296
297    let mut any_failed = false;
298    for lang in langs {
299        let outcome = match *lang {
300            "rust" => format_rust(&root, &[], args.check)?,
301            "ts" => format_ts(&root, &[], args.check)?,
302            "python" => format_python(&root, &[], args.check)?,
303            "cpp" => format_cpp(&root, &[], args.check)?,
304            "csharp" => format_csharp(&root, &[], args.check)?,
305            _ => unreachable!(),
306        };
307        match outcome {
308            FormatOutcome::Clean => println!("  ✅ {lang}: clean"),
309            FormatOutcome::Formatted => {
310                if args.check {
311                    // Shouldn't reach here — check-mode success maps to Clean.
312                    println!("  ✅ {lang}: clean");
313                } else {
314                    println!("  ✨ {lang}: formatted");
315                }
316            }
317            FormatOutcome::Skipped(reason) => {
318                println!("  ⏭  {lang}: skipped ({reason})");
319            }
320            FormatOutcome::Failed(stderr) => {
321                if args.check {
322                    println!("  ❌ {lang}: would reformat (run without --check to fix)");
323                } else {
324                    println!("  ❌ {lang}: formatter failed");
325                }
326                if !stderr.trim().is_empty() {
327                    for line in stderr.lines().take(20) {
328                        println!("      {line}");
329                    }
330                }
331                any_failed = true;
332            }
333        }
334    }
335
336    if any_failed {
337        anyhow::bail!(
338            "{} issue(s); run without --check to fix",
339            if args.check { "format" } else { "formatter" }
340        );
341    }
342    Ok(())
343}