repotoire 0.3.47

Graph-powered code analysis CLI. 81 detectors for security, architecture, and code quality.
//! CLI command definitions and handlers

mod analyze;
mod clean;
mod doctor;
mod findings;
mod fix;
mod graph;
mod init;
mod serve;
mod status;
mod tui;

use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;

/// Parse and validate workers count (1-64)
fn parse_workers(s: &str) -> Result<usize, String> {
    let n: usize = s.parse().map_err(|_| format!("'{}' is not a valid number", s))?;
    if n == 0 {
        Err("workers must be at least 1".to_string())
    } else if n > 64 {
        Err("workers cannot exceed 64".to_string())
    } else {
        Ok(n)
    }
}

/// Repotoire - Graph-powered code analysis
///
/// 100% LOCAL - No account needed. No data leaves your machine.
#[derive(Parser, Debug)]
#[command(name = "repotoire")]
#[command(version, about, long_about = None)]
pub struct Cli {
    /// Path to repository (default: current directory)
    #[arg(global = true, default_value = ".")]
    pub path: PathBuf,

    /// Log level (error, warn, info, debug, trace)
    #[arg(long, global = true, default_value = "info", value_parser = ["error", "warn", "info", "debug", "trace"])]
    pub log_level: String,

    /// Number of parallel workers (1-64)
    #[arg(long, global = true, default_value = "8", value_parser = parse_workers)]
    pub workers: usize,

    #[command(subcommand)]
    pub command: Option<Commands>,
}

#[derive(Subcommand, Debug)]
pub enum Commands {
    /// Initialize repository for analysis
    Init,

    /// Analyze codebase for issues
    Analyze {
        /// Output format: text, json, sarif, html, markdown (or md)
        #[arg(long, short = 'f', default_value = "text", value_parser = ["text", "json", "sarif", "html", "markdown", "md"])]
        format: String,

        /// Output file path (default: stdout, or auto-named for html/markdown)
        #[arg(long, short = 'o')]
        output: Option<PathBuf>,

        /// Minimum severity to report (critical, high, medium, low)
        #[arg(long, value_parser = ["critical", "high", "medium", "low"])]
        severity: Option<String>,

        /// Maximum findings to show
        #[arg(long)]
        top: Option<usize>,

        /// Page number (1-indexed) for paginated output
        #[arg(long, default_value = "1")]
        page: usize,

        /// Findings per page (default: 20, 0 = all)
        #[arg(long, default_value = "20")]
        per_page: usize,

        /// Skip specific detectors
        #[arg(long)]
        skip_detector: Vec<String>,

        /// Run thorough analysis (slower)
        #[arg(long)]
        thorough: bool,
        
        /// Relaxed mode: filter to high/critical findings only (display filter, does not affect grade)
        #[arg(long)]
        relaxed: bool,

        /// Skip git history enrichment (faster for large repos)
        #[arg(long)]
        no_git: bool,

        /// Exit with code 1 if findings at this severity or higher exist
        /// Values: critical, high, medium, low (default: none - always exit 0)
        #[arg(long, value_parser = ["critical", "high", "medium", "low"])]
        fail_on: Option<String>,

        /// Disable emoji in output (cleaner for CI logs)
        #[arg(long)]
        no_emoji: bool,
    },

    /// View findings from last analysis
    Findings {
        /// Finding index to show details (e.g., --index 5)
        #[arg(long, short = 'n')]
        index: Option<usize>,

        /// Output as JSON
        #[arg(long)]
        json: bool,

        /// Maximum findings to show
        #[arg(long)]
        top: Option<usize>,

        /// Minimum severity to show (critical, high, medium, low)
        #[arg(long, value_parser = ["critical", "high", "medium", "low"])]
        severity: Option<String>,

        /// Page number (1-indexed)
        #[arg(long, default_value = "1")]
        page: usize,

        /// Findings per page (default: 20, 0 = all)
        #[arg(long, default_value = "20")]
        per_page: usize,

        /// Interactive TUI mode
        #[arg(long, short = 'i')]
        interactive: bool,
    },

    /// Generate AI-powered fix for a finding
    Fix {
        /// Finding index to fix
        index: usize,

        /// Apply fix automatically
        #[arg(long)]
        apply: bool,
    },

    /// Query the code graph directly
    Graph {
        /// Query keyword: functions, classes, files, calls, imports, stats
        query: String,

        /// Output format (json, table)
        #[arg(long, default_value = "table")]
        format: String,
    },

    /// Show graph statistics
    Stats,

    /// Show analysis status
    Status,

    /// Check environment setup
    Doctor,

    /// Remove cached analysis data for a repository
    Clean {
        /// Preview what would be removed without deleting
        #[arg(long)]
        dry_run: bool,
    },

    /// Show version info
    Version,

    /// Start MCP server for AI assistant integration
    Serve {
        /// Force local-only mode (disable PRO API features)
        #[arg(long)]
        local: bool,
    },
}

/// Run the CLI with parsed arguments
pub fn run(cli: Cli) -> Result<()> {
    match cli.command {
        Some(Commands::Init) => init::run(&cli.path),

        Some(Commands::Analyze {
            format,
            output,
            severity,
            top,
            page,
            per_page,
            skip_detector,
            thorough,
            relaxed,
            no_git,
            fail_on,
            no_emoji,
        }) => {
            // In relaxed mode, default to high severity unless explicitly specified
            let effective_severity = if relaxed && severity.is_none() {
                Some("high".to_string())
            } else {
                severity
            };
            analyze::run(&cli.path, &format, output.as_deref(), effective_severity, top, page, per_page, skip_detector, thorough, no_git, cli.workers, fail_on, no_emoji, false, None)
        }

        Some(Commands::Findings { index, json, top, severity, page, per_page, interactive }) => {
            if interactive {
                findings::run_interactive(&cli.path)
            } else {
                findings::run(&cli.path, index, json, top, severity, page, per_page)
            }
        }

        Some(Commands::Fix { index, apply }) => fix::run(&cli.path, index, apply),

        Some(Commands::Graph { query, format }) => graph::run(&cli.path, &query, &format),

        Some(Commands::Stats) => graph::stats(&cli.path),

        Some(Commands::Status) => status::run(&cli.path),

        Some(Commands::Doctor) => doctor::run(),

        Some(Commands::Clean { dry_run }) => clean::run(&cli.path, dry_run),

        Some(Commands::Version) => {
            println!("repotoire {}", env!("CARGO_PKG_VERSION"));
            Ok(())
        }

        Some(Commands::Serve { local }) => serve::run(&cli.path, local),

        None => {
            // Check if the path looks like an unknown subcommand
            let path_str = cli.path.to_string_lossy();
            if !cli.path.exists() && !path_str.contains('/') && !path_str.contains('\\') && !path_str.starts_with('.') {
                // Looks like user tried to use an unknown subcommand
                let known_commands = ["init", "analyze", "findings", "fix", "graph", "stats", "status", "doctor", "clean", "version", "serve"];
                if !known_commands.contains(&path_str.as_ref()) {
                    anyhow::bail!(
                        "Unknown command '{}'. Run 'repotoire --help' for available commands.\n\nDid you mean one of: {}?",
                        path_str,
                        known_commands.join(", ")
                    );
                }
            }
            // Default: run analyze with pagination (page 1, 20 per page)
            analyze::run(&cli.path, "text", None, None, None, 1, 20, vec![], false, false, cli.workers, None, false, false, None)
        }
    }
}