use crate::utils::types::{LintIssue, RunResult, Severity};
use colored::Colorize;
use std::collections::HashSet;
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use super::ai_fix::{run_ai_fix_all, run_ai_fix_single, AiFixConfig};
use super::editor::{open_in_editor, LineChange};
use super::nolint::{add_nolint_comment, describe_nolint_action, LineDiff, NolintResult};
use super::quickfix::{default_quickfix_path, write_quickfix_file};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InteractiveAction {
Edit,
Ignore,
Skip,
Previous,
GoTo(usize),
AiFix,
Quit,
}
#[derive(Debug, Default)]
pub struct InteractiveResult {
pub edited: usize,
pub ignored: usize,
pub skipped: usize,
pub quit_early: bool,
pub modified_files: HashSet<PathBuf>,
}
pub fn run_interactive(result: &RunResult) -> InteractiveResult {
let issues = &result.issues;
if issues.is_empty() {
println!("{}", "No issues to review.".green());
return InteractiveResult::default();
}
loop {
match show_main_menu(result) {
MainMenuChoice::ReviewOneByOne => {
return run_issue_review(issues);
}
MainMenuChoice::OpenInQuickfix => {
if let Err(e) = open_quickfix(issues) {
eprintln!("{}: {}", "Error".red(), e);
} else {
println!("{} Quickfix file created", "✓".green());
}
}
MainMenuChoice::AiFixAll => {
let ai_config = AiFixConfig::default();
let ai_result = run_ai_fix_all(result, &ai_config);
return InteractiveResult {
edited: ai_result.applied,
ignored: 0,
skipped: ai_result.skipped,
quit_early: ai_result.quit_early,
modified_files: ai_result.modified_files,
};
}
MainMenuChoice::Exit => {
return InteractiveResult::default();
}
}
}
}
#[derive(Debug, Clone, Copy)]
enum MainMenuChoice {
ReviewOneByOne,
OpenInQuickfix,
AiFixAll,
Exit,
}
fn show_main_menu(result: &RunResult) -> MainMenuChoice {
let issues = &result.issues;
let error_count = issues
.iter()
.filter(|i| i.severity == Severity::Error)
.count();
let warning_count = issues
.iter()
.filter(|i| i.severity == Severity::Warning)
.count();
let info_count = issues
.iter()
.filter(|i| i.severity == Severity::Info)
.count();
println!();
println!("{}", "═".repeat(60).dimmed());
println!(
" Found {} issue{} ({} error{}, {} warning{})",
issues.len().to_string().bold(),
if issues.len() == 1 { "" } else { "s" },
error_count.to_string().red(),
if error_count == 1 { "" } else { "s" },
warning_count.to_string().yellow(),
if warning_count == 1 { "" } else { "s" },
);
if info_count > 0 {
println!(" {} info", info_count.to_string().blue());
}
println!("{}", "═".repeat(60).dimmed());
println!();
println!(" [{}] Review issues one by one (interactive)", "1".cyan());
println!(" [{}] Open all in editor (vim quickfix)", "2".cyan());
println!(" [{}] AI Fix - get AI-powered suggestions", "3".cyan());
println!(" [{}] Exit", "4".cyan());
println!();
println!(" {}", "Vim quickfix shortcuts:".dimmed());
println!(
" {} - next issue {} - previous {} - list all",
":cn".cyan(),
":cp".cyan(),
":copen".cyan()
);
println!();
print!(" > ");
io::stdout().flush().ok();
let choice = read_line().trim().to_lowercase();
match choice.as_str() {
"1" => MainMenuChoice::ReviewOneByOne,
"2" => MainMenuChoice::OpenInQuickfix,
"3" | "ai" => MainMenuChoice::AiFixAll,
"4" | "q" | "quit" | "exit" => MainMenuChoice::Exit,
_ => {
println!("{}", "Invalid choice, please try again.".yellow());
show_main_menu(result)
}
}
}
fn run_issue_review(issues: &[LintIssue]) -> InteractiveResult {
let mut result = InteractiveResult::default();
let total = issues.len();
let mut idx: usize = 0;
let mut processed = vec![false; total];
while idx < total {
let issue = &issues[idx];
let action = show_issue_menu(issue, idx + 1, total);
match action {
InteractiveAction::Edit => {
processed[idx] = true;
handle_edit_action(issue, &mut result);
idx += 1;
}
InteractiveAction::Ignore => {
processed[idx] = true;
handle_ignore_action(issue, &mut result);
idx += 1;
}
InteractiveAction::AiFix => {
processed[idx] = true;
handle_ai_fix_action(issue, &mut result);
idx += 1;
}
InteractiveAction::Skip => {
result.skipped += 1;
processed[idx] = true;
idx += 1;
}
InteractiveAction::Previous => {
if idx > 0 {
idx -= 1;
if processed[idx] {
println!("{}", " (Revisiting previously processed issue)".dimmed());
}
} else {
println!("{}", " Already at first issue".yellow());
}
}
InteractiveAction::GoTo(target) => {
if target > 0 && target <= total {
idx = target - 1;
if processed[idx] {
println!("{}", " (Jumping to previously processed issue)".dimmed());
}
} else {
println!(
" {} Issue #{} out of range (1-{})",
"Invalid:".yellow(),
target,
total
);
}
}
InteractiveAction::Quit => {
result.quit_early = true;
for &was_processed in &processed[idx..] {
if !was_processed {
result.skipped += 1;
}
}
break;
}
}
}
print_review_summary(&result);
result
}
fn handle_edit_action(issue: &LintIssue, result: &mut InteractiveResult) {
result.edited += 1;
let editor_result = open_in_editor(&issue.file_path, issue.line, issue.column);
if editor_result.success {
println!();
if editor_result.changes.is_empty() {
println!(" {}", "No changes made".dimmed());
} else {
print_editor_changes(&editor_result.changes, &issue.file_path);
result.modified_files.insert(issue.file_path.clone());
}
} else if let Some(ref error) = editor_result.error {
eprintln!("{}: {}", "Failed to open editor".red(), error);
}
}
fn handle_ignore_action(issue: &LintIssue, result: &mut InteractiveResult) {
match add_nolint_comment(issue) {
NolintResult::Success(diffs) => {
result.ignored += 1;
println!("{} Added NOLINT comment", "✓".green());
println!();
print_diff(&diffs, &issue.file_path);
result.modified_files.insert(issue.file_path.clone());
}
NolintResult::AlreadyIgnored => {
println!("{}", "Already has NOLINT comment".yellow());
result.skipped += 1;
}
NolintResult::Error(e) => {
eprintln!("{}: {}", "Failed to add NOLINT".red(), e);
result.skipped += 1;
}
}
}
fn handle_ai_fix_action(issue: &LintIssue, result: &mut InteractiveResult) {
let ai_config = AiFixConfig::default();
match run_ai_fix_single(issue, &ai_config) {
Ok((applied, modified_files)) => {
if applied {
result.edited += 1;
result.modified_files.extend(modified_files);
} else {
result.skipped += 1;
}
}
Err(e) => {
eprintln!("{}: {}", "AI fix error".red(), e);
result.skipped += 1;
}
}
}
fn print_review_summary(result: &InteractiveResult) {
println!();
println!("{}", "═".repeat(60).dimmed());
println!(" {}", "Interactive Review Summary".bold());
println!("{}", "─".repeat(60).dimmed());
println!(" Edited: {}", result.edited.to_string().cyan());
println!(" Ignored: {}", result.ignored.to_string().yellow());
println!(" Skipped: {}", result.skipped.to_string().dimmed());
if result.quit_early {
println!(" {}", "(Quit early)".dimmed());
}
println!("{}", "═".repeat(60).dimmed());
println!();
}
fn show_issue_menu(issue: &LintIssue, current: usize, total: usize) -> InteractiveAction {
display_issue_header(issue, current, total);
display_issue_actions(issue, current, total);
let choice = read_line().trim().to_lowercase();
match choice.as_str() {
"e" | "edit" => InteractiveAction::Edit,
"i" | "ignore" => InteractiveAction::Ignore,
"a" | "ai" | "aifix" | "ai-fix" => InteractiveAction::AiFix,
"s" | "skip" | "" => InteractiveAction::Skip,
"p" | "prev" | "previous" => InteractiveAction::Previous,
"q" | "quit" => InteractiveAction::Quit,
input if input.starts_with("g") => parse_goto_input(input)
.unwrap_or_else(|| show_issue_menu(issue, current, total)),
_ => {
println!("{}", "Invalid choice. Use: e/i/s/p/g/q".yellow());
show_issue_menu(issue, current, total)
}
}
}
fn display_issue_header(issue: &LintIssue, current: usize, total: usize) {
println!();
println!("{}", "─".repeat(60).dimmed());
let severity_badge = match issue.severity {
Severity::Error => format!("[E{}]", current).red().bold(),
Severity::Warning => format!("[W{}]", current).yellow().bold(),
Severity::Info => format!("[I{}]", current).blue(),
};
let lang_tag = issue
.language
.map(|l| format!("[{}]", format!("{:?}", l).to_lowercase()))
.unwrap_or_default()
.dimmed();
let source_tag = issue
.source
.as_ref()
.map(|s| format!("[{}]", s))
.unwrap_or_default()
.dimmed();
let location = if let Some(col) = issue.column {
format!("{}:{}:{}", issue.file_path.display(), issue.line, col)
} else {
format!("{}:{}", issue.file_path.display(), issue.line)
};
let progress = format!("({}/{})", current, total).dimmed();
println!(
" {} {}{} {} {}",
severity_badge,
lang_tag,
source_tag,
location.white().bold(),
progress
);
print_code_context(issue);
if let Some(ref code) = issue.code {
println!(" {} ({})", issue.message, code.cyan());
} else {
println!(" {}", issue.message);
}
if let Some(ref suggestion) = issue.suggestion {
println!(" {} {}", "-->".green(), suggestion);
}
}
fn display_issue_actions(issue: &LintIssue, current: usize, total: usize) {
println!();
println!(" {}", format!("Issue {}/{}", current, total).bold().cyan());
println!();
let nolint_desc = describe_nolint_action(issue);
println!(" [{}] Edit - open $EDITOR at this line", "e".cyan());
println!(" [{}] Ignore - {}", "i".cyan(), nolint_desc.dimmed());
println!(
" [{}] AI fix - get AI suggestion for this issue",
"a".cyan()
);
println!(" [{}] Skip", "s".cyan());
if current > 1 {
println!(
" [{}] Previous - go back to issue #{}",
"p".cyan(),
current - 1
);
}
println!(" [{}] Go to #N - jump to specific issue", "g".cyan());
println!(" [{}] Quit", "q".cyan());
println!();
print!(" > ");
io::stdout().flush().ok();
}
fn parse_goto_input(input: &str) -> Option<InteractiveAction> {
let parts: Vec<&str> = input.split_whitespace().collect();
if parts.len() >= 2 {
if let Ok(num) = parts[1].parse::<usize>() {
return Some(InteractiveAction::GoTo(num));
}
println!("{}", "Invalid issue number".yellow());
return None;
}
print!(" {} ", "Go to issue #:".cyan());
io::stdout().flush().ok();
let num_input = read_line().trim().to_string();
if let Ok(num) = num_input.parse::<usize>() {
Some(InteractiveAction::GoTo(num))
} else {
println!("{}", "Invalid issue number".yellow());
None
}
}
pub(crate) fn print_code_context(issue: &LintIssue) {
for (line_num, content) in &issue.context_before {
println!(
" {} {}",
format!("{:>5} |", line_num).dimmed(),
content.dimmed()
);
}
if let Some(ref code_line) = issue.code_line {
println!(
" {} {} {}",
">".red().bold(),
format!("{:>5} |", issue.line).dimmed(),
code_line
);
if let Some(col) = issue.column {
let padding = " ".repeat(col.saturating_sub(1));
println!(
" {} {}{}",
" |".dimmed(),
padding,
"^".red().bold()
);
}
}
for (line_num, content) in &issue.context_after {
println!(
" {} {}",
format!("{:>5} |", line_num).dimmed(),
content.dimmed()
);
}
}
fn open_quickfix(issues: &[LintIssue]) -> super::InteractiveResult<()> {
use super::InteractiveError;
let path = default_quickfix_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
InteractiveError::FileOperation(format!(
"Failed to create directory '{}': {}",
parent.display(),
e
))
})?;
}
write_quickfix_file(issues, &path)?;
println!(
"{} Quickfix file written to: {}",
"✓".green(),
path.display()
);
println!();
let editor = detect_quickfix_editor();
if let Some(editor_cmd) = editor {
println!("Opening in {}...", editor_cmd.cyan());
println!(" Use {} to jump between issues", ":cn / :cp".cyan());
println!();
match launch_quickfix_editor(&editor_cmd, &path) {
Ok(()) => {
println!();
println!("{} Editor closed", "✓".green());
}
Err(e) => {
eprintln!("{}: {}", "Failed to open editor".red(), e);
println!();
show_manual_quickfix_instructions(&path);
}
}
} else {
show_manual_quickfix_instructions(&path);
}
Ok(())
}
fn show_manual_quickfix_instructions(path: &std::path::Path) {
println!("To open in vim:");
println!(" {} {}", "vim -q".cyan(), path.display());
println!();
println!("Or load in vim with:");
println!(" {} {}", ":cfile".cyan(), path.display());
println!();
println!("{}", "Quickfix shortcuts in vim:".bold());
println!(" {} - Jump to next issue", ":cn".cyan());
println!(" {} - Jump to previous issue", ":cp".cyan());
println!(" {} - Open quickfix window", ":copen".cyan());
println!(" {} - Close quickfix window", ":cclose".cyan());
println!(" {} - Jump to issue #N", ":cc N".cyan());
}
fn detect_quickfix_editor() -> Option<String> {
let vim_editors = [
"nvim", "vim", "vi", ];
if let Ok(editor) = std::env::var("EDITOR") {
let editor_lower = editor.to_lowercase();
if editor_lower.contains("vim")
|| editor_lower.contains("nvim")
|| editor_lower.contains("vi")
{
return Some(editor);
}
}
for editor in &vim_editors {
if which_exists(editor) {
return Some(editor.to_string());
}
}
None
}
fn which_exists(cmd: &str) -> bool {
use std::process::{Command, Stdio};
#[cfg(windows)]
let which_cmd = "where";
#[cfg(not(windows))]
let which_cmd = "which";
Command::new(which_cmd)
.arg(cmd)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn launch_quickfix_editor(editor: &str, path: &std::path::Path) -> super::InteractiveResult<()> {
use super::InteractiveError;
use std::process::Command;
let mut cmd = Command::new(editor);
cmd.arg("-q").arg(path);
match cmd.status() {
Ok(status) => {
if status.success() {
Ok(())
} else {
Err(InteractiveError::EditorLaunch {
editor: editor.to_string(),
message: format!("exited with status: {}", status.code().unwrap_or(-1)),
})
}
}
Err(e) => Err(InteractiveError::EditorLaunch {
editor: editor.to_string(),
message: e.to_string(),
}),
}
}
pub(crate) fn print_diff(diffs: &[LineDiff], _file_path: &PathBuf) {
println!(" {}", "Changes:".bold());
for diff in diffs {
if let Some(ref context_before) = diff.context_before {
println!(
" {} {}",
format!(" {:>4} |", diff.line_number - 1).dimmed(),
context_before.dimmed()
);
}
if !diff.old_content.is_empty() {
println!(
" {} {}",
format!("-{:>4} |", diff.line_number).red(),
diff.old_content.red()
);
}
println!(
" {} {}",
format!("+{:>4} |", diff.line_number).green(),
diff.new_content.green()
);
if let Some(ref context_after) = diff.context_after {
println!(
" {} {}",
format!(" {:>4} |", diff.line_number + 1).dimmed(),
context_after.dimmed()
);
}
}
println!();
}
fn print_editor_changes(changes: &[LineChange], file_path: &PathBuf) {
use std::fs;
println!(" {}", "Changes:".bold());
let file_content = fs::read_to_string(file_path).ok();
let lines: Vec<String> = file_content
.as_ref()
.map(|content| content.lines().map(|s| s.to_string()).collect())
.unwrap_or_default();
const MAX_CHANGES_SHOWN: usize = 20;
let changes_to_show = if changes.len() > MAX_CHANGES_SHOWN {
&changes[..MAX_CHANGES_SHOWN]
} else {
changes
};
for change in changes_to_show {
let line_idx = change.line_number.saturating_sub(1);
if line_idx > 0 && line_idx <= lines.len() {
if let Some(context_line) = lines.get(line_idx - 1) {
println!(
" {} {}",
format!(" {:>4} |", change.line_number - 1).dimmed(),
context_line.dimmed()
);
}
}
if !change.old_content.is_empty() {
println!(
" {} {}",
format!("-{:>4} |", change.line_number).red(),
change.old_content.red()
);
}
if !change.new_content.is_empty() {
println!(
" {} {}",
format!("+{:>4} |", change.line_number).green(),
change.new_content.green()
);
}
if line_idx + 1 < lines.len() {
if let Some(context_line) = lines.get(line_idx + 1) {
println!(
" {} {}",
format!(" {:>4} |", change.line_number + 1).dimmed(),
context_line.dimmed()
);
}
}
}
if changes.len() > MAX_CHANGES_SHOWN {
println!(
" {} ({} more changes not shown)",
"...".dimmed(),
changes.len() - MAX_CHANGES_SHOWN
);
}
println!();
}
fn read_line() -> String {
let stdin = io::stdin();
let mut line = String::new();
let mut handle = stdin.lock();
handle.read_line(&mut line).ok();
line
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_interactive_result_default() {
let result = InteractiveResult::default();
assert_eq!(result.edited, 0);
assert_eq!(result.ignored, 0);
assert_eq!(result.skipped, 0);
assert!(!result.quit_early);
}
#[test]
fn test_interactive_action_variants() {
assert_ne!(InteractiveAction::Edit, InteractiveAction::Skip);
assert_ne!(InteractiveAction::Ignore, InteractiveAction::Quit);
assert_ne!(InteractiveAction::Previous, InteractiveAction::Skip);
assert_ne!(InteractiveAction::AiFix, InteractiveAction::Edit);
}
#[test]
fn test_interactive_action_goto() {
let goto1 = InteractiveAction::GoTo(1);
let goto2 = InteractiveAction::GoTo(2);
assert_ne!(goto1, goto2);
assert_eq!(goto1, InteractiveAction::GoTo(1));
}
#[test]
fn test_interactive_action_clone() {
let action = InteractiveAction::GoTo(42);
let cloned = action.clone();
assert_eq!(action, cloned);
}
}