diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index 78c1a8c..0df7f10 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -5,3 +5,9 @@ edition = "2021" [dependencies] walkdir = "2.0" +anyhow = "1.0" +colored = "2.0" +dialoguer = "0.11" +similar = "2.6" +gasguard-auto-fix = { path = "../../libs/auto-fix" } +gasguard-engine = { path = "../../libs/engine" } diff --git a/packages/cli/src/lib.rs b/packages/cli/src/lib.rs index d0ae0b6..2cf7282 100644 --- a/packages/cli/src/lib.rs +++ b/packages/cli/src/lib.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use std::time::Instant; pub mod transformers; +pub mod wizard; pub fn collect_scannable_files(dir_path: &Path) -> Vec { let mut files: Vec = walkdir::WalkDir::new(dir_path) diff --git a/packages/cli/src/wizard/interactive_diff.rs b/packages/cli/src/wizard/interactive_diff.rs new file mode 100644 index 0000000..18f18cb --- /dev/null +++ b/packages/cli/src/wizard/interactive_diff.rs @@ -0,0 +1,41 @@ +use colored::Colorize; + +pub struct DiffPreview<'a> { + pub file_path: &'a str, + pub line_number: usize, + pub before_context: Option<&'a str>, + pub original_line: &'a str, + pub suggested_line: &'a str, + pub after_context: Option<&'a str>, +} + +pub fn render_git_style_diff(preview: &DiffPreview<'_>) -> String { + let mut output = String::new(); + output.push_str(&format!("{}\n", format!("--- a/{}", preview.file_path).red())); + output.push_str(&format!("{}\n", format!("+++ b/{}", preview.file_path).green())); + output.push_str(&format!( + "{}\n", + format!( + "@@ -{},1 +{},1 @@", + preview.line_number, preview.line_number + ) + .cyan() + .bold() + )); + + if let Some(line) = preview.before_context { + output.push_str(&format!(" {}\n", line.dimmed())); + } + + output.push_str(&format!("{}\n", format!("-{}", preview.original_line).red())); + output.push_str(&format!( + "{}\n", + format!("+{}", preview.suggested_line).green() + )); + + if let Some(line) = preview.after_context { + output.push_str(&format!(" {}\n", line.dimmed())); + } + + output +} diff --git a/packages/cli/src/wizard/mod.rs b/packages/cli/src/wizard/mod.rs new file mode 100644 index 0000000..4c8426e --- /dev/null +++ b/packages/cli/src/wizard/mod.rs @@ -0,0 +1,240 @@ +mod interactive_diff; + +use anyhow::{anyhow, Context, Result}; +use colored::Colorize; +use dialoguer::{theme::ColorfulTheme, Confirm, Select}; +use gasguard_auto_fix::{FixEngine, FixPreview}; +use gasguard_engine::ContractScanner; +use gasguard_rule_engine::RuleViolation; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::collect_scannable_files; +use interactive_diff::{render_git_style_diff, DiffPreview}; + +#[derive(Debug, Clone)] +struct WizardIssue { + file_path: PathBuf, + violation: RuleViolation, + preview: FixPreview, +} + +#[derive(Debug, Default, Clone)] +pub struct WizardSummary { + pub reviewed: usize, + pub applied: usize, + pub skipped: usize, + pub files_modified: usize, + pub aborted: bool, +} + +pub fn run_interactive_fix_wizard(path: &Path, scanner: &ContractScanner) -> Result { + let fix_engine = FixEngine::new(); + let issues = collect_fixable_issues(path, scanner, &fix_engine)?; + + if issues.is_empty() { + println!( + "{}", + "No safe auto-fix opportunities found for interactive mode." + .green() + .bold() + ); + return Ok(WizardSummary::default()); + } + + println!( + "{}", + format!( + "Interactive mode loaded {} fixable issue(s). Review each preview and choose what to apply.", + issues.len() + ) + .bold() + ); + + let theme = ColorfulTheme::default(); + let mut summary = WizardSummary::default(); + let mut selected_by_file: BTreeMap> = BTreeMap::new(); + + for (index, issue) in issues.iter().enumerate() { + summary.reviewed += 1; + + println!(); + println!( + "{}", + format!( + "[{}/{}] {}:{}", + index + 1, + issues.len(), + issue.file_path.display(), + issue.preview.line_number + ) + .bold() + ); + println!("Rule: {}", issue.preview.rule_name.yellow().bold()); + println!("Why: {}", issue.preview.description); + println!( + "Confidence: {}", + format!("{:.0}%", issue.preview.confidence * 100.0).cyan() + ); + + let file_contents = fs::read_to_string(&issue.file_path) + .with_context(|| format!("Failed to read {}", issue.file_path.display()))?; + let file_lines: Vec<&str> = file_contents.lines().collect(); + let line_index = issue.preview.line_number.saturating_sub(1); + let before_context = line_index + .checked_sub(1) + .and_then(|idx| file_lines.get(idx)) + .copied(); + let after_context = file_lines.get(line_index + 1).copied(); + + let diff = render_git_style_diff(&DiffPreview { + file_path: &issue.file_path.to_string_lossy(), + line_number: issue.preview.line_number, + before_context, + original_line: &issue.preview.original_line, + suggested_line: &issue.preview.suggested_line, + after_context, + }); + println!("{}", diff); + + let selection = Select::with_theme(&theme) + .with_prompt("Choose an action") + .items(&[ + "Apply this optimization", + "Skip this optimization", + "Apply all remaining safe optimizations in this file", + "Quit wizard", + ]) + .default(0) + .interact()?; + + match selection { + 0 => { + selected_by_file + .entry(issue.file_path.clone()) + .or_default() + .push(issue.violation.clone()); + summary.applied += 1; + } + 1 => { + summary.skipped += 1; + } + 2 => { + let pending_for_file = issues[index..] + .iter() + .filter(|candidate| candidate.file_path == issue.file_path) + .map(|candidate| candidate.violation.clone()) + .collect::>(); + + let remaining_count = pending_for_file.len(); + selected_by_file + .entry(issue.file_path.clone()) + .or_default() + .extend(pending_for_file); + summary.applied += remaining_count; + + let skipped_after_this = issues[index + remaining_count..] + .iter() + .take_while(|candidate| candidate.file_path == issue.file_path) + .count(); + summary.skipped += skipped_after_this; + } + _ => { + summary.aborted = true; + summary.skipped += issues.len().saturating_sub(index + 1); + break; + } + } + } + + if selected_by_file.is_empty() { + println!("{}", "No changes selected.".yellow()); + return Ok(summary); + } + + if !Confirm::with_theme(&theme) + .with_prompt("Write the selected changes to disk?") + .default(true) + .interact()? + { + println!("{}", "Aborted before writing any changes.".yellow()); + summary.aborted = true; + return Ok(summary); + } + + for (file_path, accepted) in selected_by_file { + let original = fs::read_to_string(&file_path) + .with_context(|| format!("Failed to read {}", file_path.display()))?; + let updated = fix_engine + .apply_fixes(&file_path, &accepted) + .with_context(|| format!("Failed to generate fixes for {}", file_path.display()))?; + + if updated == original { + continue; + } + + fs::write(&file_path, &updated) + .with_context(|| format!("Failed to write {}", file_path.display()))?; + + if let Err(error) = scanner.scan_file(&file_path) { + fs::write(&file_path, original).with_context(|| { + format!("Failed to restore {} after validation error", file_path.display()) + })?; + return Err(anyhow!( + "Refusing to keep changes in {} because the updated file failed validation: {}", + file_path.display(), + error + )); + } + + summary.files_modified += 1; + } + + Ok(summary) +} + +fn collect_fixable_issues( + path: &Path, + scanner: &ContractScanner, + fix_engine: &FixEngine, +) -> Result> { + let mut issues = Vec::new(); + let files = if path.is_dir() { + collect_scannable_files(path) + } else { + vec![path.to_path_buf()] + }; + + for file_path in files { + let result = scanner.scan_file(&file_path).with_context(|| { + format!("Failed to scan {} for interactive fixes", file_path.display()) + })?; + let safe_violations = fix_engine.validate_and_filter_fixes(&result.violations); + if safe_violations.is_empty() { + continue; + } + + let report = fix_engine + .preview_fixes(&file_path, &safe_violations) + .with_context(|| format!("Failed to build preview for {}", file_path.display()))?; + + for (violation, preview) in safe_violations + .into_iter() + .zip(report.previews.into_iter()) + { + issues.push(WizardIssue { + file_path: file_path.clone(), + violation, + preview, + }); + } + } + + issues.sort_by(|left, right| { + left.file_path + .cmp(&right.file_path) + .then(left.preview.line_number.cmp(&right.preview.line_number)) + }); + Ok(issues) +}