bcore-mutation 1.1.0

Mutation testing tool for Bitcoin Core
Documentation
use clap::{Parser, Subcommand};
use std::collections::HashMap;
use std::path::PathBuf;

mod analyze;
mod ast_analysis;
mod commands;
mod coverage;
mod db;
mod error;
mod git_changes;
mod mutation;
mod operators;
mod project;
mod report;

use error::{MutationError, Result};
use project::Project;

#[derive(Parser)]
#[command(name = "bcore-mutation")]
#[command(about = "Mutation testing tool designed for Bitcoin Core")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Create mutants for a specific PR or file
    Mutate {
        /// Project to mutate (bitcoin-core or secp256k1)
        #[arg(long, value_enum, default_value_t = Project::default())]
        project: Project,

        /// PR number (0 = current branch)
        #[arg(short, long, default_value = "0")]
        pr: u32,

        /// Only create mutants for unit and functional tests
        #[arg(short = 't', long)]
        test_only: bool,

        /// Path for the coverage file (*.info generated with cmake -P build/Coverage.cmake)
        #[arg(short, long)]
        cov: Option<PathBuf>,

        /// Path for the file with lines to skip when creating mutants
        #[arg(long)]
        skip_lines: Option<PathBuf>,

        /// File path to mutate
        #[arg(short, long)]
        file: Option<PathBuf>,

        /// Specify a range of lines from a file to be mutated
        #[arg(short, long, num_args = 2)]
        range: Option<Vec<usize>>,

        /// Create only one mutant per line
        #[arg(long)]
        one_mutant: bool,

        /// Apply only security-based mutations (usually to test fuzzing)
        #[arg(short, long)]
        only_security_mutations: bool,

        /// Disable AST-based arid node detection (generate more mutants)
        #[arg(long)]
        disable_ast_filtering: bool,

        /// Add custom expert rule for arid node detection
        #[arg(long, value_name = "PATTERN")]
        add_expert_rule: Option<String>,

        /// Persist results to a SQLite database (default path: mutation.db)
        #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "mutation.db")]
        sqlite: Option<PathBuf>,
    },
    /// Analyze mutants
    Analyze {
        /// Project being analyzed (bitcoin-core or secp256k1)
        #[arg(long, value_enum, default_value_t = Project::default())]
        project: Project,

        /// Folder with the mutants
        #[arg(short, long)]
        folder: Option<PathBuf>,

        /// Timeout value per mutant in seconds
        #[arg(short, long, default_value = "300")]
        timeout: u64,

        /// Number of jobs to be used to compile Bitcoin Core
        #[arg(short, long, default_value = "0")]
        jobs: u32,

        /// Command to test the mutants
        #[arg(short, long)]
        command: Option<String>,

        /// Maximum acceptable survival rate (0.3 = 30%)
        #[arg(long, default_value = "0.75")]
        survival_threshold: f64,

        /// Fail (non-zero exit) if the final mutation score is below this value
        /// (0.8 = 80%). Intended as a CI gate. When unset, the score is not enforced.
        #[arg(long, value_name = "RATE")]
        min_score: Option<f64>,

        /// SQLite database path to read mutants from (requires --run_id)
        #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "mutation.db")]
        sqlite: Option<PathBuf>,

        /// Run ID to analyze from the SQLite database (requires --sqlite)
        #[arg(long)]
        run_id: Option<i64>,

        /// Only analyze mutants for this file path (requires --run_id)
        #[arg(long)]
        file_path: Option<String>,

        /// Only analyze mutants that survived a previous run (requires --run_id)
        #[arg(long)]
        survivors_only: bool,
    },
}

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();

    match cli.command {
        Commands::Mutate {
            project,
            pr,
            test_only,
            cov,
            skip_lines,
            file,
            range,
            one_mutant,
            only_security_mutations,
            disable_ast_filtering,
            add_expert_rule,
            sqlite,
        } => {
            let skip_lines_map = if let Some(path) = skip_lines {
                read_skip_lines(&path)?
            } else {
                HashMap::new()
            };

            let coverage = if let Some(cov_path) = cov {
                Some(coverage::parse_coverage_file(&cov_path)?)
            } else {
                None
            };

            let range_lines = if let Some(range_vec) = range {
                if range_vec.len() != 2 || range_vec[0] > range_vec[1] {
                    return Err(MutationError::InvalidInput("Invalid range".to_string()));
                }
                Some((range_vec[0], range_vec[1]))
            } else {
                None
            };

            if pr != 0 && file.is_some() {
                return Err(MutationError::InvalidInput(
                    "You should only provide PR number or file".to_string(),
                ));
            }

            if coverage.is_some() && range_lines.is_some() {
                return Err(MutationError::InvalidInput(
                    "You should only provide coverage file or the range of lines to mutate"
                        .to_string(),
                ));
            }

            if let Some(ref expert_rule) = add_expert_rule {
                println!("Custom expert rule will be applied: {}", expert_rule);
            }

            mutation::run_mutation(
                project,
                if pr == 0 { None } else { Some(pr) },
                file,
                one_mutant,
                only_security_mutations,
                range_lines,
                coverage,
                test_only,
                skip_lines_map,
                !disable_ast_filtering,
                add_expert_rule,
                sqlite,
            )
            .await?;
        }
        Commands::Analyze {
            project,
            folder,
            timeout,
            jobs,
            command,
            survival_threshold,
            min_score,
            sqlite,
            run_id,
            file_path,
            survivors_only,
        } => {
            if run_id.is_some() && sqlite.is_none() {
                return Err(MutationError::InvalidInput(
                    "--run_id requires --sqlite <path>".to_string(),
                ));
            }

            if file_path.is_some() && run_id.is_none() {
                return Err(MutationError::InvalidInput(
                    "--file_path requires --run_id".to_string(),
                ));
            }

            if let Some(min) = min_score {
                if !(0.0..=1.0).contains(&min) {
                    return Err(MutationError::InvalidInput(
                        "--min-score must be between 0.0 and 1.0".to_string(),
                    ));
                }
            }

            analyze::run_analysis(
                project,
                folder,
                command,
                jobs,
                timeout,
                survival_threshold,
                min_score,
                sqlite,
                run_id,
                file_path,
                survivors_only,
            )
            .await?;
        }
    }

    Ok(())
}

fn read_skip_lines(path: &PathBuf) -> Result<HashMap<String, Vec<usize>>> {
    let content = std::fs::read_to_string(path)?;
    let map: HashMap<String, Vec<usize>> = serde_json::from_str(&content)?;
    Ok(map)
}