resq_cli/commands/
format.rs1use anyhow::Result;
26use clap::Parser;
27use std::path::{Path, PathBuf};
28use std::process::{Command, Stdio};
29
30#[derive(Debug, PartialEq, Eq)]
32pub enum FormatOutcome {
33 Clean,
35 Formatted,
38 Skipped(String),
40 Failed(String),
42}
43
44impl FormatOutcome {
45 #[must_use]
47 pub fn passed(&self) -> bool {
48 matches!(self, Self::Clean | Self::Formatted | Self::Skipped(_))
49 }
50}
51
52#[derive(Parser, Debug)]
54pub struct FormatArgs {
55 #[arg(long, value_parser = ["rust", "ts", "python", "cpp", "csharp", "all"])]
57 pub language: Option<String>,
58
59 #[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#[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#[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#[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#[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 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#[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 Ok(FormatOutcome::Failed(stderr))
275 } else {
276 Ok(FormatOutcome::Failed(stderr))
277 }
278}
279
280pub 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 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}