Skip to main content

resq_cli/commands/
pre_commit.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//! Unified pre-commit hook logic with an optimized ratatui TUI.
18
19use anyhow::{bail, Result};
20use crossterm::{
21    event::{self, Event, KeyCode, KeyEventKind},
22    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
23    ExecutableCommand,
24};
25use ratatui::{
26    layout::{Constraint, Layout, Rect},
27    style::{Color, Style, Stylize},
28    text::{Line, Span},
29    widgets::{Block, BorderType, Borders, Paragraph},
30    Frame,
31};
32use resq_tui::{self as tui, Theme};
33use std::io;
34use std::path::{Path, PathBuf};
35use std::process::{Command, Stdio};
36use std::sync::mpsc;
37use std::time::{Duration, Instant};
38
39// ── CLI Args ─────────────────────────────────────────────────────────────────
40
41/// Run all pre-commit checks with TUI progress output.
42#[derive(clap::Args, Debug)]
43pub struct PreCommitArgs {
44    /// Project root (defaults to auto-detected)
45    #[arg(long, default_value = ".")]
46    pub root: PathBuf,
47
48    /// Skip security audit (osv-scanner + npm audit-ci)
49    #[arg(long)]
50    pub skip_audit: bool,
51
52    /// Skip formatting step
53    #[arg(long)]
54    pub skip_format: bool,
55
56    /// Skip changeset/versioning prompt
57    #[arg(long)]
58    pub skip_versioning: bool,
59
60    /// Maximum file size in bytes (default: 10 MiB)
61    #[arg(long, default_value_t = 10_485_760)]
62    pub max_file_size: u64,
63
64    /// Disable TUI (plain output for CI or piped stderr)
65    #[arg(long)]
66    pub no_tui: bool,
67}
68
69// ── Step tracking ────────────────────────────────────────────────────────────
70
71/// The status of a pre-commit step.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73enum StepStatus {
74    Pending,
75    Running,
76    Pass,
77    Warn,
78    Skip,
79    Fail,
80}
81
82impl StepStatus {
83    fn icon(self) -> &'static str {
84        match self {
85            Self::Pending => "○",
86            Self::Running => "●",
87            Self::Pass => "✅",
88            Self::Warn => "⚠️ ",
89            Self::Skip => "⏭️ ",
90            Self::Fail => "❌",
91        }
92    }
93
94    fn color(self, theme: &Theme) -> Color {
95        match self {
96            Self::Pending => theme.inactive,
97            Self::Running => theme.primary,
98            Self::Pass => theme.success,
99            Self::Warn => theme.warning,
100            Self::Skip => theme.inactive,
101            Self::Fail => theme.error,
102        }
103    }
104
105    fn is_terminal(self) -> bool {
106        matches!(self, Self::Pass | Self::Warn | Self::Skip | Self::Fail)
107    }
108}
109
110/// Visible state for one step in the TUI.
111#[derive(Debug, Clone)]
112struct StepState {
113    name: String,
114    emoji: String,
115    status: StepStatus,
116    detail: Option<String>,
117    sub_lines: Vec<String>,
118    elapsed: Option<Duration>,
119    is_formatter: bool,
120    is_versioning: bool,
121}
122
123/// Message sent from the worker thread to the TUI render loop.
124#[derive(Debug)]
125enum StepMsg {
126    Started(usize),
127    Finished(usize, StepStatus, Option<String>, Vec<String>, Duration),
128    Output(usize, String),
129    PromptChangeset(usize),
130    AllDone,
131}
132
133#[derive(Debug)]
134enum ChangesetResponse {
135    Patch,
136    Minor,
137    Major,
138    None,
139}
140
141// ── TUI Application State ────────────────────────────────────────────────────
142
143struct App {
144    steps: Vec<StepState>,
145    start_time: Instant,
146    done: bool,
147    aborted: bool,
148    spinner_tick: usize,
149    scroll_offset: usize,
150    theme: Theme,
151
152    // Versioning Prompt State
153    prompting_idx: Option<usize>,
154    changeset_selector: usize, // 0: none, 1: patch, 2: minor, 3: major
155    changeset_message: String,
156    entering_message: bool,
157}
158
159impl App {
160    fn new(steps: Vec<StepState>) -> Self {
161        Self {
162            steps,
163            start_time: Instant::now(),
164            done: false,
165            aborted: false,
166            spinner_tick: 0,
167            scroll_offset: 0,
168            theme: Theme::default(),
169            prompting_idx: None,
170            changeset_selector: 0,
171            changeset_message: String::new(),
172            entering_message: false,
173        }
174    }
175
176    fn apply(&mut self, msg: StepMsg) {
177        match msg {
178            StepMsg::Started(i) => {
179                if let Some(s) = self.steps.get_mut(i) {
180                    s.status = StepStatus::Running;
181                }
182            }
183            StepMsg::Finished(i, status, detail, sub_lines, elapsed) => {
184                if let Some(s) = self.steps.get_mut(i) {
185                    s.status = status;
186                    s.detail = detail;
187                    s.sub_lines = sub_lines;
188                    s.elapsed = Some(elapsed);
189                }
190            }
191            StepMsg::Output(i, line) => {
192                if let Some(s) = self.steps.get_mut(i) {
193                    if s.sub_lines.len() > 10 {
194                        s.sub_lines.remove(0);
195                    }
196                    s.sub_lines.push(line);
197                }
198            }
199            StepMsg::PromptChangeset(i) => {
200                self.prompting_idx = Some(i);
201                if let Some(s) = self.steps.get_mut(i) {
202                    s.status = StepStatus::Running;
203                }
204            }
205            StepMsg::AllDone => {
206                self.done = true;
207            }
208        }
209    }
210
211    fn counts(&self) -> (usize, usize, usize, usize) {
212        let mut p = 0;
213        let mut f = 0;
214        let mut w = 0;
215        let mut s = 0;
216        for step in &self.steps {
217            match step.status {
218                StepStatus::Pass => p += 1,
219                StepStatus::Fail => f += 1,
220                StepStatus::Warn => w += 1,
221                StepStatus::Skip => s += 1,
222                _ => {}
223            }
224        }
225        (p, f, w, s)
226    }
227}
228
229// ── TUI Rendering ────────────────────────────────────────────────────────────
230
231fn draw(frame: &mut Frame, app: &App) {
232    let size = frame.area();
233    let chunks = Layout::vertical([
234        Constraint::Length(3), // Header
235        Constraint::Min(5),    // Steps
236        Constraint::Length(3), // Footer
237    ])
238    .split(size);
239
240    draw_header(frame, chunks[0], app);
241    draw_steps(frame, chunks[1], app);
242    draw_footer(frame, chunks[2], app);
243
244    if app.prompting_idx.is_some() {
245        draw_prompt(frame, size, app);
246    }
247}
248
249fn draw_header(frame: &mut Frame, area: Rect, app: &App) {
250    let (pass, fail, _warn, _skip) = app.counts();
251    let status_text = if app.done {
252        if fail > 0 {
253            format!("FAILED ({fail} ERROR)")
254        } else {
255            format!("PASSED ({pass} OK)")
256        }
257    } else if app.prompting_idx.is_some() {
258        "WAITING FOR INPUT".to_string()
259    } else {
260        let done = app.steps.iter().filter(|s| s.status.is_terminal()).count();
261        format!("RUNNING ({done}/{})", app.steps.len())
262    };
263
264    let status_color = if app.done {
265        if fail > 0 {
266            app.theme.error
267        } else {
268            app.theme.success
269        }
270    } else {
271        app.theme.warning
272    };
273
274    tui::draw_header(
275        frame,
276        area,
277        "Pre-commit",
278        &status_text,
279        status_color,
280        None,
281        &format!("{}s", app.start_time.elapsed().as_secs()),
282        &app.theme,
283    );
284}
285
286fn draw_steps(frame: &mut Frame, area: Rect, app: &App) {
287    let block = Block::default()
288        .borders(Borders::ALL)
289        .border_type(BorderType::Rounded)
290        .border_style(Style::default().fg(app.theme.inactive))
291        .title(" Verification Pipeline ");
292
293    let inner = block.inner(area);
294    frame.render_widget(block, area);
295
296    let mut lines = Vec::new();
297    let mut saw_formatter = false;
298    let mut saw_versioning = false;
299
300    for step in &app.steps {
301        if step.is_formatter && !saw_formatter {
302            saw_formatter = true;
303            lines.push(Line::from(
304                "  ── Formatters ──────────────────────────────────────────".fg(app.theme.inactive),
305            ));
306        }
307        if step.is_versioning && !saw_versioning {
308            saw_versioning = true;
309            lines.push(Line::from(
310                "  ── Versioning ──────────────────────────────────────────".fg(app.theme.inactive),
311            ));
312        }
313
314        let spinner = tui::SPINNER_FRAMES[app.spinner_tick % tui::SPINNER_FRAMES.len()];
315        let icon = if step.status == StepStatus::Running {
316            spinner
317        } else {
318            step.status.icon()
319        };
320
321        let mut spans = vec![
322            Span::raw("  "),
323            Span::styled(
324                format!("{icon} "),
325                Style::default().fg(step.status.color(&app.theme)),
326            ),
327            Span::styled(format!("{} ", step.emoji), Style::default()),
328            Span::styled(
329                step.name.clone(),
330                Style::default().fg(if step.status == StepStatus::Pending {
331                    app.theme.inactive
332                } else {
333                    app.theme.fg
334                }),
335            ),
336        ];
337
338        if let Some(ref d) = step.detail {
339            spans.push(Span::raw(format!(" {d}")).fg(app.theme.inactive));
340        }
341
342        lines.push(Line::from(spans));
343
344        if (step.status == StepStatus::Running
345            || step.status == StepStatus::Fail
346            || step.status == StepStatus::Warn)
347            && !step.sub_lines.is_empty()
348        {
349            for sub in step.sub_lines.iter().rev().take(3).rev() {
350                lines.push(Line::from(
351                    format!("       └─ {sub}").fg(app.theme.inactive),
352                ));
353            }
354        }
355    }
356
357    frame.render_widget(
358        Paragraph::new(lines).scroll((app.scroll_offset as u16, 0)),
359        inner,
360    );
361}
362
363fn draw_prompt(frame: &mut Frame, area: Rect, app: &App) {
364    let prompt_area = tui::centered_rect(60, 40, area);
365    frame.render_widget(ratatui::widgets::Clear, prompt_area);
366
367    let block = Block::default()
368        .borders(Borders::ALL)
369        .border_type(BorderType::Double)
370        .border_style(Style::default().fg(app.theme.primary))
371        .title(" Version Bump Intent ")
372        .bg(Color::Black);
373
374    let inner = block.inner(prompt_area);
375    frame.render_widget(block, prompt_area);
376
377    let chunks = Layout::vertical([
378        Constraint::Length(2),
379        Constraint::Length(3),
380        Constraint::Length(1),
381        Constraint::Length(3),
382        Constraint::Min(1),
383    ])
384    .split(inner);
385
386    frame.render_widget(
387        Paragraph::new("Does this change require a version bump?").bold(),
388        chunks[0],
389    );
390
391    let options = [" None ", " Patch ", " Minor ", " Major "];
392    let spans: Vec<Span> = options
393        .iter()
394        .enumerate()
395        .map(|(i, &opt)| {
396            if i == app.changeset_selector {
397                Span::styled(
398                    opt,
399                    Style::default()
400                        .bg(app.theme.primary)
401                        .fg(app.theme.bg)
402                        .bold(),
403                )
404            } else {
405                Span::raw(opt).fg(app.theme.fg)
406            }
407        })
408        .collect();
409
410    frame.render_widget(Paragraph::new(Line::from(spans)), chunks[1]);
411
412    if app.changeset_selector > 0 {
413        let msg_style = if app.entering_message {
414            app.theme.primary
415        } else {
416            app.theme.inactive
417        };
418        let msg_block = Block::default()
419            .borders(Borders::ALL)
420            .border_type(BorderType::Rounded)
421            .border_style(Style::default().fg(msg_style))
422            .title(" Change Summary ");
423        let msg = if app.changeset_message.is_empty() && !app.entering_message {
424            "Press 'm' to add a summary..."
425        } else {
426            &app.changeset_message
427        };
428        frame.render_widget(Paragraph::new(msg).block(msg_block), chunks[3]);
429    }
430
431    let help = if app.entering_message {
432        "Enter: confirm, Esc: cancel"
433    } else {
434        "←/→ Select, 'm' Message, Enter: Commit, Esc: Abort"
435    };
436    frame.render_widget(Paragraph::new(help).fg(app.theme.inactive), chunks[4]);
437}
438
439fn draw_footer(frame: &mut Frame, area: Rect, app: &App) {
440    let keys = if app.done {
441        vec![("Enter", "Exit"), ("q", "Quit")]
442    } else if app.prompting_idx.is_some() {
443        vec![("Enter", "Confirm"), ("Esc", "Abort")]
444    } else {
445        vec![("q", "Abort"), ("↑/↓", "Scroll")]
446    };
447    tui::draw_footer(frame, area, &keys, &app.theme);
448}
449
450// ── Logic Helpers ────────────────────────────────────────────────────────────
451
452fn staged_files(exts: &[&str]) -> Vec<String> {
453    let output = Command::new("git")
454        .args(["diff", "--cached", "--name-only", "--diff-filter=ACM"])
455        .output();
456    let Ok(output) = output else { return vec![] };
457    if !output.status.success() {
458        return vec![];
459    }
460    String::from_utf8_lossy(&output.stdout)
461        .lines()
462        .filter(|f| exts.iter().any(|ext| f.ends_with(ext)))
463        .filter(|f| !f.contains("/vendor/"))
464        .map(String::from)
465        .collect()
466}
467
468fn restage(files: &[String]) {
469    if !files.is_empty() {
470        let _ = Command::new("git").arg("add").args(files).status();
471    }
472}
473
474fn has_cmd(cmd: &str) -> bool {
475    Command::new("which")
476        .arg(cmd)
477        .stdout(Stdio::null())
478        .stderr(Stdio::null())
479        .status()
480        .map(|s| s.success())
481        .unwrap_or(false)
482}
483
484fn self_exe() -> PathBuf {
485    std::env::current_exe().unwrap_or_else(|_| PathBuf::from("resq"))
486}
487
488struct StepResult {
489    status: StepStatus,
490    detail: Option<String>,
491    sub_lines: Vec<String>,
492}
493
494// ── Step Implementations ─────────────────────────────────────────────────────
495
496fn step_copyright(root: &Path) -> StepResult {
497    let exe = self_exe();
498    let ok = Command::new(&exe)
499        .arg("copyright")
500        .current_dir(root)
501        .stdout(Stdio::null())
502        .stderr(Stdio::null())
503        .status()
504        .map(|s| s.success())
505        .unwrap_or(false);
506    if !ok {
507        return StepResult {
508            status: StepStatus::Fail,
509            detail: Some("write failed".into()),
510            sub_lines: vec![],
511        };
512    }
513    let _ = Command::new("git")
514        .args(["add", "-u"])
515        .current_dir(root)
516        .status();
517    let ok = Command::new(&exe)
518        .args(["copyright", "--check"])
519        .current_dir(root)
520        .stdout(Stdio::null())
521        .stderr(Stdio::null())
522        .status()
523        .map(|s| s.success())
524        .unwrap_or(false);
525    StepResult {
526        status: if ok {
527            StepStatus::Pass
528        } else {
529            StepStatus::Fail
530        },
531        detail: if ok {
532            None
533        } else {
534            Some("headers missing".into())
535        },
536        sub_lines: vec![],
537    }
538}
539
540fn step_large_files(max_size: u64) -> StepResult {
541    let output = Command::new("git")
542        .args(["diff", "--cached", "--name-only", "--diff-filter=ACM"])
543        .output();
544    let Ok(output) = output else {
545        return StepResult {
546            status: StepStatus::Skip,
547            detail: None,
548            sub_lines: vec![],
549        };
550    };
551    let files = String::from_utf8_lossy(&output.stdout);
552    let mut large = Vec::new();
553    for f in files.lines() {
554        if let Ok(m) = std::fs::metadata(f) {
555            if m.len() > max_size {
556                large.push(format!("{f} ({:.1} MiB)", m.len() as f64 / 1048576.0));
557            }
558        }
559    }
560    if large.is_empty() {
561        StepResult {
562            status: StepStatus::Pass,
563            detail: None,
564            sub_lines: vec![],
565        }
566    } else {
567        StepResult {
568            status: StepStatus::Fail,
569            detail: Some(format!("{} files too large", large.len())),
570            sub_lines: large,
571        }
572    }
573}
574
575fn step_debug_statements() -> StepResult {
576    let files = staged_files(&[".rs", ".ts", ".tsx", ".js", ".jsx", ".py"]);
577    let mut warnings = Vec::new();
578    for file in &files {
579        let out = Command::new("git")
580            .args(["diff", "--cached", "--", file])
581            .output();
582        let Ok(out) = out else { continue };
583        let diff = String::from_utf8_lossy(&out.stdout);
584        let patterns = if file.ends_with(".py") {
585            vec!["print(", "breakpoint(", "import pdb"]
586        } else {
587            vec!["console.log", "dbg!", "debugger;"]
588        };
589        for line in diff
590            .lines()
591            .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
592        {
593            if patterns.iter().any(|p| line.contains(p)) {
594                warnings.push(file.clone());
595                break;
596            }
597        }
598    }
599    if warnings.is_empty() {
600        StepResult {
601            status: StepStatus::Pass,
602            detail: None,
603            sub_lines: vec![],
604        }
605    } else {
606        StepResult {
607            status: StepStatus::Warn,
608            detail: Some(format!("{} debug stmts", warnings.len())),
609            sub_lines: warnings,
610        }
611    }
612}
613
614fn step_secrets() -> StepResult {
615    let exe = self_exe();
616    let ok = Command::new(&exe)
617        .args(["secrets", "--staged"])
618        .stdout(Stdio::null())
619        .stderr(Stdio::null())
620        .status()
621        .map(|s| s.success())
622        .unwrap_or(false);
623    StepResult {
624        status: if ok {
625            StepStatus::Pass
626        } else {
627            StepStatus::Fail
628        },
629        detail: if ok {
630            None
631        } else {
632            Some("secrets detected".into())
633        },
634        sub_lines: vec![],
635    }
636}
637
638fn step_audit(root: &Path, tx: &mpsc::Sender<StepMsg>, idx: usize) -> StepResult {
639    let exe = self_exe();
640    let mut args = vec!["audit", "--skip-react"];
641    if staged_files(&["package.json", "bun.lockb", "bun.lock"]).is_empty() {
642        args.push("--skip-npm");
643    }
644    let child = Command::new(&exe)
645        .args(&args)
646        .current_dir(root)
647        .stdin(Stdio::null())
648        .stdout(Stdio::piped())
649        .stderr(Stdio::piped())
650        .spawn();
651    let Ok(child) = child else {
652        return StepResult {
653            status: StepStatus::Fail,
654            detail: Some("spawn failed".into()),
655            sub_lines: vec![],
656        };
657    };
658    let output = child.wait_with_output();
659    let Ok(output) = output else {
660        return StepResult {
661            status: StepStatus::Fail,
662            detail: Some("exec failed".into()),
663            sub_lines: vec![],
664        };
665    };
666    let combined = format!(
667        "{}{}",
668        String::from_utf8_lossy(&output.stdout),
669        String::from_utf8_lossy(&output.stderr)
670    );
671    let interesting: Vec<String> = combined
672        .lines()
673        .filter(|l| l.contains("✅") || l.contains("❌") || l.contains("Vulnerabilities"))
674        .map(|l| l.trim().to_string())
675        .take(5)
676        .collect();
677    for line in &interesting {
678        let _ = tx.send(StepMsg::Output(idx, line.clone()));
679    }
680    StepResult {
681        status: if output.status.success() {
682            StepStatus::Pass
683        } else {
684            StepStatus::Fail
685        },
686        detail: None,
687        sub_lines: interesting,
688    }
689}
690
691fn step_format_rust(root: &Path) -> StepResult {
692    if staged_files(&[".rs"]).is_empty() {
693        return StepResult {
694            status: StepStatus::Skip,
695            detail: Some("no files".into()),
696            sub_lines: vec![],
697        };
698    }
699    if !has_cmd("cargo") {
700        return StepResult {
701            status: StepStatus::Skip,
702            detail: Some("no cargo".into()),
703            sub_lines: vec![],
704        };
705    }
706    let output = Command::new("cargo")
707        .args(["fmt", "--all"])
708        .current_dir(root)
709        .stdout(Stdio::null())
710        .stderr(Stdio::piped())
711        .output();
712    let ok = output.as_ref().map(|o| o.status.success()).unwrap_or(false);
713    if ok {
714        let _ = Command::new("git")
715            .args(["add", "-u"])
716            .current_dir(root)
717            .status();
718    } else if let Ok(ref o) = output {
719        // Show stderr only on failure (suppresses nightly-option warnings on stable)
720        let stderr = String::from_utf8_lossy(&o.stderr);
721        if !stderr.is_empty() {
722            eprintln!("{stderr}");
723        }
724    }
725    StepResult {
726        status: if ok {
727            StepStatus::Pass
728        } else {
729            StepStatus::Fail
730        },
731        detail: None,
732        sub_lines: vec![],
733    }
734}
735
736fn step_format_ts(root: &Path) -> StepResult {
737    let files = staged_files(&[".ts", ".tsx", ".js", ".jsx", ".json", ".css"]);
738    if files.is_empty() {
739        return StepResult {
740            status: StepStatus::Skip,
741            detail: Some("no files".into()),
742            sub_lines: vec![],
743        };
744    }
745    let biome = if has_cmd("biome") {
746        Some(("biome", vec![]))
747    } else if has_cmd("bunx") {
748        Some(("bunx", vec!["--bun", "biome"]))
749    } else {
750        None
751    };
752    let Some((cmd, prefix)) = biome else {
753        return StepResult {
754            status: StepStatus::Skip,
755            detail: Some("no biome".into()),
756            sub_lines: vec![],
757        };
758    };
759    let mut args = prefix.iter().map(|s| (*s).to_string()).collect::<Vec<_>>();
760    args.extend(["format".into(), "--write".into()]);
761    args.extend(files.iter().cloned());
762    let ok = Command::new(cmd)
763        .args(&args)
764        .current_dir(root)
765        .status()
766        .map(|s| s.success())
767        .unwrap_or(false);
768    if ok {
769        restage(&files);
770    }
771    StepResult {
772        status: if ok {
773            StepStatus::Pass
774        } else {
775            StepStatus::Fail
776        },
777        detail: None,
778        sub_lines: vec![],
779    }
780}
781
782fn step_format_python(root: &Path) -> StepResult {
783    let files = staged_files(&[".py"]);
784    if files.is_empty() {
785        return StepResult {
786            status: StepStatus::Skip,
787            detail: Some("no files".into()),
788            sub_lines: vec![],
789        };
790    }
791    if !has_cmd("ruff") {
792        return StepResult {
793            status: StepStatus::Skip,
794            detail: Some("no ruff".into()),
795            sub_lines: vec![],
796        };
797    }
798    let ok = Command::new("ruff")
799        .args(["format"])
800        .args(&files)
801        .current_dir(root)
802        .status()
803        .map(|s| s.success())
804        .unwrap_or(false);
805    if ok {
806        restage(&files);
807    }
808    StepResult {
809        status: if ok {
810            StepStatus::Pass
811        } else {
812            StepStatus::Fail
813        },
814        detail: None,
815        sub_lines: vec![],
816    }
817}
818
819fn step_format_cpp(root: &Path) -> StepResult {
820    let files = staged_files(&[".cpp", ".cc", ".h", ".hpp"]);
821    if files.is_empty() {
822        return StepResult {
823            status: StepStatus::Skip,
824            detail: Some("no files".into()),
825            sub_lines: vec![],
826        };
827    }
828    if !has_cmd("clang-format") {
829        return StepResult {
830            status: StepStatus::Skip,
831            detail: Some("no clang-format".into()),
832            sub_lines: vec![],
833        };
834    }
835    let ok = Command::new("clang-format")
836        .arg("-i")
837        .args(&files)
838        .current_dir(root)
839        .status()
840        .map(|s| s.success())
841        .unwrap_or(false);
842    if ok {
843        restage(&files);
844    }
845    StepResult {
846        status: if ok {
847            StepStatus::Pass
848        } else {
849            StepStatus::Fail
850        },
851        detail: None,
852        sub_lines: vec![],
853    }
854}
855
856fn step_format_csharp(root: &Path) -> StepResult {
857    let files = staged_files(&[".cs"]);
858    if files.is_empty() {
859        return StepResult {
860            status: StepStatus::Skip,
861            detail: Some("no files".into()),
862            sub_lines: vec![],
863        };
864    }
865    if !has_cmd("dotnet") {
866        return StepResult {
867            status: StepStatus::Skip,
868            detail: Some("no dotnet".into()),
869            sub_lines: vec![],
870        };
871    }
872    let ok = Command::new("dotnet")
873        .args([
874            "format",
875            "libs/dotnet/ResQ.Packages.sln",
876            "--verbosity",
877            "quiet",
878        ])
879        .current_dir(root)
880        .status()
881        .map(|s| s.success())
882        .unwrap_or(false);
883    if ok {
884        restage(&files);
885    }
886    StepResult {
887        status: if ok {
888            StepStatus::Pass
889        } else {
890            StepStatus::Fail
891        },
892        detail: None,
893        sub_lines: vec![],
894    }
895}
896
897// ── Worker & Main ────────────────────────────────────────────────────────────
898
899fn build_step_list(skip_audit: bool, skip_format: bool, skip_versioning: bool) -> Vec<StepState> {
900    let mut steps = vec![
901        StepState {
902            name: "Copyright headers".into(),
903            emoji: "📝".into(),
904            status: StepStatus::Pending,
905            detail: None,
906            sub_lines: vec![],
907            elapsed: None,
908            is_formatter: false,
909            is_versioning: false,
910        },
911        StepState {
912            name: "Large file check".into(),
913            emoji: "📦".into(),
914            status: StepStatus::Pending,
915            detail: None,
916            sub_lines: vec![],
917            elapsed: None,
918            is_formatter: false,
919            is_versioning: false,
920        },
921        StepState {
922            name: "Debug statements".into(),
923            emoji: "🐛".into(),
924            status: StepStatus::Pending,
925            detail: None,
926            sub_lines: vec![],
927            elapsed: None,
928            is_formatter: false,
929            is_versioning: false,
930        },
931        StepState {
932            name: "Secrets scan".into(),
933            emoji: "🔐".into(),
934            status: StepStatus::Pending,
935            detail: None,
936            sub_lines: vec![],
937            elapsed: None,
938            is_formatter: false,
939            is_versioning: false,
940        },
941        StepState {
942            name: "Security audit".into(),
943            emoji: "🔒".into(),
944            status: if skip_audit {
945                StepStatus::Skip
946            } else {
947                StepStatus::Pending
948            },
949            detail: None,
950            sub_lines: vec![],
951            elapsed: None,
952            is_formatter: false,
953            is_versioning: false,
954        },
955    ];
956    if !skip_format {
957        steps.extend(vec![
958            StepState {
959                name: "Format Rust".into(),
960                emoji: "🦀".into(),
961                status: StepStatus::Pending,
962                detail: None,
963                sub_lines: vec![],
964                elapsed: None,
965                is_formatter: true,
966                is_versioning: false,
967            },
968            StepState {
969                name: "Format TS/JS".into(),
970                emoji: "🎨".into(),
971                status: StepStatus::Pending,
972                detail: None,
973                sub_lines: vec![],
974                elapsed: None,
975                is_formatter: true,
976                is_versioning: false,
977            },
978            StepState {
979                name: "Format Python".into(),
980                emoji: "🐍".into(),
981                status: StepStatus::Pending,
982                detail: None,
983                sub_lines: vec![],
984                elapsed: None,
985                is_formatter: true,
986                is_versioning: false,
987            },
988            StepState {
989                name: "Format C++".into(),
990                emoji: "⚙️ ".into(),
991                status: StepStatus::Pending,
992                detail: None,
993                sub_lines: vec![],
994                elapsed: None,
995                is_formatter: true,
996                is_versioning: false,
997            },
998            StepState {
999                name: "Format C#".into(),
1000                emoji: "🔷".into(),
1001                status: StepStatus::Pending,
1002                detail: None,
1003                sub_lines: vec![],
1004                elapsed: None,
1005                is_formatter: true,
1006                is_versioning: false,
1007            },
1008        ]);
1009    }
1010    if !skip_versioning {
1011        steps.push(StepState {
1012            name: "Versioning / Changeset".into(),
1013            emoji: "🏷️".into(),
1014            status: StepStatus::Pending,
1015            detail: None,
1016            sub_lines: vec![],
1017            elapsed: None,
1018            is_formatter: false,
1019            is_versioning: true,
1020        });
1021    }
1022    steps
1023}
1024
1025fn run_worker(
1026    tx: mpsc::Sender<StepMsg>,
1027    res_rx: mpsc::Receiver<ChangesetResponse>,
1028    root: PathBuf,
1029    skip_audit: bool,
1030    skip_format: bool,
1031    skip_versioning: bool,
1032    max_file_size: u64,
1033) {
1034    let mut idx = 0;
1035    macro_rules! run {
1036        ($step_fn:expr) => {{
1037            let _ = tx.send(StepMsg::Started(idx));
1038            let start = Instant::now();
1039            let res = $step_fn;
1040            let _ = tx.send(StepMsg::Finished(
1041                idx,
1042                res.status,
1043                res.detail,
1044                res.sub_lines,
1045                start.elapsed(),
1046            ));
1047            idx += 1;
1048        }};
1049    }
1050
1051    run!(step_copyright(&root));
1052    run!(step_large_files(max_file_size));
1053    run!(step_debug_statements());
1054    run!(step_secrets());
1055    if skip_audit {
1056        idx += 1;
1057    } else {
1058        run!(step_audit(&root, &tx, idx));
1059    }
1060
1061    if !skip_format {
1062        run!(step_format_rust(&root));
1063        run!(step_format_ts(&root));
1064        run!(step_format_python(&root));
1065        run!(step_format_cpp(&root));
1066        run!(step_format_csharp(&root));
1067    }
1068
1069    if !skip_versioning {
1070        let _ = tx.send(StepMsg::PromptChangeset(idx));
1071        if let Ok(resp) = res_rx.recv() {
1072            let (status, detail) = match resp {
1073                ChangesetResponse::None => (StepStatus::Skip, Some("no bump".into())),
1074                _ => (StepStatus::Pass, Some("bump recorded".into())),
1075            };
1076            let _ = tx.send(StepMsg::Finished(
1077                idx,
1078                status,
1079                detail,
1080                vec![],
1081                Duration::ZERO,
1082            ));
1083        }
1084    }
1085    let _ = tx.send(StepMsg::AllDone);
1086}
1087
1088/// Main entry point for the pre-commit command.
1089///
1090/// Runs verification checks and formatters with a TUI progress dashboard.
1091pub async fn run(args: PreCommitArgs) -> Result<()> {
1092    let root = if args.root == Path::new(".") {
1093        crate::utils::find_project_root()
1094    } else {
1095        args.root.clone()
1096    };
1097    if args.no_tui
1098        || !crossterm::tty::IsTty::is_tty(&io::stderr())
1099        || std::env::var_os("GIT_INDEX_FILE").is_some()
1100    {
1101        return run_plain(&root, args.skip_audit, args.skip_format, args.max_file_size);
1102    }
1103
1104    let steps = build_step_list(args.skip_audit, args.skip_format, args.skip_versioning);
1105    let mut app = App::new(steps);
1106    let (tx, rx) = mpsc::channel();
1107    let (res_tx, res_rx) = mpsc::channel();
1108    let worker_root = root.clone();
1109
1110    std::thread::spawn(move || {
1111        run_worker(
1112            tx,
1113            res_rx,
1114            worker_root,
1115            args.skip_audit,
1116            args.skip_format,
1117            args.skip_versioning,
1118            args.max_file_size,
1119        );
1120    });
1121
1122    enable_raw_mode()?;
1123    io::stderr().execute(EnterAlternateScreen)?;
1124    let mut terminal =
1125        ratatui::Terminal::new(ratatui::backend::CrosstermBackend::new(io::stderr()))?;
1126    let mut done_at: Option<Instant> = None;
1127
1128    loop {
1129        while let Ok(msg) = rx.try_recv() {
1130            app.apply(msg);
1131        }
1132        if app.done && done_at.is_none() {
1133            done_at = Some(Instant::now());
1134        }
1135        if let Some(t) = done_at {
1136            if t.elapsed() > Duration::from_millis(500)
1137                && !app.aborted
1138                && app.prompting_idx.is_none()
1139            {
1140                break;
1141            }
1142        }
1143        app.spinner_tick = app.spinner_tick.wrapping_add(1);
1144        terminal.draw(|f| draw(f, &app))?;
1145
1146        if event::poll(Duration::from_millis(50))? {
1147            if let Event::Key(key) = event::read()? {
1148                if key.kind != KeyEventKind::Press {
1149                    continue;
1150                }
1151                if app.prompting_idx.is_some() {
1152                    if app.entering_message {
1153                        match key.code {
1154                            KeyCode::Char(c) => app.changeset_message.push(c),
1155                            KeyCode::Backspace => {
1156                                app.changeset_message.pop();
1157                            }
1158                            KeyCode::Enter => app.entering_message = false,
1159                            KeyCode::Esc => {
1160                                app.entering_message = false;
1161                                app.changeset_message.clear();
1162                            }
1163                            _ => {}
1164                        }
1165                    } else {
1166                        match key.code {
1167                            KeyCode::Left => {
1168                                app.changeset_selector = app.changeset_selector.saturating_sub(1);
1169                            }
1170                            KeyCode::Right => {
1171                                if app.changeset_selector < 3 {
1172                                    app.changeset_selector += 1;
1173                                }
1174                            }
1175                            KeyCode::Char('m') => {
1176                                if app.changeset_selector > 0 {
1177                                    app.entering_message = true;
1178                                }
1179                            }
1180                            KeyCode::Enter => {
1181                                let resp = match app.changeset_selector {
1182                                    1 => ChangesetResponse::Patch,
1183                                    2 => ChangesetResponse::Minor,
1184                                    3 => ChangesetResponse::Major,
1185                                    _ => ChangesetResponse::None,
1186                                };
1187                                if app.changeset_selector > 0 {
1188                                    let bump = match resp {
1189                                        ChangesetResponse::Patch => "patch",
1190                                        ChangesetResponse::Minor => "minor",
1191                                        _ => "major",
1192                                    };
1193                                    let _ = Command::new(self_exe())
1194                                        .args([
1195                                            "version",
1196                                            "add",
1197                                            "--bump",
1198                                            bump,
1199                                            "--message",
1200                                            &app.changeset_message,
1201                                        ])
1202                                        .current_dir(&root)
1203                                        .status();
1204                                }
1205                                let _ = res_tx.send(resp);
1206                            }
1207                            KeyCode::Esc => {
1208                                app.aborted = true;
1209                                break;
1210                            }
1211                            _ => {}
1212                        }
1213                    }
1214                    continue;
1215                }
1216                match key.code {
1217                    KeyCode::Char('q') | KeyCode::Esc => {
1218                        app.aborted = true;
1219                        break;
1220                    }
1221                    KeyCode::Enter if app.done => break,
1222                    KeyCode::Up => app.scroll_offset = app.scroll_offset.saturating_sub(1),
1223                    KeyCode::Down => app.scroll_offset = app.scroll_offset.saturating_add(1),
1224                    _ => {}
1225                }
1226            }
1227        }
1228    }
1229
1230    disable_raw_mode()?;
1231    io::stderr().execute(LeaveAlternateScreen)?;
1232    if app.aborted {
1233        bail!("pre-commit aborted");
1234    }
1235    let (_, fail, _, _) = app.counts();
1236    if fail > 0 {
1237        bail!("pre-commit checks failed");
1238    }
1239    Ok(())
1240}
1241
1242fn run_plain(root: &Path, skip_audit: bool, skip_format: bool, max_file_size: u64) -> Result<()> {
1243    eprintln!("🔍 ResQ Pre-commit Checks\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1244    let mut fail = false;
1245    macro_rules! run {
1246        ($name:expr, $fn:expr) => {{
1247            eprint!("  {}...", $name);
1248            let res = $fn;
1249            eprintln!("\r  {} {}", res.status.icon(), $name);
1250            if res.status == StepStatus::Fail {
1251                fail = true;
1252            }
1253        }};
1254    }
1255    run!("Copyright", step_copyright(root));
1256    run!("Large Files", step_large_files(max_file_size));
1257    run!("Debug Stmts", step_debug_statements());
1258    run!("Secrets", step_secrets());
1259    if !skip_audit {
1260        run!("Audit", step_audit(root, &mpsc::channel().0, 0));
1261    }
1262    if !skip_format {
1263        run!("Format Rust", step_format_rust(root));
1264        run!("Format TS", step_format_ts(root));
1265        run!("Format Python", step_format_python(root));
1266        run!("Format C++", step_format_cpp(root));
1267        run!("Format C#", step_format_csharp(root));
1268    }
1269    if fail {
1270        bail!("checks failed");
1271    }
1272    Ok(())
1273}