Skip to main content

linthis/utils/
output.rs

1// Copyright 2024 zhlinh and linthis Project Authors. All rights reserved.
2// Use of this source code is governed by a MIT-style
3// license that can be found at
4//
5// https://opensource.org/license/MIT
6//
7// The above copyright notice and this permission
8// notice shall be included in all copies or
9// substantial portions of the Software.
10
11//! Output formatting utilities for linthis results.
12//!
13//! The `fix_tip_lines()` function provides a single source of truth for
14//! the "Tip: To review and fix issues" hints used in both terminal output
15//! and hook output boxes.
16
17use crate::utils::types::{LintIssue, RunResult, Severity};
18use colored::Colorize;
19use crossterm::terminal;
20use std::process::{Command, Stdio};
21
22/// Tip lines for "To review and fix issues" hints.
23/// Each entry is (command, description). Used by both terminal and hook output.
24pub fn fix_tip_lines() -> Vec<(&'static str, &'static str)> {
25    vec![
26        ("linthis report show", "view full details"),
27        ("linthis report show -f html --open", "view in browser"),
28        (
29            "linthis backup diff",
30            "show diff from last format/fix/hook-fix",
31        ),
32        ("linthis backup undo", "undo last format/fix/hook-fix"),
33        ("linthis fix", "load last result and fix"),
34        (
35            "linthis fix --ai",
36            "AI-powered fix suggestions (--provider and --model supported)",
37        ),
38        (
39            "linthis fix --auto",
40            "(dangerously) auto-fix via AI agent (--provider and --model supported)",
41        ),
42    ]
43}
44
45/// Get the terminal width, with fallback to 80 columns.
46pub fn get_terminal_width() -> usize {
47    terminal::size().map(|(w, _)| w as usize).unwrap_or(80)
48}
49
50/// Detect the first available AI CLI provider (claude-cli or codebuddy-cli).
51/// Returns the provider name if found, or None if neither is available.
52pub fn detect_available_cli_provider() -> Option<&'static str> {
53    // Check for claude CLI first
54    if Command::new("claude")
55        .arg("--version")
56        .stdout(Stdio::null())
57        .stderr(Stdio::null())
58        .status()
59        .map(|s| s.success())
60        .unwrap_or(false)
61    {
62        return Some("claude-cli");
63    }
64
65    // Check for codebuddy CLI
66    if Command::new("codebuddy")
67        .arg("--version")
68        .stdout(Stdio::null())
69        .stderr(Stdio::null())
70        .status()
71        .map(|s| s.success())
72        .unwrap_or(false)
73    {
74        return Some("codebuddy-cli");
75    }
76
77    None
78}
79
80/// Output format enum
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum OutputFormat {
83    Human,
84    Json,
85    GithubActions,
86    Hook,
87}
88
89impl OutputFormat {
90    pub fn parse(s: &str) -> Option<Self> {
91        match s.to_lowercase().as_str() {
92            "human" => Some(OutputFormat::Human),
93            "json" => Some(OutputFormat::Json),
94            "github-actions" | "github" | "ga" => Some(OutputFormat::GithubActions),
95            "hook" => Some(OutputFormat::Hook),
96            _ => None,
97        }
98    }
99}
100
101/// Format a single lint issue for human-readable output.
102pub fn format_issue_human(issue: &LintIssue) -> String {
103    let severity_str = match issue.severity {
104        Severity::Error => "error".red().bold(),
105        Severity::Warning => "warning".yellow().bold(),
106        Severity::Info => "info".blue().bold(),
107    };
108
109    let location = if let Some(col) = issue.column {
110        format!("{}:{}:{}", issue.file_path.display(), issue.line, col)
111    } else {
112        format!("{}:{}", issue.file_path.display(), issue.line)
113    };
114
115    let code_str = issue
116        .code
117        .as_ref()
118        .map(|c| format!(" ({})", c))
119        .unwrap_or_default();
120
121    let mut output = format!(
122        "{}: {}: {}{}",
123        location.bold(),
124        severity_str,
125        issue.message,
126        code_str
127    );
128
129    // Show context and source code lines if available
130    if let Some(code_line) = &issue.code_line {
131        // Calculate line number width based on max line number (context_after last line or issue line)
132        let max_line = if !issue.context_after.is_empty() {
133            issue
134                .context_after
135                .last()
136                .map(|(n, _)| *n)
137                .unwrap_or(issue.line)
138        } else {
139            issue.line
140        };
141        let line_width = max_line.to_string().len().max(5);
142
143        // Show context before (dimmed)
144        for (line_num, content) in &issue.context_before {
145            let num_str = format!("{:>width$}", line_num, width = line_width);
146            output.push_str(&format!("\n  {} | {}", num_str.dimmed(), content.dimmed()));
147        }
148
149        // Show the issue line (highlighted with >)
150        let line_num = format!("{:>width$}", issue.line, width = line_width);
151        output.push_str(&format!(
152            "\n{} {} | {}",
153            ">".red().bold(),
154            line_num.cyan().bold(),
155            code_line
156        ));
157
158        // Show column indicator if available
159        if let Some(col) = issue.column {
160            let spaces = " ".repeat(line_width + 5 + col.saturating_sub(1));
161            output.push_str(&format!("\n{}^", spaces.red()));
162        }
163
164        // Show context after (dimmed)
165        for (line_num, content) in &issue.context_after {
166            let num_str = format!("{:>width$}", line_num, width = line_width);
167            output.push_str(&format!("\n  {} | {}", num_str.dimmed(), content.dimmed()));
168        }
169    }
170
171    if let Some(suggestion) = &issue.suggestion {
172        output.push_str(&format!("\n  --> {}", suggestion.cyan()));
173    }
174
175    output
176}
177
178/// Format a single lint issue for GitHub Actions output.
179pub fn format_issue_github_actions(issue: &LintIssue) -> String {
180    let severity = match issue.severity {
181        Severity::Error => "error",
182        Severity::Warning => "warning",
183        Severity::Info => "notice",
184    };
185
186    let col_str = issue
187        .column
188        .map(|c| format!(",col={}", c))
189        .unwrap_or_default();
190
191    let code_str = issue
192        .code
193        .as_ref()
194        .map(|c| format!(" ({})", c))
195        .unwrap_or_default();
196
197    format!(
198        "::{} file={},line={}{}::{}{}",
199        severity,
200        issue.file_path.display(),
201        issue.line,
202        col_str,
203        issue.message,
204        code_str
205    )
206}
207
208/// Format a duration in milliseconds as a human-readable string.
209fn format_duration_str(duration_ms: u64) -> String {
210    if duration_ms >= 1000 {
211        format!("{:.2}s", duration_ms as f64 / 1000.0)
212    } else {
213        format!("{}ms", duration_ms)
214    }
215}
216
217/// Get the "all passed" message based on run mode.
218fn all_passed_message(run_mode: &crate::utils::types::RunModeKind) -> &'static str {
219    use crate::utils::types::RunModeKind;
220    match run_mode {
221        RunModeKind::FormatOnly => "All formats passed",
222        RunModeKind::CheckOnly => "All checks passed",
223        RunModeKind::Both => "All checks and formats passed",
224    }
225}
226
227/// Format file stats string like " (0 errors, 0 warnings in N file(s))".
228fn format_file_stats(total_files: usize) -> String {
229    format!(
230        " (0 errors, 0 warnings in {} file{})",
231        total_files,
232        if total_files == 1 { "" } else { "s" },
233    )
234}
235
236/// Format the no-issues summary case.
237fn format_summary_no_issues(result: &RunResult) -> String {
238    let msg = all_passed_message(&result.run_mode);
239    let file_stats = format_file_stats(result.total_files);
240    let duration_str = format_duration_str(result.duration_ms);
241
242    format!(
243        "{} {}{}\nDone in {}",
244        "✓".green(),
245        msg.green().bold(),
246        file_stats,
247        duration_str.cyan()
248    )
249}
250
251/// Append formatting stats to the summary if files were formatted.
252fn append_formatting_stats(summary: &mut String, result: &RunResult) {
253    if result.files_formatted > 0 {
254        summary.push_str(&format!(
255            "{} Formatted {} file{}",
256            "✓".green(),
257            result.files_formatted,
258            if result.files_formatted == 1 { "" } else { "s" }
259        ));
260    }
261
262    if result.issues_fixed > 0 {
263        if !summary.is_empty() {
264            summary.push('\n');
265        }
266        summary.push_str(&format!(
267            "{} Fixed {} issue{} by formatting",
268            "✓".green(),
269            result.issues_fixed,
270            if result.issues_fixed == 1 { "" } else { "s" }
271        ));
272    }
273}
274
275/// Append remaining issues or all-passed message to the summary.
276fn append_issues_or_passed(
277    summary: &mut String,
278    result: &RunResult,
279    issue_count: usize,
280    error_count: usize,
281    warning_count: usize,
282) {
283    if issue_count > 0 {
284        if !summary.is_empty() {
285            summary.push('\n');
286        }
287        summary.push_str(&format!(
288            "{} {} remaining issue{} ({} error{}, {} warning{}) in {} of {} file{}",
289            "✗".red(),
290            issue_count,
291            if issue_count == 1 { "" } else { "s" },
292            error_count,
293            if error_count == 1 { "" } else { "s" },
294            warning_count,
295            if warning_count == 1 { "" } else { "s" },
296            result.files_with_issues,
297            result.total_files,
298            if result.total_files == 1 { "" } else { "s" }
299        ));
300    } else if result.files_formatted > 0 || result.issues_fixed > 0 {
301        if !summary.is_empty() {
302            summary.push('\n');
303        }
304        let msg = all_passed_message(&result.run_mode);
305        let file_stats = format_file_stats(result.total_files);
306        summary.push_str(&format!(
307            "{} {}{} (0 errors, 0 warnings)",
308            "✓".green(),
309            msg.green().bold(),
310            file_stats
311        ));
312    }
313}
314
315/// Format the run result summary for human-readable output.
316pub fn format_summary_human(result: &RunResult) -> String {
317    let issue_count = result.issues.len();
318    let error_count = result
319        .issues
320        .iter()
321        .filter(|i| i.severity == Severity::Error)
322        .count();
323    let warning_count = result
324        .issues
325        .iter()
326        .filter(|i| i.severity == Severity::Warning)
327        .count();
328
329    if issue_count == 0 && result.files_formatted == 0 && result.issues_fixed == 0 {
330        return format_summary_no_issues(result);
331    }
332
333    let mut summary = String::new();
334    append_formatting_stats(&mut summary, result);
335    append_issues_or_passed(
336        &mut summary,
337        result,
338        issue_count,
339        error_count,
340        warning_count,
341    );
342
343    if !summary.is_empty() {
344        summary.push('\n');
345    }
346    let duration_str = format_duration_str(result.duration_ms);
347    summary.push_str(&format!("Done in {}", duration_str.cyan()));
348
349    summary
350}
351
352/// Format the entire run result for human-readable output.
353pub fn format_result_human(result: &RunResult) -> String {
354    let mut output = String::new();
355
356    // Separate errors and warnings for numbered output
357    let errors: Vec<_> = result
358        .issues
359        .iter()
360        .filter(|i| i.severity == Severity::Error)
361        .collect();
362    let warnings: Vec<_> = result
363        .issues
364        .iter()
365        .filter(|i| i.severity == Severity::Warning)
366        .collect();
367
368    // Output errors with [E1][lang][tool], [E2][lang][tool], etc.
369    for (idx, issue) in errors.iter().enumerate() {
370        let lang_tag = issue
371            .language
372            .map(|l| format!("[{}]", l.name()))
373            .unwrap_or_default();
374        let tool_tag = issue
375            .source
376            .as_ref()
377            .map(|s| format!("[{}]", s))
378            .unwrap_or_default();
379        output.push_str(&format!(
380            "{}{}{} {}",
381            format!("[E{}]", idx + 1).red().bold(),
382            lang_tag.red(),
383            tool_tag.red(),
384            format_issue_human(issue)
385        ));
386        output.push('\n');
387    }
388
389    // Output warnings with [W1][lang][tool], [W2][lang][tool], etc.
390    for (idx, issue) in warnings.iter().enumerate() {
391        let lang_tag = issue
392            .language
393            .map(|l| format!("[{}]", l.name()))
394            .unwrap_or_default();
395        let tool_tag = issue
396            .source
397            .as_ref()
398            .map(|s| format!("[{}]", s))
399            .unwrap_or_default();
400        output.push_str(&format!(
401            "{}{}{} {}",
402            format!("[W{}]", idx + 1).yellow().bold(),
403            lang_tag.yellow(),
404            tool_tag.yellow(),
405            format_issue_human(issue)
406        ));
407        output.push('\n');
408    }
409
410    if !result.issues.is_empty() {
411        output.push('\n');
412    }
413
414    output.push_str(&format_summary_human(result));
415
416    // Show unavailable tools warning
417    if !result.unavailable_tools.is_empty() {
418        output.push_str("\n\n");
419        output.push_str(&format!(
420            "{} {} tool(s) not available:",
421            "⚠".yellow(),
422            result.unavailable_tools.len()
423        ));
424        for tool in &result.unavailable_tools {
425            let status = if tool.auto_install_failed {
426                "(auto-install failed)".red().to_string()
427            } else {
428                "(not installed)".yellow().to_string()
429            };
430            output.push_str(&format!(
431                "\n  {} {} ({}) {}",
432                "•".dimmed(),
433                tool.tool,
434                tool.language,
435                status
436            ));
437            output.push_str(&format!("\n    {}", tool.install_hint));
438            if tool.auto_install_failed {
439                output.push_str(&format!(
440                    "\n    {}",
441                    "Ensure pip/uv/brew/choco is in PATH, then retry or install manually.".dimmed()
442                ));
443            }
444        }
445        output.push_str(&format!(
446            "\n\n{}",
447            "Run 'linthis doctor' for detailed tool status.".dimmed()
448        ));
449    }
450
451    output
452}
453
454/// Unified JSON output structure for all checks.
455///
456/// Each check (lint, security, complexity) has the same shape:
457/// `{ total_files, total_issues, issues: [{ file, line, severity, message, source, ... }] }`
458#[derive(serde::Serialize)]
459struct UnifiedResult {
460    checks_run: Vec<String>,
461    #[serde(skip_serializing_if = "Option::is_none")]
462    lint: Option<CheckResultView>,
463    #[serde(skip_serializing_if = "Option::is_none")]
464    security: Option<CheckResultView>,
465    #[serde(skip_serializing_if = "Option::is_none")]
466    complexity: Option<CheckResultView>,
467    duration_ms: u64,
468    exit_code: i32,
469    run_mode: String,
470    #[serde(default, skip_serializing_if = "Vec::is_empty")]
471    unavailable_tools: Vec<UnavailableToolView>,
472    #[serde(default, skip_serializing_if = "Vec::is_empty")]
473    target_paths: Vec<String>,
474}
475
476/// Unified check result — same structure for lint, security, complexity.
477#[derive(serde::Serialize)]
478struct CheckResultView {
479    total_files: usize,
480    total_issues: usize,
481    issues: Vec<IssueView>,
482    /// Extra metadata specific to this check type
483    #[serde(skip_serializing_if = "Option::is_none")]
484    extra: Option<serde_json::Value>,
485}
486
487/// Unified issue — same fields for all check types.
488#[derive(serde::Serialize)]
489struct IssueView {
490    file: String,
491    line: usize,
492    #[serde(skip_serializing_if = "Option::is_none")]
493    column: Option<usize>,
494    #[serde(skip_serializing_if = "Option::is_none")]
495    end_line: Option<usize>,
496    severity: String,
497    message: String,
498    source: String,
499    #[serde(skip_serializing_if = "Option::is_none")]
500    code: Option<String>,
501    #[serde(skip_serializing_if = "Option::is_none")]
502    suggestion: Option<String>,
503    /// Function name (for complexity issues)
504    #[serde(skip_serializing_if = "Option::is_none")]
505    function: Option<String>,
506}
507
508#[derive(serde::Serialize)]
509struct UnavailableToolView {
510    tool: String,
511    language: String,
512    install_hint: String,
513}
514
515/// Build lint check result view from RunResult.
516fn build_lint_view(result: &RunResult) -> CheckResultView {
517    let issues: Vec<IssueView> = result
518        .issues
519        .iter()
520        .map(|i| IssueView {
521            file: i.file_path.to_string_lossy().to_string(),
522            line: i.line,
523            column: i.column,
524            end_line: None,
525            severity: i.severity.to_string(),
526            message: i.message.clone(),
527            source: i.source.clone().unwrap_or_default(),
528            code: i.code.clone(),
529            suggestion: i.suggestion.clone(),
530            function: None,
531        })
532        .collect();
533
534    let extra = serde_json::json!({
535        "files_formatted": result.files_formatted,
536        "issues_before_format": result.issues_before_format,
537        "issues_fixed": result.issues_fixed,
538    });
539
540    CheckResultView {
541        total_files: result.total_files,
542        total_issues: issues.len(),
543        issues,
544        extra: Some(extra),
545    }
546}
547
548/// Build security check result view from SastResult.
549fn build_security_view(sast: &crate::security::sast::SastResult) -> CheckResultView {
550    let issues: Vec<IssueView> = sast
551        .findings
552        .iter()
553        .map(|f| {
554            // Map security severity to unified error/warning/info
555            let unified_severity = match f.severity {
556                crate::security::Severity::Critical | crate::security::Severity::High => "error",
557                crate::security::Severity::Medium => "warning",
558                _ => "info",
559            };
560            IssueView {
561                file: f.file_path.to_string_lossy().to_string(),
562                line: f.line,
563                column: f.column,
564                end_line: f.end_line,
565                severity: unified_severity.to_string(),
566                message: f.message.clone(),
567                source: f.source.clone(),
568                code: Some(f.rule_id.clone()),
569                suggestion: f.fix_suggestion.clone(),
570                function: None,
571            }
572        })
573        .collect();
574
575    let total_files = {
576        let mut files: std::collections::HashSet<String> = std::collections::HashSet::new();
577        for f in &sast.findings {
578            files.insert(f.file_path.to_string_lossy().to_string());
579        }
580        files.len()
581    };
582
583    let extra = serde_json::json!({
584        "scanner_status": sast.scanner_status,
585        "by_severity": sast.by_severity,
586    });
587
588    CheckResultView {
589        total_files,
590        total_issues: issues.len(),
591        issues,
592        extra: Some(extra),
593    }
594}
595
596/// Build complexity check result view from AnalysisResult.
597///
598/// Converts per-function metrics into issues (one issue per function exceeding threshold).
599fn build_complexity_view(analysis: &crate::complexity::AnalysisResult) -> CheckResultView {
600    let threshold = if analysis.thresholds.cyclomatic.good > 0 {
601        analysis.thresholds.cyclomatic.good
602    } else {
603        10
604    };
605    let warning_threshold = analysis.thresholds.cyclomatic.warning;
606    let high_threshold = analysis.thresholds.cyclomatic.high;
607
608    let mut issues: Vec<IssueView> = Vec::new();
609
610    for file in &analysis.files {
611        for func in &file.functions {
612            if func.metrics.cyclomatic > threshold {
613                let severity = if func.metrics.cyclomatic > high_threshold {
614                    "error"
615                } else if func.metrics.cyclomatic > warning_threshold {
616                    "warning"
617                } else {
618                    "info"
619                };
620
621                let exceeded_threshold = match severity {
622                    "error" => high_threshold,
623                    "warning" => warning_threshold,
624                    _ => threshold,
625                };
626                issues.push(IssueView {
627                    file: file.path.to_string_lossy().to_string(),
628                    line: func.start_line as usize,
629                    column: None,
630                    end_line: Some(func.end_line as usize),
631                    severity: severity.to_string(),
632                    message: format!(
633                        "function `{}` cyclomatic complexity {} exceeds threshold {}",
634                        func.name, func.metrics.cyclomatic, exceeded_threshold,
635                    ),
636                    source: "linthis-complexity".to_string(),
637                    code: None,
638                    suggestion: Some("Consider refactoring into smaller functions".to_string()),
639                    function: Some(func.name.clone()),
640                });
641            }
642        }
643    }
644
645    let extra = serde_json::json!({
646        "summary": {
647            "total_functions": analysis.summary.total_functions,
648            "avg_cyclomatic": analysis.summary.avg_cyclomatic,
649            "max_cyclomatic": analysis.summary.max_cyclomatic,
650        },
651    });
652
653    CheckResultView {
654        total_files: analysis.files.len(),
655        total_issues: issues.len(),
656        issues,
657        extra: Some(extra),
658    }
659}
660
661/// Format the entire run result as unified JSON.
662pub fn format_result_json(result: &RunResult) -> String {
663    let lint_view =
664        if result.checks_run.contains(&"lint".to_string()) || result.checks_run.is_empty() {
665            Some(build_lint_view(result))
666        } else {
667            None
668        };
669
670    let security_view = result.security.as_ref().map(build_security_view);
671    let complexity_view = result.complexity.as_ref().map(build_complexity_view);
672
673    let unavailable: Vec<UnavailableToolView> = result
674        .unavailable_tools
675        .iter()
676        .map(|t| UnavailableToolView {
677            tool: t.tool.clone(),
678            language: t.language.clone(),
679            install_hint: t.install_hint.clone(),
680        })
681        .collect();
682
683    let unified = UnifiedResult {
684        checks_run: if result.checks_run.is_empty() {
685            vec!["lint".to_string()]
686        } else {
687            result.checks_run.clone()
688        },
689        lint: lint_view,
690        security: security_view,
691        complexity: complexity_view,
692        duration_ms: result.duration_ms,
693        exit_code: result.exit_code,
694        run_mode: format!("{:?}", result.run_mode).to_lowercase(),
695        unavailable_tools: unavailable,
696        target_paths: result.target_paths.clone(),
697    };
698    serde_json::to_string_pretty(&unified).unwrap_or_else(|_| "{}".to_string())
699}
700
701/// Format the entire run result for GitHub Actions.
702pub fn format_result_github_actions(result: &RunResult) -> String {
703    result
704        .issues
705        .iter()
706        .map(format_issue_github_actions)
707        .collect::<Vec<_>>()
708        .join("\n")
709}
710
711/// Format the entire run result for git hook output.
712/// Compact format with summary at top, error list, and fix instructions.
713///
714/// # Arguments
715/// * `result` - The run result to format
716/// * `hook_type` - Optional hook type ("pre-push", "commit-msg", or default "pre-commit")
717/// * `config_width` - Optional configured width (0 or None = auto-detect terminal width)
718pub fn format_result_hook(result: &RunResult, hook_type: Option<&str>) -> String {
719    format_result_hook_with_width(result, hook_type, None)
720}
721
722/// Build a footer showing the global and local git hook file paths.
723pub fn format_hook_paths_footer_pub(hook_type: Option<&str>) -> String {
724    format_hook_paths_footer(hook_type)
725}
726
727/// Extract `--type <value>` from a thin-wrapper hook script, if present.
728fn extract_hook_script_type(path: &std::path::Path) -> Option<String> {
729    let content = std::fs::read_to_string(path).ok()?;
730    // thin wrapper format: exec linthis hook run --event <e> --type <t> [...]
731    content
732        .split("--type ")
733        .nth(1)
734        .and_then(|s| s.split_whitespace().next())
735        .map(|s| s.to_string())
736}
737
738/// Extract `--provider <value>` from a thin-wrapper hook script, if present.
739/// The pattern `"--provider "` (trailing space) does not match `--provider-args`,
740/// so these two flags never collide.
741fn extract_hook_script_provider(path: &std::path::Path) -> Option<String> {
742    let content = std::fs::read_to_string(path).ok()?;
743    content
744        .split("--provider ")
745        .nth(1)
746        .and_then(|s| s.split_whitespace().next())
747        .map(|s| s.to_string())
748}
749
750/// Extract `--model <value>` from the `--provider-args '...'` section of a
751/// thin-wrapper hook script, if present. The value may carry a trailing `'`
752/// when `--model` is the last arg inside the single-quoted block.
753fn extract_hook_script_model(path: &std::path::Path) -> Option<String> {
754    let content = std::fs::read_to_string(path).ok()?;
755    content
756        .split("--model ")
757        .nth(1)
758        .and_then(|s| s.split_whitespace().next())
759        .map(|s| s.trim_end_matches('\'').to_string())
760        .filter(|s| !s.is_empty())
761}
762
763/// Get the fix_commit_mode suffix for hook footer display.
764/// Returns empty string for default mode (squash), otherwise ", --fix-commit-mode <mode>".
765fn fix_commit_mode_suffix(hook_type: Option<&str>) -> String {
766    let project_root = crate::utils::get_project_root();
767    let config = crate::config::Config::load_merged(&project_root);
768    let mode = match hook_type {
769        Some("pre-push") => config.hook.pre_push.fix_commit_mode.clone(),
770        _ => config.hook.pre_commit.fix_commit_mode.clone(),
771    };
772    format!("--fix-commit-mode {}", mode)
773}
774
775/// Collect hook detail flags as (key, value) pairs in display order:
776/// --type, --provider, --model, --fix-commit-mode.
777fn collect_hook_details(hook_path: &std::path::Path, mode_suffix: &str) -> Vec<(String, String)> {
778    let mut items = Vec::new();
779    if let Some(t) = extract_hook_script_type(hook_path) {
780        items.push(("--type".to_string(), t));
781    }
782    if let Some(p) = extract_hook_script_provider(hook_path) {
783        items.push(("--provider".to_string(), p));
784    }
785    if let Some(m) = extract_hook_script_model(hook_path) {
786        items.push(("--model".to_string(), m));
787    }
788    if let Some((k, v)) = mode_suffix.split_once(' ') {
789        if !v.is_empty() {
790            items.push((k.to_string(), v.to_string()));
791        }
792    }
793    items
794}
795
796/// Build indented, aligned, pre-dimmed lines like:
797/// ```text
798///     --type             git-with-agent
799///     --provider         claude
800///     --model            opus
801///     --fix-commit-mode  fixup
802/// ```
803fn build_hook_detail_lines(hook_path: &std::path::Path, mode_suffix: &str) -> Vec<String> {
804    let items = collect_hook_details(hook_path, mode_suffix);
805    if items.is_empty() {
806        return Vec::new();
807    }
808    let key_width = items.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
809    items
810        .into_iter()
811        .map(|(k, v)| {
812            format!("    {:<width$}  {}", k, v, width = key_width)
813                .dimmed()
814                .to_string()
815        })
816        .collect()
817}
818
819fn format_hook_paths_footer(hook_type: Option<&str>) -> String {
820    let hook_filename = match hook_type {
821        Some("pre-push") => "pre-push",
822        Some("commit-msg") => "commit-msg",
823        Some("post-commit") => "post-commit",
824        _ => "pre-commit",
825    };
826
827    let mode_suffix = fix_commit_mode_suffix(hook_type);
828
829    let mut lines = Vec::new();
830
831    // Global: check core.hooksPath
832    if let Some(p) = Command::new("git")
833        .args(["config", "--global", "core.hooksPath"])
834        .stdout(Stdio::piped())
835        .stderr(Stdio::null())
836        .output()
837        .ok()
838        .and_then(|out| {
839            if out.status.success() {
840                let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
841                if s.is_empty() {
842                    return None;
843                }
844                let p = std::path::PathBuf::from(s).join(hook_filename);
845                if p.exists() {
846                    Some(p)
847                } else {
848                    None
849                }
850            } else {
851                None
852            }
853        })
854    {
855        lines.push(format!("  Global: {}", p.display()).dimmed().to_string());
856        lines.extend(build_hook_detail_lines(&p, &mode_suffix));
857    }
858
859    // Local: check .git/hooks/{event}
860    if let Some(p) = Command::new("git")
861        .args(["rev-parse", "--git-dir"])
862        .stdout(Stdio::piped())
863        .stderr(Stdio::null())
864        .output()
865        .ok()
866        .and_then(|out| {
867            if out.status.success() {
868                let git_dir = String::from_utf8_lossy(&out.stdout).trim().to_string();
869                if git_dir.is_empty() {
870                    return None;
871                }
872                let p = std::path::PathBuf::from(git_dir)
873                    .join("hooks")
874                    .join(hook_filename);
875                if p.exists() {
876                    Some(p)
877                } else {
878                    None
879                }
880            } else {
881                None
882            }
883        })
884    {
885        lines.push(format!("  Local:  {}", p.display()).dimmed().to_string());
886        lines.extend(build_hook_detail_lines(&p, &mode_suffix));
887    }
888
889    if lines.is_empty() {
890        return String::new();
891    }
892    format!("\n{}", lines.join("\n"))
893}
894
895/// Box drawing context for hook output formatting.
896struct HookBoxContext {
897    content_width: usize,
898    top_border: String,
899    mid_border: String,
900    bot_border: String,
901}
902
903impl HookBoxContext {
904    fn new(config_width: Option<u32>) -> Self {
905        let box_width = match config_width {
906            Some(w) if w > 0 => (w as usize).clamp(50, 120),
907            _ => get_terminal_width().clamp(50, 120),
908        };
909        let content_width = box_width - 4;
910        Self {
911            content_width,
912            top_border: format!("╭{}╮", "─".repeat(box_width - 2)),
913            mid_border: format!("├{}┤", "─".repeat(box_width - 2)),
914            bot_border: format!("╰{}╯", "─".repeat(box_width - 2)),
915        }
916    }
917
918    fn pad_line(&self, content: &str, emoji_count: usize) -> String {
919        let visual_len = content.chars().count() + emoji_count;
920        let padding = self.content_width.saturating_sub(visual_len);
921        format!("│ {}{} │", content, " ".repeat(padding))
922    }
923
924    /// Like `pad_line`, but colours the `│` edges in the requested colour so
925    /// content rows visually continue the top/mid/bot border (red for
926    /// blocked, green for passed). The interior text is left in the default
927    /// terminal colour — upstream callers can still tint the whole line with
928    /// `.red()`/`.green()` when they want the content coloured too.
929    fn pad_line_bordered(
930        &self,
931        content: &str,
932        emoji_count: usize,
933        color: colored::Color,
934    ) -> String {
935        let visual_len = content.chars().count() + emoji_count;
936        let padding = self.content_width.saturating_sub(visual_len);
937        let bar = "│".color(color);
938        format!("{} {}{} {}", bar, content, " ".repeat(padding), bar)
939    }
940}
941
942/// Get the hook display name based on hook type.
943fn hook_display_name(hook_type: Option<&str>) -> &'static str {
944    match hook_type {
945        Some("pre-push") => "📤 [Pre-push]",
946        Some("commit-msg") => "📝 [Commit-msg]",
947        Some("post-commit") => "🔧 [Post-commit]",
948        _ => "🔍 [Pre-commit]",
949    }
950}
951
952/// Get the skip command based on hook type.
953fn hook_skip_command(hook_type: Option<&str>) -> &'static str {
954    match hook_type {
955        Some("pre-push") => "git push --no-verify",
956        _ => "git commit --no-verify",
957    }
958}
959
960/// Format the success box for hook output.
961fn format_hook_success_box(
962    result: &RunResult,
963    hook_type: Option<&str>,
964    ctx: &HookBoxContext,
965) -> String {
966    let hook_name = hook_display_name(hook_type);
967    let mut output = String::new();
968    output.push_str(&format!("{}\n", ctx.top_border.green()));
969    let header = format!("{} Linthis {} Passed", "✓", hook_name);
970    output.push_str(&format!("{}\n", ctx.pad_line(&header, 1).green()));
971    output.push_str(&format!("{}\n", ctx.mid_border.green()));
972    let checks_msg = if hook_type == Some("pre-push") {
973        "All reviews finish"
974    } else {
975        "All checks passed!"
976    };
977    output.push_str(&format!("{}\n", ctx.pad_line(checks_msg, 0).green()));
978    output.push_str(&format!(
979        "{}\n",
980        ctx.pad_line_bordered("", 0, colored::Color::Green)
981    ));
982    output.push_str(&format!(
983        "{}\n",
984        ctx.pad_line_bordered(
985            &format!("Files checked:   {:>3}", result.total_files),
986            0,
987            colored::Color::Green,
988        )
989    ));
990    output.push_str(&format!(
991        "{}\n",
992        ctx.pad_line_bordered(
993            &format!("Files formatted: {:>3}", result.files_formatted),
994            0,
995            colored::Color::Green,
996        )
997    ));
998
999    // Show formatted file list when files were actually formatted
1000    if result.files_formatted > 0 {
1001        for fr in &result.format_results {
1002            if !fr.changed {
1003                continue;
1004            }
1005            let filename = fr
1006                .file_path
1007                .file_name()
1008                .unwrap_or_default()
1009                .to_string_lossy();
1010            let lines_changed = fr
1011                .diff
1012                .as_ref()
1013                .map(|d| {
1014                    d.lines()
1015                        .filter(|l| l.starts_with('+') || l.starts_with('-'))
1016                        .count()
1017                })
1018                .unwrap_or(0);
1019            let detail = if lines_changed > 0 {
1020                format!("  {}  ({} lines)", filename, lines_changed)
1021            } else {
1022                format!("  {}", filename)
1023            };
1024            output.push_str(&format!(
1025                "{}\n",
1026                ctx.pad_line_bordered(&detail, 0, colored::Color::Green)
1027            ));
1028        }
1029        output.push_str(&format!(
1030            "{}\n",
1031            ctx.pad_line_bordered("", 0, colored::Color::Green)
1032        ));
1033        output.push_str(&format!(
1034            "{}\n",
1035            ctx.pad_line_bordered("Pre-change state saved in stash.", 0, colored::Color::Green,)
1036        ));
1037        output.push_str(&format!(
1038            "{}\n",
1039            ctx.pad_line_bordered(
1040                "  git stash show -p  \u{2014} review format changes",
1041                0,
1042                colored::Color::Green,
1043            )
1044        ));
1045        output.push_str(&format!(
1046            "{}\n",
1047            ctx.pad_line_bordered(
1048                "  git stash drop     \u{2014} discard snapshot",
1049                0,
1050                colored::Color::Green,
1051            )
1052        ));
1053    }
1054
1055    output.push_str(&format!("{}", ctx.bot_border.green()));
1056    output.push_str(&format_hook_paths_footer(hook_type));
1057    output
1058}
1059
1060/// Format a truncated issue line for box display.
1061fn format_hook_issue_line(issue: &LintIssue, content_width: usize) -> String {
1062    let filename = issue
1063        .file_path
1064        .file_name()
1065        .unwrap_or_default()
1066        .to_string_lossy();
1067    let location = format!("{}:{}", filename, issue.line);
1068    let severity_char = match issue.severity {
1069        Severity::Error => "E",
1070        Severity::Warning => "W",
1071        Severity::Info => "I",
1072    };
1073    let location_max = (content_width / 3).clamp(10, 35);
1074    let location_display = if location.len() > location_max {
1075        format!("{}...", &location[..location_max.saturating_sub(3)])
1076    } else {
1077        location
1078    };
1079    let msg_prefix_len = 4;
1080    let max_msg_len = content_width.saturating_sub(msg_prefix_len + location_display.len());
1081    let msg = if issue.message.len() > max_msg_len {
1082        format!("{}...", &issue.message[..max_msg_len.saturating_sub(3)])
1083    } else {
1084        issue.message.clone()
1085    };
1086    format!(" {} {} {}", severity_char, location_display, msg)
1087}
1088
1089/// Append the tip and skip hint sections for hook failure box.
1090/// Content rows use a red-tinted `│` so the side bars keep their blocked
1091/// colour even when a downstream viewer (IDE VCS console) tints uncoloured
1092/// text differently.
1093fn append_hook_tip_section(
1094    output: &mut String,
1095    result: &RunResult,
1096    hook_type: Option<&str>,
1097    ctx: &HookBoxContext,
1098) {
1099    let border = colored::Color::Red;
1100    output.push_str(&format!(
1101        "{}\n",
1102        ctx.pad_line_bordered("Tip: To review and fix issues:", 0, border)
1103    ));
1104    for (cmd, desc) in fix_tip_lines() {
1105        let line = format!("  {:<36} : {}", cmd, desc);
1106        output.push_str(&format!("{}\n", ctx.pad_line_bordered(&line, 0, border)));
1107    }
1108    output.push_str(&format!("{}\n", ctx.pad_line_bordered("", 0, border)));
1109
1110    let clang_tidy_count = result
1111        .issues
1112        .iter()
1113        .filter(|i| i.source.as_deref() == Some("clang-tidy"))
1114        .count();
1115    if clang_tidy_count >= 10 {
1116        output.push_str(&format!(
1117            "{}\n",
1118            ctx.pad_line_bordered(
1119                &format!(
1120                    "Too many clang-tidy issues ({})? Skip with:",
1121                    clang_tidy_count
1122                ),
1123                0,
1124                border,
1125            )
1126        ));
1127        output.push_str(&format!(
1128            "{}\n",
1129            ctx.pad_line_bordered("  LINTHIS_SKIP_CLANG_TIDY=1", 0, border)
1130        ));
1131        output.push_str(&format!("{}\n", ctx.pad_line_bordered("", 0, border)));
1132    }
1133
1134    let skip_command = hook_skip_command(hook_type);
1135    output.push_str(&format!(
1136        "{}\n",
1137        ctx.pad_line_bordered("To skip this check:", 0, border)
1138    ));
1139    output.push_str(&format!(
1140        "{}\n",
1141        ctx.pad_line_bordered(&format!("  {}", skip_command), 0, border)
1142    ));
1143}
1144
1145pub fn format_result_hook_with_width(
1146    result: &RunResult,
1147    hook_type: Option<&str>,
1148    config_width: Option<u32>,
1149) -> String {
1150    let ctx = HookBoxContext::new(config_width);
1151
1152    // exit_code is already computed based on fail_on config:
1153    //   0 = pass, 1 = error, 2 = warning, 3 = info, 4 = format error
1154    if result.exit_code == 0 {
1155        return format_hook_success_box(result, hook_type, &ctx);
1156    }
1157
1158    let shown_issues = filter_issues_by_exit_code(&result.issues, result.exit_code);
1159
1160    let hook_name = hook_display_name(hook_type);
1161    let mut output = String::new();
1162
1163    // Header
1164    output.push_str(&format!("{}\n", ctx.top_border.red()));
1165    let header = format!("X Linthis {} Blocked", hook_name);
1166    output.push_str(&format!("{}\n", ctx.pad_line(&header, 1).red()));
1167    output.push_str(&format!("{}\n", ctx.mid_border.red()));
1168
1169    // Summary + issue list
1170    append_issue_summary(&mut output, &shown_issues, &ctx);
1171    append_issue_list(&mut output, &shown_issues, &ctx);
1172
1173    output.push_str(&format!("{}\n", ctx.mid_border.red()));
1174
1175    // Tip and skip sections
1176    append_hook_tip_section(&mut output, result, hook_type, &ctx);
1177
1178    output.push_str(&format!("{}", ctx.bot_border.red()));
1179    output.push_str(&format_hook_paths_footer(hook_type));
1180
1181    output
1182}
1183
1184/// Filter issues based on exit_code severity level.
1185fn filter_issues_by_exit_code(issues: &[LintIssue], exit_code: i32) -> Vec<&LintIssue> {
1186    issues
1187        .iter()
1188        .filter(|i| match exit_code {
1189            2 => i.severity == Severity::Error || i.severity == Severity::Warning,
1190            3 => true,
1191            _ => i.severity == Severity::Error,
1192        })
1193        .collect()
1194}
1195
1196/// Append summary line (e.g. "2 errors, 1 warning in 3 files") to output.
1197/// Failure-path helper — content rows carry a red-tinted `│` to match the
1198/// enclosing box.
1199fn append_issue_summary(output: &mut String, issues: &[&LintIssue], ctx: &HookBoxContext) {
1200    let error_count = issues
1201        .iter()
1202        .filter(|i| i.severity == Severity::Error)
1203        .count();
1204    let warning_count = issues
1205        .iter()
1206        .filter(|i| i.severity == Severity::Warning)
1207        .count();
1208    let info_count = issues
1209        .iter()
1210        .filter(|i| i.severity == Severity::Info)
1211        .count();
1212    let files_with_issues = {
1213        let mut files = std::collections::HashSet::new();
1214        for i in issues {
1215            files.insert(&i.file_path);
1216        }
1217        files.len()
1218    };
1219
1220    let mut parts = Vec::new();
1221    if error_count > 0 {
1222        parts.push(format!(
1223            "{} error{}",
1224            error_count,
1225            if error_count == 1 { "" } else { "s" }
1226        ));
1227    }
1228    if warning_count > 0 {
1229        parts.push(format!(
1230            "{} warning{}",
1231            warning_count,
1232            if warning_count == 1 { "" } else { "s" }
1233        ));
1234    }
1235    if info_count > 0 {
1236        parts.push(format!("{} info", info_count));
1237    }
1238    let summary = format!(
1239        "{} in {} file{}",
1240        parts.join(", "),
1241        files_with_issues,
1242        if files_with_issues == 1 { "" } else { "s" },
1243    );
1244    let border = colored::Color::Red;
1245    output.push_str(&format!("{}\n", ctx.pad_line_bordered(&summary, 0, border)));
1246    output.push_str(&format!("{}\n", ctx.pad_line_bordered("", 0, border)));
1247}
1248
1249/// Append truncated issue list (max 8 items) to output.
1250/// Failure-path helper — content rows carry a red-tinted `│`.
1251fn append_issue_list(output: &mut String, issues: &[&LintIssue], ctx: &HookBoxContext) {
1252    let border = colored::Color::Red;
1253    let max_issues = 8;
1254    for issue in issues.iter().take(max_issues) {
1255        let line_content = format_hook_issue_line(issue, ctx.content_width);
1256        output.push_str(&format!(
1257            "{}\n",
1258            ctx.pad_line_bordered(&line_content, 0, border)
1259        ));
1260    }
1261
1262    if issues.len() > max_issues {
1263        let remaining = issues.len() - max_issues;
1264        let more_line = format!(
1265            " ... and {} more issue{}",
1266            remaining,
1267            if remaining == 1 { "" } else { "s" }
1268        );
1269        output.push_str(&format!(
1270            "{}\n",
1271            ctx.pad_line_bordered(&more_line, 0, border)
1272        ));
1273    }
1274}
1275
1276/// Format result according to the specified output format.
1277pub fn format_result(result: &RunResult, format: OutputFormat) -> String {
1278    format_result_with_hook_type(result, format, None)
1279}
1280
1281/// Format result with optional hook type for hook output.
1282pub fn format_result_with_hook_type(
1283    result: &RunResult,
1284    format: OutputFormat,
1285    hook_type: Option<&str>,
1286) -> String {
1287    match format {
1288        OutputFormat::Human => format_result_human(result),
1289        OutputFormat::Json => format_result_json(result),
1290        OutputFormat::GithubActions => format_result_github_actions(result),
1291        OutputFormat::Hook => format_result_hook(result, hook_type),
1292    }
1293}
1294
1295/// Format a review result summary in a bordered box for terminal output.
1296///
1297/// Produces a box similar to the hook result box, with assessment header,
1298/// issue counts, and top issues listed.
1299/// Format a single review issue line with truncation for box display.
1300fn format_review_issue_line(issue: &crate::review::ReviewIssue, content_width: usize) -> String {
1301    use crate::review::Severity;
1302
1303    let severity_char = match issue.severity {
1304        Severity::Critical => "C",
1305        Severity::Important => "I",
1306        Severity::Minor => "M",
1307    };
1308    let location = if let Some(line) = issue.line {
1309        format!("{}:{}", issue.file.display(), line)
1310    } else {
1311        issue.file.display().to_string()
1312    };
1313    let location_max = (content_width / 3).clamp(10, 35);
1314    let location_display = if location.len() > location_max {
1315        format!("{}...", &location[..location_max.saturating_sub(3)])
1316    } else {
1317        location
1318    };
1319    let msg_prefix_len = 4;
1320    let max_msg_len = content_width.saturating_sub(msg_prefix_len + location_display.len());
1321    let msg = if issue.message.len() > max_msg_len {
1322        format!("{}...", &issue.message[..max_msg_len.saturating_sub(3)])
1323    } else {
1324        issue.message.clone()
1325    };
1326    format!(" {} {} {}", severity_char, location_display, msg)
1327}
1328
1329/// Append the review issues section to the output.
1330fn append_review_issues(
1331    output: &mut String,
1332    issues: &[crate::review::ReviewIssue],
1333    content_width: usize,
1334    pad_line: &dyn Fn(&str, usize) -> String,
1335) {
1336    if issues.is_empty() {
1337        return;
1338    }
1339    output.push_str(&format!("{}\n", pad_line("", 0)));
1340    let max_issues = 6;
1341    for issue in issues.iter().take(max_issues) {
1342        let line_content = format_review_issue_line(issue, content_width);
1343        output.push_str(&format!("{}\n", pad_line(&line_content, 0)));
1344    }
1345    if issues.len() > max_issues {
1346        let more = format!(
1347            " ... and {} more issue{}",
1348            issues.len() - max_issues,
1349            if issues.len() - max_issues == 1 {
1350                ""
1351            } else {
1352                "s"
1353            }
1354        );
1355        output.push_str(&format!("{}\n", pad_line(&more, 0)));
1356    }
1357}
1358
1359pub fn format_review_box(result: &crate::review::ReviewResult) -> String {
1360    use crate::review::Assessment;
1361
1362    let box_width = get_terminal_width().clamp(50, 120);
1363    let content_width = box_width - 4;
1364
1365    let top_border = format!("╭{}╮", "─".repeat(box_width - 2));
1366    let mid_border = format!("├{}┤", "─".repeat(box_width - 2));
1367    let bot_border = format!("╰{}╯", "─".repeat(box_width - 2));
1368
1369    let pad_line = |content: &str, emoji_count: usize| -> String {
1370        let visual_len = content.chars().count() + emoji_count;
1371        let padding = content_width.saturating_sub(visual_len);
1372        format!("│ {}{} │", content, " ".repeat(padding))
1373    };
1374
1375    let summary = &result.summary;
1376    let (header_icon, header_text) = match summary.assessment {
1377        Assessment::Ready => ("✓", "Code Review — Ready"),
1378        Assessment::NeedsWork => ("!", "Code Review — Needs Work"),
1379        Assessment::CriticalIssues => ("X", "Code Review — Critical Issues"),
1380    };
1381    let header = format!("{} {}", header_icon, header_text);
1382    let is_success = summary.assessment == Assessment::Ready;
1383
1384    let mut output = String::new();
1385
1386    // Header with color based on success/failure
1387    let color_border = |s: &str, success: bool| -> String {
1388        if success {
1389            s.green().to_string()
1390        } else {
1391            s.red().to_string()
1392        }
1393    };
1394    output.push_str(&format!("{}\n", color_border(&top_border, is_success)));
1395    output.push_str(&format!(
1396        "{}\n",
1397        color_border(&pad_line(&header, 1), is_success)
1398    ));
1399    output.push_str(&format!("{}\n", color_border(&mid_border, is_success)));
1400
1401    // Summary counts
1402    let counts = format!(
1403        "{} issue{}: {} critical, {} important, {} minor",
1404        summary.total_issues,
1405        if summary.total_issues == 1 { "" } else { "s" },
1406        summary.critical_count,
1407        summary.important_count,
1408        summary.minor_count
1409    );
1410    output.push_str(&format!("{}\n", pad_line(&counts, 0)));
1411    output.push_str(&format!(
1412        "{}\n",
1413        pad_line(&format!("Files reviewed: {}", summary.files_reviewed), 0)
1414    ));
1415    output.push_str(&format!(
1416        "{}\n",
1417        pad_line(
1418            &format!("Diff: {}..{}", result.base_ref, result.head_ref),
1419            0
1420        )
1421    ));
1422
1423    // Issues section
1424    append_review_issues(&mut output, &result.issues, content_width, &pad_line);
1425
1426    // Bottom border
1427    output.push_str(&color_border(&bot_border, is_success));
1428
1429    output
1430}
1431
1432/// Format a commit-msg hook result box (passed or blocked).
1433///
1434/// Returns the box as a `String` without trailing newline.
1435/// The footer (hook file paths) is handled by the caller.
1436pub fn format_cmsg_result(passed: bool, first_line: &str) -> String {
1437    if passed {
1438        let mut out = String::new();
1439        out.push_str(&format!(
1440            "{}\n",
1441            "╭────────────────────────────────────────╮".green()
1442        ));
1443        out.push_str(&format!(
1444            "{}\n",
1445            "│ ✓ Linthis 📝 [Commit-msg] Passed       │".green()
1446        ));
1447        out.push_str(&format!(
1448            "{}\n",
1449            "├────────────────────────────────────────┤".green()
1450        ));
1451        out.push_str(&format!(
1452            "{}\n",
1453            "│ Commit message is valid                │".green()
1454        ));
1455        out.push_str(&format!(
1456            "{}",
1457            "╰────────────────────────────────────────╯".green()
1458        ));
1459        out
1460    } else {
1461        // Wrap each content-row's `│` in red so the side bars stay in the
1462        // blocked colour even under IDE VCS consoles that paint uncoloured
1463        // text differently.
1464        let bar = "│".red().to_string();
1465        let tint = |line: &str| -> String { line.replace('│', &bar) };
1466
1467        let mut out = String::new();
1468        out.push_str(&format!(
1469            "{}\n",
1470            "╭────────────────────────────────────────╮".red()
1471        ));
1472        out.push_str(&format!(
1473            "{}\n",
1474            "│ X Linthis 📝 [Commit-msg] Blocked      │".red()
1475        ));
1476        out.push_str(&format!(
1477            "{}\n",
1478            "├────────────────────────────────────────┤".red()
1479        ));
1480        out.push_str(&format!(
1481            "{}\n",
1482            "│ Validation Failed!                     │".red()
1483        ));
1484        out.push_str(&tint("│                                        │\n"));
1485        out.push_str(&tint("│ Your message:                          │\n"));
1486        let truncated = if first_line.chars().count() > 36 {
1487            format!("{}...", &first_line.chars().take(33).collect::<String>())
1488        } else {
1489            first_line.to_string()
1490        };
1491        let padding = 36usize.saturating_sub(truncated.chars().count());
1492        out.push_str(&tint(&format!(
1493            "│   {}{} │\n",
1494            truncated,
1495            " ".repeat(padding)
1496        )));
1497        out.push_str(&tint("│                                        │\n"));
1498        out.push_str(&tint("│ Expected format (Conventional Commits):│\n"));
1499        out.push_str(&tint("│   type(scope)?: description            │\n"));
1500        out.push_str(&tint("│                                        │\n"));
1501        out.push_str(&tint("│ Valid types:                           │\n"));
1502        out.push_str(&tint("│   feat, fix, docs, style, refactor,   │\n"));
1503        out.push_str(&tint("│   perf, test, build, ci, chore, revert │\n"));
1504        out.push_str(&tint("│                                        │\n"));
1505        out.push_str(&tint("│ Examples:                              │\n"));
1506        out.push_str(&tint("│   feat: add user authentication        │\n"));
1507        out.push_str(&tint("│   fix(api): handle null response       │\n"));
1508        out.push_str(&tint("│   docs: update README                  │\n"));
1509        out.push_str(&format!(
1510            "{}\n",
1511            "├────────────────────────────────────────┤".red()
1512        ));
1513        out.push_str(&tint("│ To skip this check:                    │\n"));
1514        out.push_str(&tint("│   git commit --no-verify               │\n"));
1515        out.push_str(&format!(
1516            "{}",
1517            "╰────────────────────────────────────────╯".red()
1518        ));
1519        out
1520    }
1521}
1522
1523#[cfg(test)]
1524mod tests {
1525    use super::*;
1526    use std::path::PathBuf;
1527
1528    /// Regression: the empty padding row must SURVIVE the `_linthis_paint`
1529    /// awk filter (the IDE VCS-console white-wrap pipeline). An earlier
1530    /// hypothesis was that awk's `gsub($0)` would trigger field splitting
1531    /// and collapse multiple spaces via OFS — this test guards against
1532    /// that and any similar shell-pipeline regression.
1533    #[test]
1534    fn hook_blocked_box_empty_line_survives_paint_filter() {
1535        use std::io::Write;
1536        use std::process::{Command, Stdio};
1537        let issue = LintIssue::new(
1538            PathBuf::from("broken.py"),
1539            27,
1540            "Local variable `a` is assigned to but never used".to_string(),
1541            Severity::Error,
1542        );
1543        let result = RunResult {
1544            issues: vec![issue],
1545            exit_code: 1,
1546            ..Default::default()
1547        };
1548        let out = format_result_hook_with_width(&result, Some("post-commit"), Some(80));
1549        // The awk filter lifted verbatim from shell_timer_functions' paint block.
1550        let awk_script = r#"
1551BEGIN { ESC = sprintf("\033"); RESET = ESC "[0m"; RESET_RE = ESC "\\[0m"; WHITE = ESC "[0;37m" }
1552{ gsub(RESET_RE, WHITE); print WHITE $0 RESET; fflush() }
1553"#;
1554        let mut child = Command::new("awk")
1555            .arg(awk_script)
1556            .stdin(Stdio::piped())
1557            .stdout(Stdio::piped())
1558            .stderr(Stdio::null())
1559            .spawn()
1560            .expect("spawn awk");
1561        child
1562            .stdin
1563            .as_mut()
1564            .expect("stdin")
1565            .write_all(out.as_bytes())
1566            .expect("write awk input");
1567        let painted = child.wait_with_output().expect("awk wait");
1568        assert!(painted.status.success(), "awk failed");
1569        let painted_s = String::from_utf8_lossy(&painted.stdout);
1570        let ansi_re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
1571        for (idx, line) in painted_s.lines().enumerate() {
1572            let stripped = ansi_re.replace_all(line, "");
1573            assert!(
1574                !stripped.contains("││"),
1575                "paint filter collapsed sidebars (line {idx}): stripped={stripped:?}"
1576            );
1577        }
1578        // And the expected empty row specifically survives.
1579        let content_width: usize = 76;
1580        let expected_empty = format!("│ {} │", " ".repeat(content_width));
1581        let full_stripped: String = painted_s
1582            .lines()
1583            .map(|l| ansi_re.replace_all(l, "").into_owned())
1584            .collect::<Vec<_>>()
1585            .join("\n");
1586        assert!(
1587            full_stripped.contains(&expected_empty),
1588            "paint filter altered the empty padding row. Expected {expected_empty:?} \
1589             in painted output:\n{full_stripped}"
1590        );
1591    }
1592
1593    /// Regression: in the Blocked box, the blank line between the summary
1594    /// and the issue list must render as `│ <content_width spaces> │` —
1595    /// NOT `││` with the sidebars touching. Bug surfaces only via visual
1596    /// inspection of live hook output, so encode it as an assertion.
1597    ///
1598    /// Scope: this test only checks the empty-padding row (which has no
1599    /// emoji). Header/issue rows containing emoji aren't checked for
1600    /// exact char-count-matches-width because `chars().count()` differs
1601    /// from visual cell width for wide glyphs.
1602    #[test]
1603    fn hook_blocked_box_preserves_empty_line_padding() {
1604        let issue = LintIssue::new(
1605            PathBuf::from("broken.py"),
1606            27,
1607            "Local variable `a` is assigned to but never used".to_string(),
1608            Severity::Error,
1609        );
1610        let result = RunResult {
1611            issues: vec![issue],
1612            exit_code: 1,
1613            ..Default::default()
1614        };
1615        for width in [50_u32, 60, 80, 100, 120] {
1616            let out = format_result_hook_with_width(&result, Some("post-commit"), Some(width));
1617            // No two vertical bars may touch in any line.
1618            for (idx, line) in out.lines().enumerate() {
1619                assert!(
1620                    !line.contains("││"),
1621                    "width={width} line {idx}: two `│` touching (no padding): {line:?}"
1622                );
1623            }
1624            // The empty padding row between summary and issue list must have
1625            // exactly `content_width` spaces between the two sidebars.
1626            let content_width = (width as usize) - 4;
1627            let expected_empty = format!("│ {}{} │", "", " ".repeat(content_width));
1628            // Strip ANSI colour codes before matching.
1629            let ansi_re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
1630            let stripped: String = out
1631                .lines()
1632                .map(|l| ansi_re.replace_all(l, "").into_owned())
1633                .collect::<Vec<_>>()
1634                .join("\n");
1635            assert!(
1636                stripped.contains(&expected_empty),
1637                "width={width}: empty padding row missing. Expected {expected_empty:?} \
1638                 in:\n{stripped}"
1639            );
1640        }
1641    }
1642
1643    #[test]
1644    fn test_format_issue_human() {
1645        let issue = LintIssue::new(
1646            PathBuf::from("src/main.rs"),
1647            42,
1648            "unused variable".to_string(),
1649            Severity::Warning,
1650        )
1651        .with_column(10)
1652        .with_code("W0001".to_string());
1653
1654        let output = format_issue_human(&issue);
1655        assert!(output.contains("src/main.rs:42:10"));
1656        assert!(output.contains("unused variable"));
1657        assert!(output.contains("W0001"));
1658    }
1659
1660    #[test]
1661    fn test_format_issue_github_actions() {
1662        let issue = LintIssue::new(
1663            PathBuf::from("src/main.rs"),
1664            42,
1665            "unused variable".to_string(),
1666            Severity::Error,
1667        )
1668        .with_column(10);
1669
1670        let output = format_issue_github_actions(&issue);
1671        assert!(output.starts_with("::error"));
1672        assert!(output.contains("file=src/main.rs"));
1673        assert!(output.contains("line=42"));
1674        assert!(output.contains("col=10"));
1675    }
1676
1677    #[test]
1678    fn test_build_hook_detail_lines_includes_provider_and_model() {
1679        let tmp = std::env::temp_dir().join("linthis-test-hook");
1680        let script = "#!/bin/sh\n\
1681            exec linthis hook run --event post-commit --type git-with-agent \
1682             --provider codebuddy --provider-args '--model glm-5.0-ioa' --global \"$@\"\n";
1683        std::fs::write(&tmp, script).unwrap();
1684
1685        let lines = build_hook_detail_lines(&tmp, "--fix-commit-mode fixup");
1686        let joined = lines.join("\n");
1687        // Strip ANSI color codes for assertion reliability.
1688        let plain: String = joined
1689            .chars()
1690            .filter(|c| c.is_ascii_graphic() || c.is_ascii_whitespace())
1691            .collect();
1692        let _ = std::fs::remove_file(&tmp);
1693
1694        assert!(plain.contains("--type"), "missing --type line: {plain}");
1695        assert!(plain.contains("git-with-agent"));
1696        assert!(plain.contains("--provider"));
1697        assert!(plain.contains("codebuddy"));
1698        assert!(plain.contains("--model"));
1699        assert!(plain.contains("glm-5.0-ioa"));
1700        assert!(plain.contains("--fix-commit-mode"));
1701        assert!(plain.contains("fixup"));
1702        assert_eq!(lines.len(), 4);
1703    }
1704
1705    #[test]
1706    fn test_build_hook_detail_lines_only_type_when_no_provider() {
1707        let tmp = std::env::temp_dir().join("linthis-test-hook-notypeonly");
1708        let script = "#!/bin/sh\nexec linthis hook run --event pre-commit --type git \"$@\"\n";
1709        std::fs::write(&tmp, script).unwrap();
1710
1711        let lines = build_hook_detail_lines(&tmp, "");
1712        let _ = std::fs::remove_file(&tmp);
1713
1714        assert_eq!(lines.len(), 1);
1715        let plain: String = lines[0]
1716            .chars()
1717            .filter(|c| c.is_ascii_graphic() || c.is_ascii_whitespace())
1718            .collect();
1719        assert!(plain.contains("--type"));
1720        assert!(plain.contains("git"));
1721        assert!(!plain.contains("--provider"));
1722        assert!(!plain.contains("--model"));
1723    }
1724
1725    #[test]
1726    fn test_extract_hook_script_provider_ignores_provider_args() {
1727        let tmp = std::env::temp_dir().join("linthis-test-hook-providerargs");
1728        // Only --provider-args present, no standalone --provider.
1729        let script = "#!/bin/sh\nexec linthis hook run --event pre-commit --type git \
1730             --provider-args '--model glm' \"$@\"\n";
1731        std::fs::write(&tmp, script).unwrap();
1732
1733        let provider = extract_hook_script_provider(&tmp);
1734        let model = extract_hook_script_model(&tmp);
1735        let _ = std::fs::remove_file(&tmp);
1736
1737        assert!(
1738            provider.is_none(),
1739            "should not confuse --provider-args with --provider"
1740        );
1741        assert_eq!(model.as_deref(), Some("glm"));
1742    }
1743}