use super::rules::{Diagnostic, DiagnosticSeverity, Fix, FixConfidence, RuleCode};
use clap::ValueEnum;
use serde::Serialize;
use std::collections::HashMap;
use std::io::{self, Write};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, ValueEnum, Default)]
pub enum OutputFormat {
#[default]
Text,
Concise,
Json,
Github,
}
#[derive(Debug, Clone, Copy, ValueEnum, Default)]
pub enum DryRunFormat {
Concise,
#[default]
Full,
Json,
}
#[derive(Debug, Default)]
pub struct LintSummary {
pub total_files: usize,
pub files_with_issues: usize,
pub total_diagnostics: usize,
pub fixable_diagnostics: usize,
pub errors: usize,
pub warnings: usize,
}
impl LintSummary {
pub fn add_diagnostic(&mut self, diag: &Diagnostic) {
self.total_diagnostics += 1;
if diag.fix.is_some() {
self.fixable_diagnostics += 1;
}
match diag.severity {
DiagnosticSeverity::Error => self.errors += 1,
DiagnosticSeverity::Warning => self.warnings += 1,
_ => {}
}
}
}
mod colors {
pub const RESET: &str = "\x1b[0m";
pub const BOLD: &str = "\x1b[1m";
pub const DIM: &str = "\x1b[2m";
pub const ITALIC: &str = "\x1b[3m";
pub const RED: &str = "\x1b[31m";
pub const GREEN: &str = "\x1b[32m";
pub const YELLOW: &str = "\x1b[33m";
pub const BLUE: &str = "\x1b[34m";
pub const MAGENTA: &str = "\x1b[35m";
pub const CYAN: &str = "\x1b[36m";
pub const WHITE: &str = "\x1b[37m";
pub const BRIGHT_RED: &str = "\x1b[91m";
pub const BRIGHT_GREEN: &str = "\x1b[92m";
pub const BRIGHT_YELLOW: &str = "\x1b[93m";
pub const BRIGHT_BLUE: &str = "\x1b[94m";
pub const BRIGHT_CYAN: &str = "\x1b[96m";
pub const BG_RED: &str = "\x1b[41m";
pub const BG_GREEN: &str = "\x1b[42m";
pub const BG_YELLOW: &str = "\x1b[43m";
}
mod box_chars {
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}"; }
#[derive(Debug, Default)]
pub struct DryRunSummary {
pub files_affected: usize,
pub total_fixes: usize,
pub safe_fixes: usize,
pub review_required: usize,
pub by_rule: HashMap<RuleCode, usize>,
pub by_file: HashMap<PathBuf, Vec<FixPreview>>,
pub lines_removed: usize,
pub lines_added: usize,
}
#[derive(Debug, Clone)]
pub struct FixPreview {
pub rule: RuleCode,
pub message: String,
pub line_range: (usize, usize),
pub removed_content: Option<String>,
pub new_content: Option<String>,
pub confidence: FixConfidence,
pub is_safe: bool,
pub unsafe_reason: Option<String>,
}
impl DryRunSummary {
pub fn new() -> Self {
Self::default()
}
pub fn add_fix(&mut self, diag: &Diagnostic, original_content: &str) {
if let Some(fix) = &diag.fix {
self.total_fixes += 1;
if fix.is_safe && fix.confidence == FixConfidence::High {
self.safe_fixes += 1;
} else {
self.review_required += 1;
}
*self.by_rule.entry(diag.rule).or_insert(0) += 1;
let preview = create_fix_preview(diag, fix, original_content);
if let Some(removed) = &preview.removed_content {
self.lines_removed += removed.lines().count();
}
if let Some(added) = &preview.new_content {
self.lines_added += added.lines().count();
}
self.by_file
.entry(diag.file.clone())
.or_default()
.push(preview);
}
}
pub fn finalize(&mut self) {
self.files_affected = self.by_file.len();
}
}
fn create_fix_preview(diag: &Diagnostic, fix: &Fix, original_content: &str) -> FixPreview {
let lines: Vec<&str> = original_content.lines().collect();
let start_line = diag.range.start_line.saturating_sub(1);
let end_line = diag.range.end_line.saturating_sub(1);
let removed_content = if start_line < lines.len() {
let end = std::cmp::min(end_line + 1, lines.len());
Some(lines[start_line..end].join("\n"))
} else {
None
};
let new_content = if !fix.edits.is_empty() {
let text = &fix.edits[0].new_text;
if text.is_empty() {
None
} else {
Some(text.clone())
}
} else {
None
};
FixPreview {
rule: diag.rule,
message: fix.message.clone(),
line_range: (diag.range.start_line, diag.range.end_line),
removed_content,
new_content,
confidence: fix.confidence,
is_safe: fix.is_safe,
unsafe_reason: fix.unsafe_reason.clone(),
}
}
pub fn print_dry_run_header<W: Write>(w: &mut W) -> io::Result<()> {
use box_chars::*;
use colors::*;
let message = " DRY RUN - No files will be modified. Use --apply to write changes ";
let width = message.len() + 2;
writeln!(w)?;
writeln!(
w,
"{BOLD}{BRIGHT_YELLOW}{H_DOUBLE_TOP_LEFT}{}{H_DOUBLE_TOP_RIGHT}{RESET}",
H_DOUBLE_HORIZONTAL.repeat(width)
)?;
writeln!(
w,
"{BOLD}{BRIGHT_YELLOW}{H_DOUBLE_VERTICAL}{RESET} {BOLD}{YELLOW}{message}{RESET} {BOLD}{BRIGHT_YELLOW}{H_DOUBLE_VERTICAL}{RESET}"
)?;
writeln!(
w,
"{BOLD}{BRIGHT_YELLOW}{H_DOUBLE_BOTTOM_LEFT}{}{H_DOUBLE_BOTTOM_RIGHT}{RESET}",
H_DOUBLE_HORIZONTAL.repeat(width)
)?;
writeln!(w)?;
Ok(())
}
pub fn print_apply_header<W: Write>(w: &mut W) -> io::Result<()> {
use box_chars::*;
use colors::*;
let message = " APPLYING CHANGES - Files will be modified! ";
let width = message.len() + 2;
writeln!(w)?;
writeln!(
w,
"{BOLD}{BRIGHT_RED}{H_DOUBLE_TOP_LEFT}{}{H_DOUBLE_TOP_RIGHT}{RESET}",
H_DOUBLE_HORIZONTAL.repeat(width)
)?;
writeln!(
w,
"{BOLD}{BRIGHT_RED}{H_DOUBLE_VERTICAL}{RESET} {BOLD}{RED}{message}{RESET} {BOLD}{BRIGHT_RED}{H_DOUBLE_VERTICAL}{RESET}"
)?;
writeln!(
w,
"{BOLD}{BRIGHT_RED}{H_DOUBLE_BOTTOM_LEFT}{}{H_DOUBLE_BOTTOM_RIGHT}{RESET}",
H_DOUBLE_HORIZONTAL.repeat(width)
)?;
writeln!(w)?;
Ok(())
}
pub fn print_fix_preview_full<W: Write>(
w: &mut W,
file: &PathBuf,
preview: &FixPreview,
) -> io::Result<()> {
use box_chars::*;
use colors::*;
let rule_line = format!("{}: {}", preview.rule, preview.rule.name());
let max_width = 70;
writeln!(w, "{BOLD}{CYAN}File: {}{RESET}", file.display())?;
writeln!(
w,
" {DIM}{TOP_LEFT}{}{TOP_RIGHT}{RESET}",
HORIZONTAL.repeat(max_width - 4)
)?;
writeln!(
w,
" {DIM}{VERTICAL}{RESET} {BRIGHT_CYAN}{rule_line}{RESET}"
)?;
let line_info = format!("Lines {}-{}", preview.line_range.0, preview.line_range.1);
writeln!(
w,
" {DIM}{VERTICAL}{RESET} {DIM}{line_info}{RESET}"
)?;
let confidence_str = match preview.confidence {
FixConfidence::High => format!("{GREEN}{CHECK} HIGH confidence{RESET}"),
FixConfidence::Medium => format!("{YELLOW}{WARNING} MEDIUM confidence{RESET}"),
FixConfidence::Low => format!("{RED}{CROSS} LOW confidence{RESET}"),
};
writeln!(w, " {DIM}{VERTICAL}{RESET} {confidence_str}")?;
if preview.is_safe {
writeln!(
w,
" {DIM}{VERTICAL}{RESET} {GREEN}Safe to auto-apply{RESET}"
)?;
} else {
writeln!(
w,
" {DIM}{VERTICAL}{RESET} {RED}Requires review{RESET}"
)?;
if let Some(reason) = &preview.unsafe_reason {
writeln!(
w,
" {DIM}{VERTICAL}{RESET} {DIM}Reason: {reason}{RESET}"
)?;
}
}
writeln!(
w,
" {DIM}{VERTICAL_RIGHT}{}{VERTICAL_LEFT}{RESET}",
HORIZONTAL.repeat(max_width - 4)
)?;
if let Some(removed) = &preview.removed_content {
writeln!(w, " {DIM}{VERTICAL}{RESET}")?;
for (i, line) in removed.lines().take(15).enumerate() {
let line_num = preview.line_range.0 + i;
let display_line = if line.len() > 60 {
format!("{}...", &line[..57])
} else {
line.to_string()
};
writeln!(
w,
" {DIM}{VERTICAL}{RESET} {RED}-{line_num:>4} {VERTICAL}{RESET} {RED}{display_line}{RESET}"
)?;
}
let total_lines = removed.lines().count();
if total_lines > 15 {
writeln!(
w,
" {DIM}{VERTICAL}{RESET} {RED} ... ({} more lines){RESET}",
total_lines - 15
)?;
}
}
if let Some(added) = &preview.new_content {
writeln!(w, " {DIM}{VERTICAL}{RESET}")?;
for (i, line) in added.lines().take(15).enumerate() {
let line_num = preview.line_range.0 + i;
let display_line = if line.len() > 60 {
format!("{}...", &line[..57])
} else {
line.to_string()
};
writeln!(
w,
" {DIM}{VERTICAL}{RESET} {GREEN}+{line_num:>4} {VERTICAL}{RESET} {GREEN}{display_line}{RESET}"
)?;
}
let total_lines = added.lines().count();
if total_lines > 15 {
writeln!(
w,
" {DIM}{VERTICAL}{RESET} {GREEN} ... ({} more lines){RESET}",
total_lines - 15
)?;
}
} else if preview.removed_content.is_some() {
writeln!(w, " {DIM}{VERTICAL}{RESET}")?;
writeln!(
w,
" {DIM}{VERTICAL}{RESET} {YELLOW}{INFO} Lines will be deleted (no replacement){RESET}"
)?;
}
writeln!(
w,
" {DIM}{BOTTOM_LEFT}{}{BOTTOM_RIGHT}{RESET}",
HORIZONTAL.repeat(max_width - 4)
)?;
writeln!(w)?;
Ok(())
}
pub fn print_fix_preview_concise<W: Write>(
w: &mut W,
file: &PathBuf,
preview: &FixPreview,
) -> io::Result<()> {
use colors::*;
let confidence_marker = match preview.confidence {
FixConfidence::High => format!("{GREEN}[H]{RESET}"),
FixConfidence::Medium => format!("{YELLOW}[M]{RESET}"),
FixConfidence::Low => format!("{RED}[L]{RESET}"),
};
let safe_marker = if preview.is_safe {
format!("{GREEN}*{RESET}")
} else {
format!("{RED}!{RESET}")
};
writeln!(
w,
"{safe_marker} {}:{}-{} {} {confidence_marker} {}",
file.display(),
preview.line_range.0,
preview.line_range.1,
preview.rule,
preview.message
)?;
Ok(())
}
pub fn print_dry_run_summary<W: Write>(w: &mut W, summary: &DryRunSummary) -> io::Result<()> {
use box_chars::*;
use colors::*;
let width = 60;
writeln!(w)?;
writeln!(
w,
"{BOLD}{TOP_LEFT}{}{TOP_RIGHT}{RESET}",
HORIZONTAL.repeat(width - 2)
)?;
writeln!(
w,
"{BOLD}{VERTICAL} Summary{}{VERTICAL}{RESET}",
" ".repeat(width - 10)
)?;
writeln!(
w,
"{VERTICAL_RIGHT}{}{VERTICAL_LEFT}",
HORIZONTAL.repeat(width - 2)
)?;
writeln!(
w,
"{DIM}{VERTICAL}{RESET} Files affected: {BOLD}{CYAN}{:>6}{RESET} {DIM}{VERTICAL}{RESET}",
summary.files_affected
)?;
writeln!(
w,
"{DIM}{VERTICAL}{RESET} Total fixes: {BOLD}{:>6}{RESET} {DIM}{VERTICAL}{RESET}",
summary.total_fixes
)?;
writeln!(
w,
"{DIM}{VERTICAL}{RESET} Safe to apply: {BOLD}{GREEN}{:>6}{RESET} {DIM}{VERTICAL}{RESET}",
summary.safe_fixes
)?;
writeln!(
w,
"{DIM}{VERTICAL}{RESET} Requires review: {BOLD}{YELLOW}{:>6}{RESET} {DIM}{VERTICAL}{RESET}",
summary.review_required
)?;
writeln!(
w,
"{DIM}{VERTICAL}{RESET} Lines removed: {RED}{:>6}{RESET} {DIM}{VERTICAL}{RESET}",
summary.lines_removed
)?;
writeln!(
w,
"{DIM}{VERTICAL}{RESET} Lines added: {GREEN}{:>6}{RESET} {DIM}{VERTICAL}{RESET}",
summary.lines_added
)?;
if !summary.by_rule.is_empty() {
writeln!(
w,
"{VERTICAL_RIGHT}{}{VERTICAL_LEFT}",
HORIZONTAL.repeat(width - 2)
)?;
writeln!(
w,
"{DIM}{VERTICAL}{RESET} {DIM}Fixes by rule:{RESET} {DIM}{VERTICAL}{RESET}"
)?;
let mut rules: Vec<_> = summary.by_rule.iter().collect();
rules.sort_by(|a, b| b.1.cmp(a.1));
for (rule, count) in rules.iter().take(10) {
writeln!(
w,
"{DIM}{VERTICAL}{RESET} {CYAN}{:<8}{RESET} {:<20} {BOLD}{:>5}{RESET} {DIM}{VERTICAL}{RESET}",
rule,
rule.name(),
count
)?;
}
if rules.len() > 10 {
writeln!(
w,
"{DIM}{VERTICAL}{RESET} {DIM}... and {} more rules{RESET} {DIM}{VERTICAL}{RESET}",
rules.len() - 10
)?;
}
}
writeln!(
w,
"{BOTTOM_LEFT}{}{BOTTOM_RIGHT}",
HORIZONTAL.repeat(width - 2)
)?;
Ok(())
}
pub fn print_apply_instructions<W: Write>(w: &mut W, command_hint: &str) -> io::Result<()> {
use colors::*;
writeln!(w)?;
writeln!(
w,
"{BOLD}To apply these changes, run:{RESET}"
)?;
writeln!(
w,
" {BRIGHT_GREEN}{command_hint}{RESET}"
)?;
writeln!(w)?;
Ok(())
}
pub fn print_dry_run_full(summary: &DryRunSummary) -> io::Result<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();
print_dry_run_header(&mut handle)?;
for (file, previews) in &summary.by_file {
for preview in previews {
print_fix_preview_full(&mut handle, file, preview)?;
}
}
print_dry_run_summary(&mut handle, summary)?;
print_apply_instructions(&mut handle, "fstar-lsp fix <path> --apply")?;
Ok(())
}
pub fn print_dry_run_concise(summary: &DryRunSummary) -> io::Result<()> {
use colors::*;
let stdout = io::stdout();
let mut handle = stdout.lock();
writeln!(handle)?;
writeln!(
handle,
"{BOLD}{YELLOW}DRY RUN{RESET} - No files will be modified"
)?;
writeln!(handle)?;
for (file, previews) in &summary.by_file {
for preview in previews {
print_fix_preview_concise(&mut handle, file, preview)?;
}
}
writeln!(handle)?;
writeln!(
handle,
"{DIM}Legend: * = safe to apply, ! = requires review, [H/M/L] = confidence{RESET}"
)?;
writeln!(handle)?;
writeln!(
handle,
"Files: {CYAN}{}{RESET} Fixes: {}{RESET} Safe: {GREEN}{}{RESET} Review: {YELLOW}{}{RESET}",
summary.files_affected,
summary.total_fixes,
summary.safe_fixes,
summary.review_required
)?;
writeln!(handle)?;
writeln!(
handle,
"Run {BRIGHT_GREEN}fstar-lsp fix <path> --apply{RESET} to write changes"
)?;
Ok(())
}
#[derive(Serialize)]
struct JsonDryRunOutput {
dry_run: bool,
summary: JsonDryRunSummary,
fixes: Vec<JsonFixPreview>,
}
#[derive(Serialize)]
struct JsonDryRunSummary {
files_affected: usize,
total_fixes: usize,
safe_fixes: usize,
review_required: usize,
lines_removed: usize,
lines_added: usize,
by_rule: HashMap<String, usize>,
}
#[derive(Serialize)]
struct JsonFixPreview {
file: String,
rule: String,
rule_name: String,
message: String,
start_line: usize,
end_line: usize,
confidence: String,
is_safe: bool,
unsafe_reason: Option<String>,
removed_content: Option<String>,
new_content: Option<String>,
}
pub fn print_dry_run_json(summary: &DryRunSummary) -> io::Result<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();
let by_rule: HashMap<String, usize> = summary
.by_rule
.iter()
.map(|(k, v)| (k.to_string(), *v))
.collect();
let fixes: Vec<JsonFixPreview> = summary
.by_file
.iter()
.flat_map(|(file, previews)| {
previews.iter().map(move |p| JsonFixPreview {
file: file.display().to_string(),
rule: p.rule.to_string(),
rule_name: p.rule.name().to_string(),
message: p.message.clone(),
start_line: p.line_range.0,
end_line: p.line_range.1,
confidence: p.confidence.to_string(),
is_safe: p.is_safe,
unsafe_reason: p.unsafe_reason.clone(),
removed_content: p.removed_content.clone(),
new_content: p.new_content.clone(),
})
})
.collect();
let output = JsonDryRunOutput {
dry_run: true,
summary: JsonDryRunSummary {
files_affected: summary.files_affected,
total_fixes: summary.total_fixes,
safe_fixes: summary.safe_fixes,
review_required: summary.review_required,
lines_removed: summary.lines_removed,
lines_added: summary.lines_added,
by_rule,
},
fixes,
};
let json = serde_json::to_string_pretty(&output)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
writeln!(handle, "{}", json)?;
Ok(())
}
pub fn print_dry_run(summary: &DryRunSummary, format: DryRunFormat) -> io::Result<()> {
match format {
DryRunFormat::Full => print_dry_run_full(summary),
DryRunFormat::Concise => print_dry_run_concise(summary),
DryRunFormat::Json => print_dry_run_json(summary),
}
}
pub fn print_no_fixes_message() -> io::Result<()> {
use colors::*;
let stdout = io::stdout();
let mut handle = stdout.lock();
writeln!(handle)?;
writeln!(
handle,
"{GREEN}{} No fixable issues found.{RESET}",
box_chars::CHECK
)?;
writeln!(handle)?;
Ok(())
}
pub fn print_fixes_applied(count: usize) -> io::Result<()> {
use colors::*;
let stdout = io::stdout();
let mut handle = stdout.lock();
writeln!(handle)?;
writeln!(
handle,
"{BOLD}{GREEN}{} {} file{} fixed.{RESET}",
box_chars::CHECK,
count,
if count == 1 { "" } else { "s" }
)?;
writeln!(handle)?;
Ok(())
}
pub fn print_diagnostics(
diagnostics: &[Diagnostic],
format: OutputFormat,
show_fixes: bool,
) -> io::Result<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();
match format {
OutputFormat::Text => print_text(&mut handle, diagnostics, show_fixes),
OutputFormat::Concise => print_concise(&mut handle, diagnostics),
OutputFormat::Json => print_json(&mut handle, diagnostics),
OutputFormat::Github => print_github(&mut handle, diagnostics),
}
}
pub fn print_summary(summary: &LintSummary, format: OutputFormat) -> io::Result<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();
match format {
OutputFormat::Text | OutputFormat::Concise => {
writeln!(handle)?;
if summary.total_diagnostics == 0 {
writeln!(handle, "All checks passed!")?;
} else {
write!(
handle,
"Found {} issue{}",
summary.total_diagnostics,
if summary.total_diagnostics == 1 {
""
} else {
"s"
}
)?;
if summary.fixable_diagnostics > 0 {
write!(handle, " ({} fixable)", summary.fixable_diagnostics)?;
}
writeln!(handle)?;
if summary.errors > 0 || summary.warnings > 0 {
writeln!(
handle,
" {} error{}, {} warning{}",
summary.errors,
if summary.errors == 1 { "" } else { "s" },
summary.warnings,
if summary.warnings == 1 { "" } else { "s" }
)?;
}
}
}
OutputFormat::Json | OutputFormat::Github => {
}
}
Ok(())
}
fn print_text<W: Write>(w: &mut W, diagnostics: &[Diagnostic], show_fixes: bool) -> io::Result<()> {
for diag in diagnostics {
let severity_color = match diag.severity {
DiagnosticSeverity::Error => "\x1b[31m", DiagnosticSeverity::Warning => "\x1b[33m", DiagnosticSeverity::Info => "\x1b[36m", DiagnosticSeverity::Hint => "\x1b[90m", };
let reset = "\x1b[0m";
let bold = "\x1b[1m";
writeln!(
w,
"{}{}{}:{}:{}: {}{}{} {} {}",
bold,
diag.file.display(),
reset,
diag.range.start_line,
diag.range.start_col,
severity_color,
diag.rule,
reset,
diag.message,
if diag.fix.is_some() { "[*]" } else { "" }
)?;
if show_fixes {
if let Some(fix) = &diag.fix {
writeln!(w, " {} fix: {}{}", severity_color, fix.message, reset)?;
for edit in &fix.edits {
if edit.new_text.is_empty() {
writeln!(
w,
" {}|- Delete lines {}-{}{}",
severity_color, edit.range.start_line, edit.range.end_line, reset
)?;
} else {
let preview: String = edit
.new_text
.lines()
.take(3)
.collect::<Vec<_>>()
.join(" | ");
let truncated = if preview.len() > 60 {
format!("{}...", &preview[..60])
} else {
preview
};
writeln!(w, " {}|+ {}{}", severity_color, truncated, reset)?;
}
}
}
}
}
Ok(())
}
fn print_concise<W: Write>(w: &mut W, diagnostics: &[Diagnostic]) -> io::Result<()> {
for diag in diagnostics {
writeln!(
w,
"{}:{}:{}: {} {}",
diag.file.display(),
diag.range.start_line,
diag.range.start_col,
diag.rule,
diag.message
)?;
}
Ok(())
}
#[derive(Serialize)]
struct JsonDiagnostic {
code: String,
message: String,
severity: String,
file: String,
line: usize,
column: usize,
end_line: usize,
end_column: usize,
fixable: bool,
}
fn print_json<W: Write>(w: &mut W, diagnostics: &[Diagnostic]) -> io::Result<()> {
let json_diags: Vec<JsonDiagnostic> = diagnostics
.iter()
.map(|d| JsonDiagnostic {
code: d.rule.to_string(),
message: d.message.clone(),
severity: d.severity.to_string(),
file: d.file.display().to_string(),
line: d.range.start_line,
column: d.range.start_col,
end_line: d.range.end_line,
end_column: d.range.end_col,
fixable: d.fix.is_some(),
})
.collect();
let json = serde_json::to_string_pretty(&json_diags)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
writeln!(w, "{}", json)?;
Ok(())
}
fn print_github<W: Write>(w: &mut W, diagnostics: &[Diagnostic]) -> io::Result<()> {
for diag in diagnostics {
let level = match diag.severity {
DiagnosticSeverity::Error => "error",
DiagnosticSeverity::Warning => "warning",
DiagnosticSeverity::Info | DiagnosticSeverity::Hint => "notice",
};
writeln!(
w,
"::{} file={},line={},col={},endLine={},endColumn={}::{}: {}",
level,
diag.file.display(),
diag.range.start_line,
diag.range.start_col,
diag.range.end_line,
diag.range.end_col,
diag.rule,
diag.message
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_preview(
rule: RuleCode,
line_range: (usize, usize),
confidence: FixConfidence,
is_safe: bool,
) -> FixPreview {
FixPreview {
rule,
message: format!("Test fix for {:?}", rule),
line_range,
removed_content: Some("old code".to_string()),
new_content: Some("new code".to_string()),
confidence,
is_safe,
unsafe_reason: if !is_safe {
Some("Test reason".to_string())
} else {
None
},
}
}
#[test]
fn test_dry_run_summary_new() {
let summary = DryRunSummary::new();
assert_eq!(summary.files_affected, 0);
assert_eq!(summary.total_fixes, 0);
assert_eq!(summary.safe_fixes, 0);
assert_eq!(summary.review_required, 0);
}
#[test]
fn test_dry_run_summary_add_fix() {
let mut summary = DryRunSummary::new();
let diag = Diagnostic {
rule: RuleCode::FST001,
severity: DiagnosticSeverity::Warning,
file: PathBuf::from("test.fst"),
range: super::super::rules::Range::new(1, 1, 5, 1),
message: "Test diagnostic".to_string(),
fix: Some(Fix::safe("Test fix", vec![])),
};
let content = "line1\nline2\nline3\nline4\nline5";
summary.add_fix(&diag, content);
summary.finalize();
assert_eq!(summary.total_fixes, 1);
assert_eq!(summary.safe_fixes, 1);
assert_eq!(summary.review_required, 0);
assert_eq!(summary.files_affected, 1);
assert!(summary.by_rule.contains_key(&RuleCode::FST001));
}
#[test]
fn test_dry_run_summary_counts_unsafe_as_review() {
let mut summary = DryRunSummary::new();
let diag = Diagnostic {
rule: RuleCode::FST005,
severity: DiagnosticSeverity::Warning,
file: PathBuf::from("test.fst"),
range: super::super::rules::Range::new(1, 1, 5, 1),
message: "Test diagnostic".to_string(),
fix: Some(Fix::new("Test fix", vec![]).with_confidence(FixConfidence::Medium)),
};
let content = "line1\nline2\nline3\nline4\nline5";
summary.add_fix(&diag, content);
summary.finalize();
assert_eq!(summary.total_fixes, 1);
assert_eq!(summary.safe_fixes, 0);
assert_eq!(summary.review_required, 1);
}
#[test]
fn test_dry_run_header_output() {
let mut output = Vec::new();
print_dry_run_header(&mut output).expect("Failed to write header");
let result = String::from_utf8(output).expect("Invalid UTF-8");
assert!(result.contains("DRY RUN"));
assert!(result.contains("No files will be modified"));
assert!(result.contains("\u{2554}")); assert!(result.contains("\u{2557}")); }
#[test]
fn test_apply_header_output() {
let mut output = Vec::new();
print_apply_header(&mut output).expect("Failed to write header");
let result = String::from_utf8(output).expect("Invalid UTF-8");
assert!(result.contains("APPLYING CHANGES"));
assert!(result.contains("Files will be modified"));
}
#[test]
fn test_fix_preview_full_output() {
let mut output = Vec::new();
let file = PathBuf::from("/test/path/file.fst");
let preview = create_test_preview(RuleCode::FST001, (10, 15), FixConfidence::High, true);
print_fix_preview_full(&mut output, &file, &preview).expect("Failed to write preview");
let result = String::from_utf8(output).expect("Invalid UTF-8");
assert!(result.contains("file.fst"));
assert!(result.contains("FST001"));
assert!(result.contains("10"));
assert!(result.contains("15"));
assert!(result.contains("HIGH"));
}
#[test]
fn test_fix_preview_concise_output() {
let mut output = Vec::new();
let file = PathBuf::from("/test/path/file.fst");
let preview = create_test_preview(RuleCode::FST004, (5, 5), FixConfidence::High, true);
print_fix_preview_concise(&mut output, &file, &preview).expect("Failed to write preview");
let result = String::from_utf8(output).expect("Invalid UTF-8");
assert_eq!(result.lines().count(), 1);
assert!(result.contains("file.fst"));
assert!(result.contains("FST004"));
assert!(result.contains("*"));
assert!(result.contains("[H]"));
}
#[test]
fn test_fix_preview_concise_unsafe() {
let mut output = Vec::new();
let file = PathBuf::from("/test/path/file.fst");
let preview = create_test_preview(RuleCode::FST005, (5, 5), FixConfidence::Low, false);
print_fix_preview_concise(&mut output, &file, &preview).expect("Failed to write preview");
let result = String::from_utf8(output).expect("Invalid UTF-8");
assert!(result.contains("!"));
assert!(result.contains("[L]"));
}
#[test]
fn test_dry_run_summary_output() {
let mut summary = DryRunSummary::new();
summary.files_affected = 5;
summary.total_fixes = 10;
summary.safe_fixes = 7;
summary.review_required = 3;
summary.lines_removed = 50;
summary.lines_added = 30;
summary.by_rule.insert(RuleCode::FST001, 4);
summary.by_rule.insert(RuleCode::FST004, 6);
let mut output = Vec::new();
print_dry_run_summary(&mut output, &summary).expect("Failed to write summary");
let result = String::from_utf8(output).expect("Invalid UTF-8");
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"));
assert!(result.contains("FST004"));
}
#[test]
fn test_apply_instructions_output() {
let mut output = Vec::new();
print_apply_instructions(&mut output, "fstar-lsp fix src/ --apply")
.expect("Failed to write instructions");
let result = String::from_utf8(output).expect("Invalid UTF-8");
assert!(result.contains("fstar-lsp fix src/ --apply"));
assert!(result.contains("To apply these changes"));
}
#[test]
fn test_dry_run_format_enum() {
let _concise = DryRunFormat::Concise;
let _full = DryRunFormat::Full;
let _json = DryRunFormat::Json;
let default: DryRunFormat = Default::default();
assert!(matches!(default, DryRunFormat::Full));
}
#[test]
fn test_fix_preview_with_no_new_content() {
let preview = FixPreview {
rule: RuleCode::FST001,
message: "Delete duplicate type".to_string(),
line_range: (10, 15),
removed_content: Some("type old = int".to_string()),
new_content: None, confidence: FixConfidence::High,
is_safe: true,
unsafe_reason: None,
};
let mut output = Vec::new();
let file = PathBuf::from("test.fst");
print_fix_preview_full(&mut output, &file, &preview).expect("Failed to write preview");
let result = String::from_utf8(output).expect("Invalid UTF-8");
assert!(result.contains("deleted"));
}
#[test]
fn test_create_fix_preview_helper() {
let diag = Diagnostic {
rule: RuleCode::FST001,
severity: DiagnosticSeverity::Warning,
file: PathBuf::from("test.fst"),
range: super::super::rules::Range::new(5, 1, 10, 1),
message: "Duplicate type".to_string(),
fix: Some(Fix::safe("Remove duplicate", vec![super::super::rules::Edit {
file: PathBuf::from("test.fst"),
range: super::super::rules::Range::new(5, 1, 10, 1),
new_text: "".to_string(),
}])),
};
let content = "line1\nline2\nline3\nline4\ntype x = int\ntype x = int\nline7\nline8\nline9\nline10";
let preview = create_fix_preview(&diag, diag.fix.as_ref().unwrap(), content);
assert_eq!(preview.rule, RuleCode::FST001);
assert_eq!(preview.line_range, (5, 10));
assert!(preview.removed_content.is_some());
assert!(preview.new_content.is_none()); }
#[test]
fn test_lines_counting() {
let mut summary = DryRunSummary::new();
let diag = Diagnostic {
rule: RuleCode::FST001,
severity: DiagnosticSeverity::Warning,
file: PathBuf::from("test.fst"),
range: super::super::rules::Range::new(1, 1, 3, 1),
message: "Test".to_string(),
fix: Some(Fix::safe("Fix", vec![super::super::rules::Edit {
file: PathBuf::from("test.fst"),
range: super::super::rules::Range::new(1, 1, 3, 1),
new_text: "new line 1\nnew line 2".to_string(),
}])),
};
let content = "line1\nline2\nline3";
summary.add_fix(&diag, content);
assert_eq!(summary.lines_removed, 3);
assert_eq!(summary.lines_added, 2);
}
}