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
474#[allow(dead_code)]
475fn has_cmd(cmd: &str) -> bool {
476    Command::new("which")
477        .arg(cmd)
478        .stdout(Stdio::null())
479        .stderr(Stdio::null())
480        .status()
481        .map(|s| s.success())
482        .unwrap_or(false)
483}
484
485fn self_exe() -> PathBuf {
486    std::env::current_exe().unwrap_or_else(|_| PathBuf::from("resq"))
487}
488
489struct StepResult {
490    status: StepStatus,
491    detail: Option<String>,
492    sub_lines: Vec<String>,
493}
494
495// ── Step Implementations ─────────────────────────────────────────────────────
496
497fn step_copyright(root: &Path) -> StepResult {
498    let exe = self_exe();
499    let ok = Command::new(&exe)
500        .arg("copyright")
501        .current_dir(root)
502        .stdout(Stdio::null())
503        .stderr(Stdio::null())
504        .status()
505        .map(|s| s.success())
506        .unwrap_or(false);
507    if !ok {
508        return StepResult {
509            status: StepStatus::Fail,
510            detail: Some("write failed".into()),
511            sub_lines: vec![],
512        };
513    }
514    let _ = Command::new("git")
515        .args(["add", "-u"])
516        .current_dir(root)
517        .status();
518    let ok = Command::new(&exe)
519        .args(["copyright", "--check"])
520        .current_dir(root)
521        .stdout(Stdio::null())
522        .stderr(Stdio::null())
523        .status()
524        .map(|s| s.success())
525        .unwrap_or(false);
526    StepResult {
527        status: if ok {
528            StepStatus::Pass
529        } else {
530            StepStatus::Fail
531        },
532        detail: if ok {
533            None
534        } else {
535            Some("headers missing".into())
536        },
537        sub_lines: vec![],
538    }
539}
540
541fn step_large_files(max_size: u64) -> StepResult {
542    let output = Command::new("git")
543        .args(["diff", "--cached", "--name-only", "--diff-filter=ACM"])
544        .output();
545    let Ok(output) = output else {
546        return StepResult {
547            status: StepStatus::Skip,
548            detail: None,
549            sub_lines: vec![],
550        };
551    };
552    let files = String::from_utf8_lossy(&output.stdout);
553    let mut large = Vec::new();
554    for f in files.lines() {
555        if let Ok(m) = std::fs::metadata(f) {
556            if m.len() > max_size {
557                large.push(format!("{f} ({:.1} MiB)", m.len() as f64 / 1048576.0));
558            }
559        }
560    }
561    if large.is_empty() {
562        StepResult {
563            status: StepStatus::Pass,
564            detail: None,
565            sub_lines: vec![],
566        }
567    } else {
568        StepResult {
569            status: StepStatus::Fail,
570            detail: Some(format!("{} files too large", large.len())),
571            sub_lines: large,
572        }
573    }
574}
575
576fn step_debug_statements() -> StepResult {
577    let files = staged_files(&[".rs", ".ts", ".tsx", ".js", ".jsx", ".py"]);
578    let mut warnings = Vec::new();
579    for file in &files {
580        let out = Command::new("git")
581            .args(["diff", "--cached", "--", file])
582            .output();
583        let Ok(out) = out else { continue };
584        let diff = String::from_utf8_lossy(&out.stdout);
585        let patterns = if file.ends_with(".py") {
586            vec!["print(", "breakpoint(", "import pdb"]
587        } else {
588            vec!["console.log", "dbg!", "debugger;"]
589        };
590        for line in diff
591            .lines()
592            .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
593        {
594            if patterns.iter().any(|p| line.contains(p)) {
595                warnings.push(file.clone());
596                break;
597            }
598        }
599    }
600    if warnings.is_empty() {
601        StepResult {
602            status: StepStatus::Pass,
603            detail: None,
604            sub_lines: vec![],
605        }
606    } else {
607        StepResult {
608            status: StepStatus::Warn,
609            detail: Some(format!("{} debug stmts", warnings.len())),
610            sub_lines: warnings,
611        }
612    }
613}
614
615fn step_secrets() -> StepResult {
616    let exe = self_exe();
617    let ok = Command::new(&exe)
618        .args(["secrets", "--staged"])
619        .stdout(Stdio::null())
620        .stderr(Stdio::null())
621        .status()
622        .map(|s| s.success())
623        .unwrap_or(false);
624    StepResult {
625        status: if ok {
626            StepStatus::Pass
627        } else {
628            StepStatus::Fail
629        },
630        detail: if ok {
631            None
632        } else {
633            Some("secrets detected".into())
634        },
635        sub_lines: vec![],
636    }
637}
638
639fn step_audit(root: &Path, tx: &mpsc::Sender<StepMsg>, idx: usize) -> StepResult {
640    let exe = self_exe();
641    let mut args = vec!["audit", "--skip-react"];
642    if staged_files(&["package.json", "bun.lockb", "bun.lock"]).is_empty() {
643        args.push("--skip-npm");
644    }
645    let child = Command::new(&exe)
646        .args(&args)
647        .current_dir(root)
648        .stdin(Stdio::null())
649        .stdout(Stdio::piped())
650        .stderr(Stdio::piped())
651        .spawn();
652    let Ok(child) = child else {
653        return StepResult {
654            status: StepStatus::Fail,
655            detail: Some("spawn failed".into()),
656            sub_lines: vec![],
657        };
658    };
659    let output = child.wait_with_output();
660    let Ok(output) = output else {
661        return StepResult {
662            status: StepStatus::Fail,
663            detail: Some("exec failed".into()),
664            sub_lines: vec![],
665        };
666    };
667    let combined = format!(
668        "{}{}",
669        String::from_utf8_lossy(&output.stdout),
670        String::from_utf8_lossy(&output.stderr)
671    );
672    let interesting: Vec<String> = combined
673        .lines()
674        .filter(|l| l.contains("✅") || l.contains("❌") || l.contains("Vulnerabilities"))
675        .map(|l| l.trim().to_string())
676        .take(5)
677        .collect();
678    for line in &interesting {
679        let _ = tx.send(StepMsg::Output(idx, line.clone()));
680    }
681    StepResult {
682        status: if output.status.success() {
683            StepStatus::Pass
684        } else {
685            StepStatus::Fail
686        },
687        detail: None,
688        sub_lines: interesting,
689    }
690}
691
692// Per-language format steps delegate their implementations to
693// `commands::format`, so both `resq pre-commit` and `resq format` stay in
694// sync. This wrapper captures the staged file list, calls the shared
695// function, and restages any rewrites.
696fn run_format_step<F>(root: &Path, exts: &[&str], formatter: F) -> StepResult
697where
698    F: FnOnce(&Path, &[String], bool) -> anyhow::Result<crate::commands::format::FormatOutcome>,
699{
700    let files = staged_files(exts);
701    if files.is_empty() {
702        return StepResult {
703            status: StepStatus::Skip,
704            detail: Some("no files".into()),
705            sub_lines: vec![],
706        };
707    }
708    match formatter(root, &files, false) {
709        Ok(crate::commands::format::FormatOutcome::Clean)
710        | Ok(crate::commands::format::FormatOutcome::Formatted) => {
711            restage(&files);
712            StepResult {
713                status: StepStatus::Pass,
714                detail: None,
715                sub_lines: vec![],
716            }
717        }
718        Ok(crate::commands::format::FormatOutcome::Skipped(reason)) => StepResult {
719            status: StepStatus::Skip,
720            detail: Some(reason),
721            sub_lines: vec![],
722        },
723        Ok(crate::commands::format::FormatOutcome::Failed(stderr)) => {
724            if !stderr.trim().is_empty() {
725                eprintln!("{stderr}");
726            }
727            StepResult {
728                status: StepStatus::Fail,
729                detail: None,
730                sub_lines: vec![],
731            }
732        }
733        Err(e) => StepResult {
734            status: StepStatus::Fail,
735            detail: Some(e.to_string()),
736            sub_lines: vec![],
737        },
738    }
739}
740
741fn step_format_rust(root: &Path) -> StepResult {
742    run_format_step(root, &[".rs"], crate::commands::format::format_rust)
743}
744
745fn step_format_ts(root: &Path) -> StepResult {
746    run_format_step(
747        root,
748        &[".ts", ".tsx", ".js", ".jsx", ".json", ".css"],
749        crate::commands::format::format_ts,
750    )
751}
752
753fn step_format_python(root: &Path) -> StepResult {
754    run_format_step(root, &[".py"], crate::commands::format::format_python)
755}
756
757fn step_format_cpp(root: &Path) -> StepResult {
758    run_format_step(
759        root,
760        &[".cpp", ".cc", ".h", ".hpp"],
761        crate::commands::format::format_cpp,
762    )
763}
764
765fn step_format_csharp(root: &Path) -> StepResult {
766    run_format_step(root, &[".cs"], crate::commands::format::format_csharp)
767}
768
769// ── Worker & Main ────────────────────────────────────────────────────────────
770
771fn build_step_list(skip_audit: bool, skip_format: bool, skip_versioning: bool) -> Vec<StepState> {
772    let mut steps = vec![
773        StepState {
774            name: "Copyright headers".into(),
775            emoji: "📝".into(),
776            status: StepStatus::Pending,
777            detail: None,
778            sub_lines: vec![],
779            elapsed: None,
780            is_formatter: false,
781            is_versioning: false,
782        },
783        StepState {
784            name: "Large file check".into(),
785            emoji: "📦".into(),
786            status: StepStatus::Pending,
787            detail: None,
788            sub_lines: vec![],
789            elapsed: None,
790            is_formatter: false,
791            is_versioning: false,
792        },
793        StepState {
794            name: "Debug statements".into(),
795            emoji: "🐛".into(),
796            status: StepStatus::Pending,
797            detail: None,
798            sub_lines: vec![],
799            elapsed: None,
800            is_formatter: false,
801            is_versioning: false,
802        },
803        StepState {
804            name: "Secrets scan".into(),
805            emoji: "🔐".into(),
806            status: StepStatus::Pending,
807            detail: None,
808            sub_lines: vec![],
809            elapsed: None,
810            is_formatter: false,
811            is_versioning: false,
812        },
813        StepState {
814            name: "Security audit".into(),
815            emoji: "🔒".into(),
816            status: if skip_audit {
817                StepStatus::Skip
818            } else {
819                StepStatus::Pending
820            },
821            detail: None,
822            sub_lines: vec![],
823            elapsed: None,
824            is_formatter: false,
825            is_versioning: false,
826        },
827    ];
828    if !skip_format {
829        steps.extend(vec![
830            StepState {
831                name: "Format Rust".into(),
832                emoji: "🦀".into(),
833                status: StepStatus::Pending,
834                detail: None,
835                sub_lines: vec![],
836                elapsed: None,
837                is_formatter: true,
838                is_versioning: false,
839            },
840            StepState {
841                name: "Format TS/JS".into(),
842                emoji: "🎨".into(),
843                status: StepStatus::Pending,
844                detail: None,
845                sub_lines: vec![],
846                elapsed: None,
847                is_formatter: true,
848                is_versioning: false,
849            },
850            StepState {
851                name: "Format Python".into(),
852                emoji: "🐍".into(),
853                status: StepStatus::Pending,
854                detail: None,
855                sub_lines: vec![],
856                elapsed: None,
857                is_formatter: true,
858                is_versioning: false,
859            },
860            StepState {
861                name: "Format C++".into(),
862                emoji: "⚙️ ".into(),
863                status: StepStatus::Pending,
864                detail: None,
865                sub_lines: vec![],
866                elapsed: None,
867                is_formatter: true,
868                is_versioning: false,
869            },
870            StepState {
871                name: "Format C#".into(),
872                emoji: "🔷".into(),
873                status: StepStatus::Pending,
874                detail: None,
875                sub_lines: vec![],
876                elapsed: None,
877                is_formatter: true,
878                is_versioning: false,
879            },
880        ]);
881    }
882    if !skip_versioning {
883        steps.push(StepState {
884            name: "Versioning / Changeset".into(),
885            emoji: "🏷️".into(),
886            status: StepStatus::Pending,
887            detail: None,
888            sub_lines: vec![],
889            elapsed: None,
890            is_formatter: false,
891            is_versioning: true,
892        });
893    }
894    steps
895}
896
897fn run_worker(
898    tx: mpsc::Sender<StepMsg>,
899    res_rx: mpsc::Receiver<ChangesetResponse>,
900    root: PathBuf,
901    skip_audit: bool,
902    skip_format: bool,
903    skip_versioning: bool,
904    max_file_size: u64,
905) {
906    let mut idx = 0;
907    macro_rules! run {
908        ($step_fn:expr) => {{
909            let _ = tx.send(StepMsg::Started(idx));
910            let start = Instant::now();
911            let res = $step_fn;
912            let _ = tx.send(StepMsg::Finished(
913                idx,
914                res.status,
915                res.detail,
916                res.sub_lines,
917                start.elapsed(),
918            ));
919            idx += 1;
920        }};
921    }
922
923    run!(step_copyright(&root));
924    run!(step_large_files(max_file_size));
925    run!(step_debug_statements());
926    run!(step_secrets());
927    if skip_audit {
928        idx += 1;
929    } else {
930        run!(step_audit(&root, &tx, idx));
931    }
932
933    if !skip_format {
934        run!(step_format_rust(&root));
935        run!(step_format_ts(&root));
936        run!(step_format_python(&root));
937        run!(step_format_cpp(&root));
938        run!(step_format_csharp(&root));
939    }
940
941    if !skip_versioning {
942        let _ = tx.send(StepMsg::PromptChangeset(idx));
943        if let Ok(resp) = res_rx.recv() {
944            let (status, detail) = match resp {
945                ChangesetResponse::None => (StepStatus::Skip, Some("no bump".into())),
946                _ => (StepStatus::Pass, Some("bump recorded".into())),
947            };
948            let _ = tx.send(StepMsg::Finished(
949                idx,
950                status,
951                detail,
952                vec![],
953                Duration::ZERO,
954            ));
955        }
956    }
957    let _ = tx.send(StepMsg::AllDone);
958}
959
960/// Main entry point for the pre-commit command.
961///
962/// Runs verification checks and formatters with a TUI progress dashboard.
963pub async fn run(args: PreCommitArgs) -> Result<()> {
964    let root = if args.root == Path::new(".") {
965        crate::utils::find_project_root()
966    } else {
967        args.root.clone()
968    };
969    if args.no_tui
970        || !crossterm::tty::IsTty::is_tty(&io::stderr())
971        || std::env::var_os("GIT_INDEX_FILE").is_some()
972    {
973        return run_plain(&root, args.skip_audit, args.skip_format, args.max_file_size);
974    }
975
976    let steps = build_step_list(args.skip_audit, args.skip_format, args.skip_versioning);
977    let mut app = App::new(steps);
978    let (tx, rx) = mpsc::channel();
979    let (res_tx, res_rx) = mpsc::channel();
980    let worker_root = root.clone();
981
982    std::thread::spawn(move || {
983        run_worker(
984            tx,
985            res_rx,
986            worker_root,
987            args.skip_audit,
988            args.skip_format,
989            args.skip_versioning,
990            args.max_file_size,
991        );
992    });
993
994    enable_raw_mode()?;
995    io::stderr().execute(EnterAlternateScreen)?;
996    let mut terminal =
997        ratatui::Terminal::new(ratatui::backend::CrosstermBackend::new(io::stderr()))?;
998    let mut done_at: Option<Instant> = None;
999
1000    loop {
1001        while let Ok(msg) = rx.try_recv() {
1002            app.apply(msg);
1003        }
1004        if app.done && done_at.is_none() {
1005            done_at = Some(Instant::now());
1006        }
1007        if let Some(t) = done_at {
1008            if t.elapsed() > Duration::from_millis(500)
1009                && !app.aborted
1010                && app.prompting_idx.is_none()
1011            {
1012                break;
1013            }
1014        }
1015        app.spinner_tick = app.spinner_tick.wrapping_add(1);
1016        terminal.draw(|f| draw(f, &app))?;
1017
1018        if event::poll(Duration::from_millis(50))? {
1019            if let Event::Key(key) = event::read()? {
1020                if key.kind != KeyEventKind::Press {
1021                    continue;
1022                }
1023                if app.prompting_idx.is_some() {
1024                    if app.entering_message {
1025                        match key.code {
1026                            KeyCode::Char(c) => app.changeset_message.push(c),
1027                            KeyCode::Backspace => {
1028                                app.changeset_message.pop();
1029                            }
1030                            KeyCode::Enter => app.entering_message = false,
1031                            KeyCode::Esc => {
1032                                app.entering_message = false;
1033                                app.changeset_message.clear();
1034                            }
1035                            _ => {}
1036                        }
1037                    } else {
1038                        match key.code {
1039                            KeyCode::Left => {
1040                                app.changeset_selector = app.changeset_selector.saturating_sub(1);
1041                            }
1042                            KeyCode::Right if app.changeset_selector < 3 => {
1043                                app.changeset_selector += 1;
1044                            }
1045                            KeyCode::Char('m') if app.changeset_selector > 0 => {
1046                                app.entering_message = true;
1047                            }
1048                            KeyCode::Enter => {
1049                                let resp = match app.changeset_selector {
1050                                    1 => ChangesetResponse::Patch,
1051                                    2 => ChangesetResponse::Minor,
1052                                    3 => ChangesetResponse::Major,
1053                                    _ => ChangesetResponse::None,
1054                                };
1055                                if app.changeset_selector > 0 {
1056                                    let bump = match resp {
1057                                        ChangesetResponse::Patch => "patch",
1058                                        ChangesetResponse::Minor => "minor",
1059                                        _ => "major",
1060                                    };
1061                                    let _ = Command::new(self_exe())
1062                                        .args([
1063                                            "version",
1064                                            "add",
1065                                            "--bump",
1066                                            bump,
1067                                            "--message",
1068                                            &app.changeset_message,
1069                                        ])
1070                                        .current_dir(&root)
1071                                        .status();
1072                                }
1073                                let _ = res_tx.send(resp);
1074                            }
1075                            KeyCode::Esc => {
1076                                app.aborted = true;
1077                                break;
1078                            }
1079                            _ => {}
1080                        }
1081                    }
1082                    continue;
1083                }
1084                match key.code {
1085                    KeyCode::Char('q') | KeyCode::Esc => {
1086                        app.aborted = true;
1087                        break;
1088                    }
1089                    KeyCode::Enter if app.done => break,
1090                    KeyCode::Up => app.scroll_offset = app.scroll_offset.saturating_sub(1),
1091                    KeyCode::Down => app.scroll_offset = app.scroll_offset.saturating_add(1),
1092                    _ => {}
1093                }
1094            }
1095        }
1096    }
1097
1098    disable_raw_mode()?;
1099    io::stderr().execute(LeaveAlternateScreen)?;
1100    if app.aborted {
1101        bail!("pre-commit aborted");
1102    }
1103    let (_, fail, _, _) = app.counts();
1104    if fail > 0 {
1105        bail!("pre-commit checks failed");
1106    }
1107    Ok(())
1108}
1109
1110fn run_plain(root: &Path, skip_audit: bool, skip_format: bool, max_file_size: u64) -> Result<()> {
1111    eprintln!("🔍 ResQ Pre-commit Checks\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1112    let mut fail = false;
1113    macro_rules! run {
1114        ($name:expr, $fn:expr) => {{
1115            eprint!("  {}...", $name);
1116            let res = $fn;
1117            eprintln!("\r  {} {}", res.status.icon(), $name);
1118            if res.status == StepStatus::Fail {
1119                fail = true;
1120            }
1121        }};
1122    }
1123    run!("Copyright", step_copyright(root));
1124    run!("Large Files", step_large_files(max_file_size));
1125    run!("Debug Stmts", step_debug_statements());
1126    run!("Secrets", step_secrets());
1127    if !skip_audit {
1128        run!("Audit", step_audit(root, &mpsc::channel().0, 0));
1129    }
1130    if !skip_format {
1131        run!("Format Rust", step_format_rust(root));
1132        run!("Format TS", step_format_ts(root));
1133        run!("Format Python", step_format_python(root));
1134        run!("Format C++", step_format_cpp(root));
1135        run!("Format C#", step_format_csharp(root));
1136    }
1137    if fail {
1138        bail!("checks failed");
1139    }
1140    Ok(())
1141}