1use crate::utils::types::{LintIssue, RunResult, Severity};
18use colored::Colorize;
19use crossterm::terminal;
20use std::process::{Command, Stdio};
21
22pub 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
45pub fn get_terminal_width() -> usize {
47 terminal::size().map(|(w, _)| w as usize).unwrap_or(80)
48}
49
50pub fn detect_available_cli_provider() -> Option<&'static str> {
53 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 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#[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
101pub 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 if let Some(code_line) = &issue.code_line {
131 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 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 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 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 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
178pub 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
208fn 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
217fn 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
227fn 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
236fn 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
251fn 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
275fn 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
315pub 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
352pub fn format_result_human(result: &RunResult) -> String {
354 let mut output = String::new();
355
356 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 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 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 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#[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#[derive(serde::Serialize)]
478struct CheckResultView {
479 total_files: usize,
480 total_issues: usize,
481 issues: Vec<IssueView>,
482 #[serde(skip_serializing_if = "Option::is_none")]
484 extra: Option<serde_json::Value>,
485}
486
487#[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 #[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
515fn 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
548fn build_security_view(sast: &crate::security::sast::SastResult) -> CheckResultView {
550 let issues: Vec<IssueView> = sast
551 .findings
552 .iter()
553 .map(|f| {
554 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
596fn 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
661pub 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
701pub 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
711pub fn format_result_hook(result: &RunResult, hook_type: Option<&str>) -> String {
719 format_result_hook_with_width(result, hook_type, None)
720}
721
722pub fn format_hook_paths_footer_pub(hook_type: Option<&str>) -> String {
724 format_hook_paths_footer(hook_type)
725}
726
727fn extract_hook_script_type(path: &std::path::Path) -> Option<String> {
729 let content = std::fs::read_to_string(path).ok()?;
730 content
732 .split("--type ")
733 .nth(1)
734 .and_then(|s| s.split_whitespace().next())
735 .map(|s| s.to_string())
736}
737
738fn 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
750fn 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
763fn 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
775fn 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
796fn 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 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 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
895struct 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 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
942fn 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
952fn 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
960fn 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 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
1060fn 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
1089fn 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 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 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 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 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
1184fn 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
1196fn 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
1249fn 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
1276pub fn format_result(result: &RunResult, format: OutputFormat) -> String {
1278 format_result_with_hook_type(result, format, None)
1279}
1280
1281pub 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
1295fn 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
1329fn 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 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 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 append_review_issues(&mut output, &result.issues, content_width, &pad_line);
1425
1426 output.push_str(&color_border(&bot_border, is_success));
1428
1429 output
1430}
1431
1432pub 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 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 #[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 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 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 #[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 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 let content_width = (width as usize) - 4;
1627 let expected_empty = format!("│ {}{} │", "", " ".repeat(content_width));
1628 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 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 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}