1use super::rules::{Diagnostic, DiagnosticSeverity, Fix, FixConfidence, RuleCode};
7use clap::ValueEnum;
8use serde::Serialize;
9use std::collections::HashMap;
10use std::io::{self, Write};
11use std::path::PathBuf;
12
13#[derive(Debug, Clone, Copy, ValueEnum, Default)]
15pub enum OutputFormat {
16 #[default]
18 Text,
19 Concise,
21 Json,
23 Github,
25}
26
27#[derive(Debug, Clone, Copy, ValueEnum, Default)]
29pub enum DryRunFormat {
30 Concise,
32 #[default]
34 Full,
35 Json,
37}
38
39#[derive(Debug, Default)]
41pub struct LintSummary {
42 pub total_files: usize,
43 pub files_with_issues: usize,
44 pub total_diagnostics: usize,
45 pub fixable_diagnostics: usize,
46 pub errors: usize,
47 pub warnings: usize,
48}
49
50impl LintSummary {
51 pub fn add_diagnostic(&mut self, diag: &Diagnostic) {
52 self.total_diagnostics += 1;
53 if diag.fix.is_some() {
54 self.fixable_diagnostics += 1;
55 }
56 match diag.severity {
57 DiagnosticSeverity::Error => self.errors += 1,
58 DiagnosticSeverity::Warning => self.warnings += 1,
59 _ => {}
60 }
61 }
62}
63
64mod colors {
70 pub const RESET: &str = "\x1b[0m";
71 pub const BOLD: &str = "\x1b[1m";
72 pub const DIM: &str = "\x1b[2m";
73 pub const ITALIC: &str = "\x1b[3m";
74 pub const RED: &str = "\x1b[31m";
75 pub const GREEN: &str = "\x1b[32m";
76 pub const YELLOW: &str = "\x1b[33m";
77 pub const BLUE: &str = "\x1b[34m";
78 pub const MAGENTA: &str = "\x1b[35m";
79 pub const CYAN: &str = "\x1b[36m";
80 pub const WHITE: &str = "\x1b[37m";
81 pub const BRIGHT_RED: &str = "\x1b[91m";
82 pub const BRIGHT_GREEN: &str = "\x1b[92m";
83 pub const BRIGHT_YELLOW: &str = "\x1b[93m";
84 pub const BRIGHT_BLUE: &str = "\x1b[94m";
85 pub const BRIGHT_CYAN: &str = "\x1b[96m";
86 pub const BG_RED: &str = "\x1b[41m";
87 pub const BG_GREEN: &str = "\x1b[42m";
88 pub const BG_YELLOW: &str = "\x1b[43m";
89}
90
91mod box_chars {
93 pub const H_DOUBLE_TOP_LEFT: &str = "\u{2554}"; pub const H_DOUBLE_TOP_RIGHT: &str = "\u{2557}"; pub const H_DOUBLE_BOTTOM_LEFT: &str = "\u{255a}"; pub const H_DOUBLE_BOTTOM_RIGHT: &str = "\u{255d}"; pub const H_DOUBLE_HORIZONTAL: &str = "\u{2550}"; pub const H_DOUBLE_VERTICAL: &str = "\u{2551}"; pub const TOP_LEFT: &str = "\u{256d}"; pub const TOP_RIGHT: &str = "\u{256e}"; pub const BOTTOM_LEFT: &str = "\u{2570}"; pub const BOTTOM_RIGHT: &str = "\u{256f}"; pub const HORIZONTAL: &str = "\u{2500}"; pub const VERTICAL: &str = "\u{2502}"; pub const VERTICAL_RIGHT: &str = "\u{251c}"; pub const VERTICAL_LEFT: &str = "\u{2524}"; pub const ARROW_RIGHT: &str = "\u{25b6}"; pub const CHECK: &str = "\u{2713}"; pub const CROSS: &str = "\u{2717}"; pub const WARNING: &str = "\u{26a0}"; pub const INFO: &str = "\u{2139}"; }
118
119#[derive(Debug, Default)]
121pub struct DryRunSummary {
122 pub files_affected: usize,
124 pub total_fixes: usize,
126 pub safe_fixes: usize,
128 pub review_required: usize,
130 pub by_rule: HashMap<RuleCode, usize>,
132 pub by_file: HashMap<PathBuf, Vec<FixPreview>>,
134 pub lines_removed: usize,
136 pub lines_added: usize,
138}
139
140#[derive(Debug, Clone)]
142pub struct FixPreview {
143 pub rule: RuleCode,
145 pub message: String,
147 pub line_range: (usize, usize),
149 pub removed_content: Option<String>,
151 pub new_content: Option<String>,
153 pub confidence: FixConfidence,
155 pub is_safe: bool,
157 pub unsafe_reason: Option<String>,
159}
160
161impl DryRunSummary {
162 pub fn new() -> Self {
164 Self::default()
165 }
166
167 pub fn add_fix(&mut self, diag: &Diagnostic, original_content: &str) {
169 if let Some(fix) = &diag.fix {
170 self.total_fixes += 1;
171
172 if fix.is_safe && fix.confidence == FixConfidence::High {
173 self.safe_fixes += 1;
174 } else {
175 self.review_required += 1;
176 }
177
178 *self.by_rule.entry(diag.rule).or_insert(0) += 1;
179
180 let preview = create_fix_preview(diag, fix, original_content);
182
183 if let Some(removed) = &preview.removed_content {
185 self.lines_removed += removed.lines().count();
186 }
187 if let Some(added) = &preview.new_content {
188 self.lines_added += added.lines().count();
189 }
190
191 self.by_file
193 .entry(diag.file.clone())
194 .or_default()
195 .push(preview);
196 }
197 }
198
199 pub fn finalize(&mut self) {
201 self.files_affected = self.by_file.len();
202 }
203}
204
205fn create_fix_preview(diag: &Diagnostic, fix: &Fix, original_content: &str) -> FixPreview {
207 let lines: Vec<&str> = original_content.lines().collect();
208 let start_line = diag.range.start_line.saturating_sub(1);
209 let end_line = diag.range.end_line.saturating_sub(1);
210
211 let removed_content = if start_line < lines.len() {
213 let end = std::cmp::min(end_line + 1, lines.len());
214 Some(lines[start_line..end].join("\n"))
215 } else {
216 None
217 };
218
219 let new_content = if !fix.edits.is_empty() {
221 let text = &fix.edits[0].new_text;
222 if text.is_empty() {
223 None
224 } else {
225 Some(text.clone())
226 }
227 } else {
228 None
229 };
230
231 FixPreview {
232 rule: diag.rule,
233 message: fix.message.clone(),
234 line_range: (diag.range.start_line, diag.range.end_line),
235 removed_content,
236 new_content,
237 confidence: fix.confidence,
238 is_safe: fix.is_safe,
239 unsafe_reason: fix.unsafe_reason.clone(),
240 }
241}
242
243pub fn print_dry_run_header<W: Write>(w: &mut W) -> io::Result<()> {
245 use box_chars::*;
246 use colors::*;
247
248 let message = " DRY RUN - No files will be modified. Use --apply to write changes ";
249 let width = message.len() + 2;
250
251 writeln!(w)?;
252 writeln!(
253 w,
254 "{BOLD}{BRIGHT_YELLOW}{H_DOUBLE_TOP_LEFT}{}{H_DOUBLE_TOP_RIGHT}{RESET}",
255 H_DOUBLE_HORIZONTAL.repeat(width)
256 )?;
257 writeln!(
258 w,
259 "{BOLD}{BRIGHT_YELLOW}{H_DOUBLE_VERTICAL}{RESET} {BOLD}{YELLOW}{message}{RESET} {BOLD}{BRIGHT_YELLOW}{H_DOUBLE_VERTICAL}{RESET}"
260 )?;
261 writeln!(
262 w,
263 "{BOLD}{BRIGHT_YELLOW}{H_DOUBLE_BOTTOM_LEFT}{}{H_DOUBLE_BOTTOM_RIGHT}{RESET}",
264 H_DOUBLE_HORIZONTAL.repeat(width)
265 )?;
266 writeln!(w)?;
267
268 Ok(())
269}
270
271pub fn print_apply_header<W: Write>(w: &mut W) -> io::Result<()> {
273 use box_chars::*;
274 use colors::*;
275
276 let message = " APPLYING CHANGES - Files will be modified! ";
277 let width = message.len() + 2;
278
279 writeln!(w)?;
280 writeln!(
281 w,
282 "{BOLD}{BRIGHT_RED}{H_DOUBLE_TOP_LEFT}{}{H_DOUBLE_TOP_RIGHT}{RESET}",
283 H_DOUBLE_HORIZONTAL.repeat(width)
284 )?;
285 writeln!(
286 w,
287 "{BOLD}{BRIGHT_RED}{H_DOUBLE_VERTICAL}{RESET} {BOLD}{RED}{message}{RESET} {BOLD}{BRIGHT_RED}{H_DOUBLE_VERTICAL}{RESET}"
288 )?;
289 writeln!(
290 w,
291 "{BOLD}{BRIGHT_RED}{H_DOUBLE_BOTTOM_LEFT}{}{H_DOUBLE_BOTTOM_RIGHT}{RESET}",
292 H_DOUBLE_HORIZONTAL.repeat(width)
293 )?;
294 writeln!(w)?;
295
296 Ok(())
297}
298
299pub fn print_fix_preview_full<W: Write>(
301 w: &mut W,
302 file: &PathBuf,
303 preview: &FixPreview,
304) -> io::Result<()> {
305 use box_chars::*;
306 use colors::*;
307
308 let rule_line = format!("{}: {}", preview.rule, preview.rule.name());
310 let max_width = 70;
311
312 writeln!(w, "{BOLD}{CYAN}File: {}{RESET}", file.display())?;
314
315 writeln!(
317 w,
318 " {DIM}{TOP_LEFT}{}{TOP_RIGHT}{RESET}",
319 HORIZONTAL.repeat(max_width - 4)
320 )?;
321
322 writeln!(
324 w,
325 " {DIM}{VERTICAL}{RESET} {BRIGHT_CYAN}{rule_line}{RESET}"
326 )?;
327
328 let line_info = format!("Lines {}-{}", preview.line_range.0, preview.line_range.1);
330 writeln!(
331 w,
332 " {DIM}{VERTICAL}{RESET} {DIM}{line_info}{RESET}"
333 )?;
334
335 let confidence_str = match preview.confidence {
337 FixConfidence::High => format!("{GREEN}{CHECK} HIGH confidence{RESET}"),
338 FixConfidence::Medium => format!("{YELLOW}{WARNING} MEDIUM confidence{RESET}"),
339 FixConfidence::Low => format!("{RED}{CROSS} LOW confidence{RESET}"),
340 };
341 writeln!(w, " {DIM}{VERTICAL}{RESET} {confidence_str}")?;
342
343 if preview.is_safe {
345 writeln!(
346 w,
347 " {DIM}{VERTICAL}{RESET} {GREEN}Safe to auto-apply{RESET}"
348 )?;
349 } else {
350 writeln!(
351 w,
352 " {DIM}{VERTICAL}{RESET} {RED}Requires review{RESET}"
353 )?;
354 if let Some(reason) = &preview.unsafe_reason {
355 writeln!(
356 w,
357 " {DIM}{VERTICAL}{RESET} {DIM}Reason: {reason}{RESET}"
358 )?;
359 }
360 }
361
362 writeln!(
364 w,
365 " {DIM}{VERTICAL_RIGHT}{}{VERTICAL_LEFT}{RESET}",
366 HORIZONTAL.repeat(max_width - 4)
367 )?;
368
369 if let Some(removed) = &preview.removed_content {
371 writeln!(w, " {DIM}{VERTICAL}{RESET}")?;
372 for (i, line) in removed.lines().take(15).enumerate() {
373 let line_num = preview.line_range.0 + i;
374 let display_line = if line.len() > 60 {
375 format!("{}...", &line[..57])
376 } else {
377 line.to_string()
378 };
379 writeln!(
380 w,
381 " {DIM}{VERTICAL}{RESET} {RED}-{line_num:>4} {VERTICAL}{RESET} {RED}{display_line}{RESET}"
382 )?;
383 }
384 let total_lines = removed.lines().count();
385 if total_lines > 15 {
386 writeln!(
387 w,
388 " {DIM}{VERTICAL}{RESET} {RED} ... ({} more lines){RESET}",
389 total_lines - 15
390 )?;
391 }
392 }
393
394 if let Some(added) = &preview.new_content {
396 writeln!(w, " {DIM}{VERTICAL}{RESET}")?;
397 for (i, line) in added.lines().take(15).enumerate() {
398 let line_num = preview.line_range.0 + i;
399 let display_line = if line.len() > 60 {
400 format!("{}...", &line[..57])
401 } else {
402 line.to_string()
403 };
404 writeln!(
405 w,
406 " {DIM}{VERTICAL}{RESET} {GREEN}+{line_num:>4} {VERTICAL}{RESET} {GREEN}{display_line}{RESET}"
407 )?;
408 }
409 let total_lines = added.lines().count();
410 if total_lines > 15 {
411 writeln!(
412 w,
413 " {DIM}{VERTICAL}{RESET} {GREEN} ... ({} more lines){RESET}",
414 total_lines - 15
415 )?;
416 }
417 } else if preview.removed_content.is_some() {
418 writeln!(w, " {DIM}{VERTICAL}{RESET}")?;
420 writeln!(
421 w,
422 " {DIM}{VERTICAL}{RESET} {YELLOW}{INFO} Lines will be deleted (no replacement){RESET}"
423 )?;
424 }
425
426 writeln!(
428 w,
429 " {DIM}{BOTTOM_LEFT}{}{BOTTOM_RIGHT}{RESET}",
430 HORIZONTAL.repeat(max_width - 4)
431 )?;
432 writeln!(w)?;
433
434 Ok(())
435}
436
437pub fn print_fix_preview_concise<W: Write>(
439 w: &mut W,
440 file: &PathBuf,
441 preview: &FixPreview,
442) -> io::Result<()> {
443 use colors::*;
444
445 let confidence_marker = match preview.confidence {
446 FixConfidence::High => format!("{GREEN}[H]{RESET}"),
447 FixConfidence::Medium => format!("{YELLOW}[M]{RESET}"),
448 FixConfidence::Low => format!("{RED}[L]{RESET}"),
449 };
450
451 let safe_marker = if preview.is_safe {
452 format!("{GREEN}*{RESET}")
453 } else {
454 format!("{RED}!{RESET}")
455 };
456
457 writeln!(
458 w,
459 "{safe_marker} {}:{}-{} {} {confidence_marker} {}",
460 file.display(),
461 preview.line_range.0,
462 preview.line_range.1,
463 preview.rule,
464 preview.message
465 )?;
466
467 Ok(())
468}
469
470pub fn print_dry_run_summary<W: Write>(w: &mut W, summary: &DryRunSummary) -> io::Result<()> {
472 use box_chars::*;
473 use colors::*;
474
475 let width = 60;
476
477 writeln!(w)?;
478 writeln!(
479 w,
480 "{BOLD}{TOP_LEFT}{}{TOP_RIGHT}{RESET}",
481 HORIZONTAL.repeat(width - 2)
482 )?;
483 writeln!(
484 w,
485 "{BOLD}{VERTICAL} Summary{}{VERTICAL}{RESET}",
486 " ".repeat(width - 10)
487 )?;
488 writeln!(
489 w,
490 "{VERTICAL_RIGHT}{}{VERTICAL_LEFT}",
491 HORIZONTAL.repeat(width - 2)
492 )?;
493
494 writeln!(
496 w,
497 "{DIM}{VERTICAL}{RESET} Files affected: {BOLD}{CYAN}{:>6}{RESET} {DIM}{VERTICAL}{RESET}",
498 summary.files_affected
499 )?;
500 writeln!(
501 w,
502 "{DIM}{VERTICAL}{RESET} Total fixes: {BOLD}{:>6}{RESET} {DIM}{VERTICAL}{RESET}",
503 summary.total_fixes
504 )?;
505 writeln!(
506 w,
507 "{DIM}{VERTICAL}{RESET} Safe to apply: {BOLD}{GREEN}{:>6}{RESET} {DIM}{VERTICAL}{RESET}",
508 summary.safe_fixes
509 )?;
510 writeln!(
511 w,
512 "{DIM}{VERTICAL}{RESET} Requires review: {BOLD}{YELLOW}{:>6}{RESET} {DIM}{VERTICAL}{RESET}",
513 summary.review_required
514 )?;
515 writeln!(
516 w,
517 "{DIM}{VERTICAL}{RESET} Lines removed: {RED}{:>6}{RESET} {DIM}{VERTICAL}{RESET}",
518 summary.lines_removed
519 )?;
520 writeln!(
521 w,
522 "{DIM}{VERTICAL}{RESET} Lines added: {GREEN}{:>6}{RESET} {DIM}{VERTICAL}{RESET}",
523 summary.lines_added
524 )?;
525
526 if !summary.by_rule.is_empty() {
528 writeln!(
529 w,
530 "{VERTICAL_RIGHT}{}{VERTICAL_LEFT}",
531 HORIZONTAL.repeat(width - 2)
532 )?;
533 writeln!(
534 w,
535 "{DIM}{VERTICAL}{RESET} {DIM}Fixes by rule:{RESET} {DIM}{VERTICAL}{RESET}"
536 )?;
537
538 let mut rules: Vec<_> = summary.by_rule.iter().collect();
539 rules.sort_by(|a, b| b.1.cmp(a.1)); for (rule, count) in rules.iter().take(10) {
542 writeln!(
543 w,
544 "{DIM}{VERTICAL}{RESET} {CYAN}{:<8}{RESET} {:<20} {BOLD}{:>5}{RESET} {DIM}{VERTICAL}{RESET}",
545 rule,
546 rule.name(),
547 count
548 )?;
549 }
550 if rules.len() > 10 {
551 writeln!(
552 w,
553 "{DIM}{VERTICAL}{RESET} {DIM}... and {} more rules{RESET} {DIM}{VERTICAL}{RESET}",
554 rules.len() - 10
555 )?;
556 }
557 }
558
559 writeln!(
560 w,
561 "{BOTTOM_LEFT}{}{BOTTOM_RIGHT}",
562 HORIZONTAL.repeat(width - 2)
563 )?;
564
565 Ok(())
566}
567
568pub fn print_apply_instructions<W: Write>(w: &mut W, command_hint: &str) -> io::Result<()> {
570 use colors::*;
571
572 writeln!(w)?;
573 writeln!(
574 w,
575 "{BOLD}To apply these changes, run:{RESET}"
576 )?;
577 writeln!(
578 w,
579 " {BRIGHT_GREEN}{command_hint}{RESET}"
580 )?;
581 writeln!(w)?;
582
583 Ok(())
584}
585
586pub fn print_dry_run_full(summary: &DryRunSummary) -> io::Result<()> {
588 let stdout = io::stdout();
589 let mut handle = stdout.lock();
590
591 print_dry_run_header(&mut handle)?;
592
593 for (file, previews) in &summary.by_file {
595 for preview in previews {
596 print_fix_preview_full(&mut handle, file, preview)?;
597 }
598 }
599
600 print_dry_run_summary(&mut handle, summary)?;
601 print_apply_instructions(&mut handle, "fstar-lsp fix <path> --apply")?;
602
603 Ok(())
604}
605
606pub fn print_dry_run_concise(summary: &DryRunSummary) -> io::Result<()> {
608 use colors::*;
609
610 let stdout = io::stdout();
611 let mut handle = stdout.lock();
612
613 writeln!(handle)?;
614 writeln!(
615 handle,
616 "{BOLD}{YELLOW}DRY RUN{RESET} - No files will be modified"
617 )?;
618 writeln!(handle)?;
619
620 for (file, previews) in &summary.by_file {
622 for preview in previews {
623 print_fix_preview_concise(&mut handle, file, preview)?;
624 }
625 }
626
627 writeln!(handle)?;
628 writeln!(
629 handle,
630 "{DIM}Legend: * = safe to apply, ! = requires review, [H/M/L] = confidence{RESET}"
631 )?;
632 writeln!(handle)?;
633 writeln!(
634 handle,
635 "Files: {CYAN}{}{RESET} Fixes: {}{RESET} Safe: {GREEN}{}{RESET} Review: {YELLOW}{}{RESET}",
636 summary.files_affected,
637 summary.total_fixes,
638 summary.safe_fixes,
639 summary.review_required
640 )?;
641 writeln!(handle)?;
642 writeln!(
643 handle,
644 "Run {BRIGHT_GREEN}fstar-lsp fix <path> --apply{RESET} to write changes"
645 )?;
646
647 Ok(())
648}
649
650#[derive(Serialize)]
652struct JsonDryRunOutput {
653 dry_run: bool,
654 summary: JsonDryRunSummary,
655 fixes: Vec<JsonFixPreview>,
656}
657
658#[derive(Serialize)]
659struct JsonDryRunSummary {
660 files_affected: usize,
661 total_fixes: usize,
662 safe_fixes: usize,
663 review_required: usize,
664 lines_removed: usize,
665 lines_added: usize,
666 by_rule: HashMap<String, usize>,
667}
668
669#[derive(Serialize)]
670struct JsonFixPreview {
671 file: String,
672 rule: String,
673 rule_name: String,
674 message: String,
675 start_line: usize,
676 end_line: usize,
677 confidence: String,
678 is_safe: bool,
679 unsafe_reason: Option<String>,
680 removed_content: Option<String>,
681 new_content: Option<String>,
682}
683
684pub fn print_dry_run_json(summary: &DryRunSummary) -> io::Result<()> {
686 let stdout = io::stdout();
687 let mut handle = stdout.lock();
688
689 let by_rule: HashMap<String, usize> = summary
690 .by_rule
691 .iter()
692 .map(|(k, v)| (k.to_string(), *v))
693 .collect();
694
695 let fixes: Vec<JsonFixPreview> = summary
696 .by_file
697 .iter()
698 .flat_map(|(file, previews)| {
699 previews.iter().map(move |p| JsonFixPreview {
700 file: file.display().to_string(),
701 rule: p.rule.to_string(),
702 rule_name: p.rule.name().to_string(),
703 message: p.message.clone(),
704 start_line: p.line_range.0,
705 end_line: p.line_range.1,
706 confidence: p.confidence.to_string(),
707 is_safe: p.is_safe,
708 unsafe_reason: p.unsafe_reason.clone(),
709 removed_content: p.removed_content.clone(),
710 new_content: p.new_content.clone(),
711 })
712 })
713 .collect();
714
715 let output = JsonDryRunOutput {
716 dry_run: true,
717 summary: JsonDryRunSummary {
718 files_affected: summary.files_affected,
719 total_fixes: summary.total_fixes,
720 safe_fixes: summary.safe_fixes,
721 review_required: summary.review_required,
722 lines_removed: summary.lines_removed,
723 lines_added: summary.lines_added,
724 by_rule,
725 },
726 fixes,
727 };
728
729 let json = serde_json::to_string_pretty(&output)
730 .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
731 writeln!(handle, "{}", json)?;
732
733 Ok(())
734}
735
736pub fn print_dry_run(summary: &DryRunSummary, format: DryRunFormat) -> io::Result<()> {
738 match format {
739 DryRunFormat::Full => print_dry_run_full(summary),
740 DryRunFormat::Concise => print_dry_run_concise(summary),
741 DryRunFormat::Json => print_dry_run_json(summary),
742 }
743}
744
745pub fn print_no_fixes_message() -> io::Result<()> {
747 use colors::*;
748
749 let stdout = io::stdout();
750 let mut handle = stdout.lock();
751
752 writeln!(handle)?;
753 writeln!(
754 handle,
755 "{GREEN}{} No fixable issues found.{RESET}",
756 box_chars::CHECK
757 )?;
758 writeln!(handle)?;
759
760 Ok(())
761}
762
763pub fn print_fixes_applied(count: usize) -> io::Result<()> {
765 use colors::*;
766
767 let stdout = io::stdout();
768 let mut handle = stdout.lock();
769
770 writeln!(handle)?;
771 writeln!(
772 handle,
773 "{BOLD}{GREEN}{} {} file{} fixed.{RESET}",
774 box_chars::CHECK,
775 count,
776 if count == 1 { "" } else { "s" }
777 )?;
778 writeln!(handle)?;
779
780 Ok(())
781}
782
783pub fn print_diagnostics(
785 diagnostics: &[Diagnostic],
786 format: OutputFormat,
787 show_fixes: bool,
788) -> io::Result<()> {
789 let stdout = io::stdout();
790 let mut handle = stdout.lock();
791
792 match format {
793 OutputFormat::Text => print_text(&mut handle, diagnostics, show_fixes),
794 OutputFormat::Concise => print_concise(&mut handle, diagnostics),
795 OutputFormat::Json => print_json(&mut handle, diagnostics),
796 OutputFormat::Github => print_github(&mut handle, diagnostics),
797 }
798}
799
800pub fn print_summary(summary: &LintSummary, format: OutputFormat) -> io::Result<()> {
802 let stdout = io::stdout();
803 let mut handle = stdout.lock();
804
805 match format {
806 OutputFormat::Text | OutputFormat::Concise => {
807 writeln!(handle)?;
808 if summary.total_diagnostics == 0 {
809 writeln!(handle, "All checks passed!")?;
810 } else {
811 write!(
812 handle,
813 "Found {} issue{}",
814 summary.total_diagnostics,
815 if summary.total_diagnostics == 1 {
816 ""
817 } else {
818 "s"
819 }
820 )?;
821
822 if summary.fixable_diagnostics > 0 {
823 write!(handle, " ({} fixable)", summary.fixable_diagnostics)?;
824 }
825 writeln!(handle)?;
826
827 if summary.errors > 0 || summary.warnings > 0 {
828 writeln!(
829 handle,
830 " {} error{}, {} warning{}",
831 summary.errors,
832 if summary.errors == 1 { "" } else { "s" },
833 summary.warnings,
834 if summary.warnings == 1 { "" } else { "s" }
835 )?;
836 }
837 }
838 }
839 OutputFormat::Json | OutputFormat::Github => {
840 }
842 }
843
844 Ok(())
845}
846
847fn print_text<W: Write>(w: &mut W, diagnostics: &[Diagnostic], show_fixes: bool) -> io::Result<()> {
849 for diag in diagnostics {
850 let severity_color = match diag.severity {
851 DiagnosticSeverity::Error => "\x1b[31m", DiagnosticSeverity::Warning => "\x1b[33m", DiagnosticSeverity::Info => "\x1b[36m", DiagnosticSeverity::Hint => "\x1b[90m", };
856 let reset = "\x1b[0m";
857 let bold = "\x1b[1m";
858
859 writeln!(
860 w,
861 "{}{}{}:{}:{}: {}{}{} {} {}",
862 bold,
863 diag.file.display(),
864 reset,
865 diag.range.start_line,
866 diag.range.start_col,
867 severity_color,
868 diag.rule,
869 reset,
870 diag.message,
871 if diag.fix.is_some() { "[*]" } else { "" }
872 )?;
873
874 if show_fixes {
876 if let Some(fix) = &diag.fix {
877 writeln!(w, " {} fix: {}{}", severity_color, fix.message, reset)?;
878 for edit in &fix.edits {
879 if edit.new_text.is_empty() {
880 writeln!(
881 w,
882 " {}|- Delete lines {}-{}{}",
883 severity_color, edit.range.start_line, edit.range.end_line, reset
884 )?;
885 } else {
886 let preview: String = edit
887 .new_text
888 .lines()
889 .take(3)
890 .collect::<Vec<_>>()
891 .join(" | ");
892 let truncated = if preview.len() > 60 {
893 format!("{}...", &preview[..60])
894 } else {
895 preview
896 };
897 writeln!(w, " {}|+ {}{}", severity_color, truncated, reset)?;
898 }
899 }
900 }
901 }
902 }
903
904 Ok(())
905}
906
907fn print_concise<W: Write>(w: &mut W, diagnostics: &[Diagnostic]) -> io::Result<()> {
909 for diag in diagnostics {
910 writeln!(
911 w,
912 "{}:{}:{}: {} {}",
913 diag.file.display(),
914 diag.range.start_line,
915 diag.range.start_col,
916 diag.rule,
917 diag.message
918 )?;
919 }
920 Ok(())
921}
922
923#[derive(Serialize)]
925struct JsonDiagnostic {
926 code: String,
927 message: String,
928 severity: String,
929 file: String,
930 line: usize,
931 column: usize,
932 end_line: usize,
933 end_column: usize,
934 fixable: bool,
935}
936
937fn print_json<W: Write>(w: &mut W, diagnostics: &[Diagnostic]) -> io::Result<()> {
939 let json_diags: Vec<JsonDiagnostic> = diagnostics
940 .iter()
941 .map(|d| JsonDiagnostic {
942 code: d.rule.to_string(),
943 message: d.message.clone(),
944 severity: d.severity.to_string(),
945 file: d.file.display().to_string(),
946 line: d.range.start_line,
947 column: d.range.start_col,
948 end_line: d.range.end_line,
949 end_column: d.range.end_col,
950 fixable: d.fix.is_some(),
951 })
952 .collect();
953
954 let json = serde_json::to_string_pretty(&json_diags)
955 .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
956 writeln!(w, "{}", json)?;
957 Ok(())
958}
959
960fn print_github<W: Write>(w: &mut W, diagnostics: &[Diagnostic]) -> io::Result<()> {
962 for diag in diagnostics {
963 let level = match diag.severity {
964 DiagnosticSeverity::Error => "error",
965 DiagnosticSeverity::Warning => "warning",
966 DiagnosticSeverity::Info | DiagnosticSeverity::Hint => "notice",
967 };
968
969 writeln!(
970 w,
971 "::{} file={},line={},col={},endLine={},endColumn={}::{}: {}",
972 level,
973 diag.file.display(),
974 diag.range.start_line,
975 diag.range.start_col,
976 diag.range.end_line,
977 diag.range.end_col,
978 diag.rule,
979 diag.message
980 )?;
981 }
982 Ok(())
983}
984
985#[cfg(test)]
990mod tests {
991 use super::*;
992
993 fn create_test_preview(
995 rule: RuleCode,
996 line_range: (usize, usize),
997 confidence: FixConfidence,
998 is_safe: bool,
999 ) -> FixPreview {
1000 FixPreview {
1001 rule,
1002 message: format!("Test fix for {:?}", rule),
1003 line_range,
1004 removed_content: Some("old code".to_string()),
1005 new_content: Some("new code".to_string()),
1006 confidence,
1007 is_safe,
1008 unsafe_reason: if !is_safe {
1009 Some("Test reason".to_string())
1010 } else {
1011 None
1012 },
1013 }
1014 }
1015
1016 #[test]
1017 fn test_dry_run_summary_new() {
1018 let summary = DryRunSummary::new();
1019 assert_eq!(summary.files_affected, 0);
1020 assert_eq!(summary.total_fixes, 0);
1021 assert_eq!(summary.safe_fixes, 0);
1022 assert_eq!(summary.review_required, 0);
1023 }
1024
1025 #[test]
1026 fn test_dry_run_summary_add_fix() {
1027 let mut summary = DryRunSummary::new();
1028
1029 let diag = Diagnostic {
1031 rule: RuleCode::FST001,
1032 severity: DiagnosticSeverity::Warning,
1033 file: PathBuf::from("test.fst"),
1034 range: super::super::rules::Range::new(1, 1, 5, 1),
1035 message: "Test diagnostic".to_string(),
1036 fix: Some(Fix::safe("Test fix", vec![])),
1037 };
1038
1039 let content = "line1\nline2\nline3\nline4\nline5";
1040 summary.add_fix(&diag, content);
1041 summary.finalize();
1042
1043 assert_eq!(summary.total_fixes, 1);
1044 assert_eq!(summary.safe_fixes, 1);
1045 assert_eq!(summary.review_required, 0);
1046 assert_eq!(summary.files_affected, 1);
1047 assert!(summary.by_rule.contains_key(&RuleCode::FST001));
1048 }
1049
1050 #[test]
1051 fn test_dry_run_summary_counts_unsafe_as_review() {
1052 let mut summary = DryRunSummary::new();
1053
1054 let diag = Diagnostic {
1056 rule: RuleCode::FST005,
1057 severity: DiagnosticSeverity::Warning,
1058 file: PathBuf::from("test.fst"),
1059 range: super::super::rules::Range::new(1, 1, 5, 1),
1060 message: "Test diagnostic".to_string(),
1061 fix: Some(Fix::new("Test fix", vec![]).with_confidence(FixConfidence::Medium)),
1062 };
1063
1064 let content = "line1\nline2\nline3\nline4\nline5";
1065 summary.add_fix(&diag, content);
1066 summary.finalize();
1067
1068 assert_eq!(summary.total_fixes, 1);
1069 assert_eq!(summary.safe_fixes, 0);
1070 assert_eq!(summary.review_required, 1);
1071 }
1072
1073 #[test]
1074 fn test_dry_run_header_output() {
1075 let mut output = Vec::new();
1076 print_dry_run_header(&mut output).expect("Failed to write header");
1077 let result = String::from_utf8(output).expect("Invalid UTF-8");
1078
1079 assert!(result.contains("DRY RUN"));
1081 assert!(result.contains("No files will be modified"));
1082 assert!(result.contains("\u{2554}")); assert!(result.contains("\u{2557}")); }
1086
1087 #[test]
1088 fn test_apply_header_output() {
1089 let mut output = Vec::new();
1090 print_apply_header(&mut output).expect("Failed to write header");
1091 let result = String::from_utf8(output).expect("Invalid UTF-8");
1092
1093 assert!(result.contains("APPLYING CHANGES"));
1095 assert!(result.contains("Files will be modified"));
1096 }
1097
1098 #[test]
1099 fn test_fix_preview_full_output() {
1100 let mut output = Vec::new();
1101 let file = PathBuf::from("/test/path/file.fst");
1102 let preview = create_test_preview(RuleCode::FST001, (10, 15), FixConfidence::High, true);
1103
1104 print_fix_preview_full(&mut output, &file, &preview).expect("Failed to write preview");
1105 let result = String::from_utf8(output).expect("Invalid UTF-8");
1106
1107 assert!(result.contains("file.fst"));
1109 assert!(result.contains("FST001"));
1111 assert!(result.contains("10"));
1113 assert!(result.contains("15"));
1114 assert!(result.contains("HIGH"));
1116 }
1117
1118 #[test]
1119 fn test_fix_preview_concise_output() {
1120 let mut output = Vec::new();
1121 let file = PathBuf::from("/test/path/file.fst");
1122 let preview = create_test_preview(RuleCode::FST004, (5, 5), FixConfidence::High, true);
1123
1124 print_fix_preview_concise(&mut output, &file, &preview).expect("Failed to write preview");
1125 let result = String::from_utf8(output).expect("Invalid UTF-8");
1126
1127 assert_eq!(result.lines().count(), 1);
1129 assert!(result.contains("file.fst"));
1131 assert!(result.contains("FST004"));
1133 assert!(result.contains("*"));
1135 assert!(result.contains("[H]"));
1137 }
1138
1139 #[test]
1140 fn test_fix_preview_concise_unsafe() {
1141 let mut output = Vec::new();
1142 let file = PathBuf::from("/test/path/file.fst");
1143 let preview = create_test_preview(RuleCode::FST005, (5, 5), FixConfidence::Low, false);
1144
1145 print_fix_preview_concise(&mut output, &file, &preview).expect("Failed to write preview");
1146 let result = String::from_utf8(output).expect("Invalid UTF-8");
1147
1148 assert!(result.contains("!"));
1150 assert!(result.contains("[L]"));
1152 }
1153
1154 #[test]
1155 fn test_dry_run_summary_output() {
1156 let mut summary = DryRunSummary::new();
1157 summary.files_affected = 5;
1158 summary.total_fixes = 10;
1159 summary.safe_fixes = 7;
1160 summary.review_required = 3;
1161 summary.lines_removed = 50;
1162 summary.lines_added = 30;
1163 summary.by_rule.insert(RuleCode::FST001, 4);
1164 summary.by_rule.insert(RuleCode::FST004, 6);
1165
1166 let mut output = Vec::new();
1167 print_dry_run_summary(&mut output, &summary).expect("Failed to write summary");
1168 let result = String::from_utf8(output).expect("Invalid UTF-8");
1169
1170 assert!(result.contains("5")); assert!(result.contains("10")); assert!(result.contains("7")); assert!(result.contains("3")); assert!(result.contains("50")); assert!(result.contains("30")); assert!(result.contains("FST001"));
1180 assert!(result.contains("FST004"));
1181 }
1182
1183 #[test]
1184 fn test_apply_instructions_output() {
1185 let mut output = Vec::new();
1186 print_apply_instructions(&mut output, "fstar-lsp fix src/ --apply")
1187 .expect("Failed to write instructions");
1188 let result = String::from_utf8(output).expect("Invalid UTF-8");
1189
1190 assert!(result.contains("fstar-lsp fix src/ --apply"));
1192 assert!(result.contains("To apply these changes"));
1193 }
1194
1195 #[test]
1196 fn test_dry_run_format_enum() {
1197 let _concise = DryRunFormat::Concise;
1199 let _full = DryRunFormat::Full;
1200 let _json = DryRunFormat::Json;
1201
1202 let default: DryRunFormat = Default::default();
1204 assert!(matches!(default, DryRunFormat::Full));
1205 }
1206
1207 #[test]
1208 fn test_fix_preview_with_no_new_content() {
1209 let preview = FixPreview {
1210 rule: RuleCode::FST001,
1211 message: "Delete duplicate type".to_string(),
1212 line_range: (10, 15),
1213 removed_content: Some("type old = int".to_string()),
1214 new_content: None, confidence: FixConfidence::High,
1216 is_safe: true,
1217 unsafe_reason: None,
1218 };
1219
1220 let mut output = Vec::new();
1221 let file = PathBuf::from("test.fst");
1222 print_fix_preview_full(&mut output, &file, &preview).expect("Failed to write preview");
1223 let result = String::from_utf8(output).expect("Invalid UTF-8");
1224
1225 assert!(result.contains("deleted"));
1227 }
1228
1229 #[test]
1230 fn test_create_fix_preview_helper() {
1231 let diag = Diagnostic {
1232 rule: RuleCode::FST001,
1233 severity: DiagnosticSeverity::Warning,
1234 file: PathBuf::from("test.fst"),
1235 range: super::super::rules::Range::new(5, 1, 10, 1),
1236 message: "Duplicate type".to_string(),
1237 fix: Some(Fix::safe("Remove duplicate", vec![super::super::rules::Edit {
1238 file: PathBuf::from("test.fst"),
1239 range: super::super::rules::Range::new(5, 1, 10, 1),
1240 new_text: "".to_string(),
1241 }])),
1242 };
1243
1244 let content = "line1\nline2\nline3\nline4\ntype x = int\ntype x = int\nline7\nline8\nline9\nline10";
1245 let preview = create_fix_preview(&diag, diag.fix.as_ref().unwrap(), content);
1246
1247 assert_eq!(preview.rule, RuleCode::FST001);
1248 assert_eq!(preview.line_range, (5, 10));
1249 assert!(preview.removed_content.is_some());
1250 assert!(preview.new_content.is_none()); }
1252
1253 #[test]
1254 fn test_lines_counting() {
1255 let mut summary = DryRunSummary::new();
1256
1257 let diag = Diagnostic {
1258 rule: RuleCode::FST001,
1259 severity: DiagnosticSeverity::Warning,
1260 file: PathBuf::from("test.fst"),
1261 range: super::super::rules::Range::new(1, 1, 3, 1),
1262 message: "Test".to_string(),
1263 fix: Some(Fix::safe("Fix", vec![super::super::rules::Edit {
1264 file: PathBuf::from("test.fst"),
1265 range: super::super::rules::Range::new(1, 1, 3, 1),
1266 new_text: "new line 1\nnew line 2".to_string(),
1267 }])),
1268 };
1269
1270 let content = "line1\nline2\nline3";
1271 summary.add_fix(&diag, content);
1272
1273 assert_eq!(summary.lines_removed, 3);
1275 assert_eq!(summary.lines_added, 2);
1277 }
1278}