ai-refactor-cli 0.2.0

Rule-based legacy code refactoring CLI (TypeScript any / Python typing / Django FBV→CBV). Complement to general AI coding agents.
Documentation
//! ai-refactor: rule-based legacy code refactoring CLI.
//!
//! v0.2.0 ships:
//!   - tree-sitter AST-backed detection for Python (no regex false positives)
//!   - Real `--apply` for `django-fbv` → CBV conversion
//!   - Benchmark harness (`cargo bench`)
//!
//! Three detection rules:
//!   1. typescript-no-any     — finds `: any`, `<any>`, `any[]`, `Array<any>` (regex, v0.1.0)
//!   2. python-missing-typing — finds `def foo(x):` without param annotations (AST, v0.2.0)
//!   3. django-fbv            — finds top-level `def view(request, ...)` FBVs (AST, v0.2.0)

use anyhow::{bail, Result};
use clap::{Parser, Subcommand, ValueEnum};

use ai_refactor_cli::ast;
use ai_refactor_cli::detectors;
use ai_refactor_cli::report;
use ai_refactor_cli::scanner;

/// Top-level CLI.
#[derive(Parser, Debug)]
#[command(
    name = "ai-refactor",
    version,
    about = "Rule-based legacy code refactoring CLI",
    long_about = "Scan and rewrite legacy code patterns using tree-sitter AST analysis. \
                  v0.2.0 adds real --apply for Django FBV→CBV conversion."
)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand, Debug)]
enum Command {
    /// Scan a path for legacy patterns and emit a report.
    Scan {
        /// Path to scan (file or directory).
        path: String,

        /// Output format.
        #[arg(long, value_enum, default_value_t = Format::Text)]
        format: Format,

        /// Restrict to a single rule (omit to run all).
        #[arg(long)]
        rule: Option<String>,
    },

    /// Fix detected patterns in-place.
    ///
    /// Without --apply: detects and reports only (dry-run).
    /// With --apply:    atomically rewrites files and writes .bak backups.
    ///
    /// Currently supported rules for --apply:
    ///   django-fbv  — converts top-level FBVs to CBV (GET-only, typical pattern)
    Fix {
        /// Path to scan (file or directory).
        path: String,

        /// Rule to apply. Supported: `typescript-no-any`, `python-missing-typing`, `django-fbv`.
        #[arg(long)]
        rule: String,

        /// Actually write changes to disk (.bak backup created first).
        #[arg(long, default_value_t = false)]
        apply: bool,

        /// Preview changes without writing. Alias for omitting --apply.
        #[arg(long, default_value_t = false)]
        dry_run: bool,
    },
}

#[derive(Copy, Clone, Debug, ValueEnum)]
enum Format {
    Text,
    Json,
}

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();
    match cli.command {
        Command::Scan { path, format, rule } => {
            let findings = scanner::scan_path(&path, rule.as_deref())?;
            match format {
                Format::Text => report::print_text(&findings),
                Format::Json => report::print_json(&findings)?,
            }
            if !findings.is_empty() {
                std::process::exit(1);
            }
        }
        Command::Fix {
            path,
            rule,
            apply,
            dry_run,
        } => {
            let do_apply = apply && !dry_run;

            // Always show findings first.
            let findings = scanner::scan_path(&path, Some(&rule))?;
            report::print_text(&findings);

            if findings.is_empty() {
                return Ok(());
            }

            if !do_apply {
                eprintln!(
                    "[ai-refactor] Dry-run mode: {} finding(s) detected. \
                     Re-run with --apply to write changes.",
                    findings.len()
                );
                std::process::exit(1);
            }

            // --apply: dispatch to the rule-specific transformer.
            match rule.as_str() {
                "django-fbv" => {
                    apply_django_fbv(&path)?;
                }
                other => {
                    bail!(
                        "--apply is not yet implemented for rule `{}`. \
                         Supported: django-fbv",
                        other
                    );
                }
            }

            if !findings.is_empty() {
                std::process::exit(1);
            }
        }
    }
    Ok(())
}

/// Apply the django-fbv → CBV transformation to all Python files under `path`.
fn apply_django_fbv(path: &str) -> Result<()> {
    use std::path::Path;
    use walkdir::WalkDir;

    let mut py_parser = ast::python::make_parser()?;

    let root = Path::new(path);
    let walker: Box<dyn Iterator<Item = walkdir::DirEntry>> = if root.is_file() {
        Box::new(WalkDir::new(root).into_iter().filter_map(|e| e.ok()))
    } else {
        Box::new(
            WalkDir::new(root)
                .into_iter()
                .filter_entry(|e| {
                    e.depth() == 0
                        || !{
                            let name = e.file_name().to_string_lossy();
                            name.starts_with('.')
                                || matches!(
                                    name.as_ref(),
                                    "node_modules"
                                        | "target"
                                        | "dist"
                                        | "build"
                                        | "__pycache__"
                                        | "venv"
                                        | ".venv"
                                )
                        }
                })
                .filter_map(|e| e.ok()),
        )
    };

    for entry in walker {
        if !entry.file_type().is_file() {
            continue;
        }
        if entry.path().extension().and_then(|s| s.to_str()) != Some("py") {
            continue;
        }
        let file_path = entry.path().display().to_string();
        let result = detectors::django::fbv_to_cbv::apply(&file_path, &mut py_parser)?;

        for msg in &result.rewrites_applied {
            println!("[ai-refactor] Applied: {}", msg);
        }
        for warn in &result.warnings {
            eprintln!("[ai-refactor] Warning: {}", warn);
        }
        if let Some(bak) = &result.backup_path {
            println!("[ai-refactor] Backup written to {}", bak);
        }
    }
    Ok(())
}