rust-diff-analyzer 2.0.1

Semantic analyzer for Rust PR diffs that distinguishes production code from test code
Documentation
// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
// SPDX-License-Identifier: MIT

use std::{
    fs,
    io::{self, Read, Write},
    path::{Path, PathBuf},
    process,
};

use clap::Parser;
use masterror::AppError;
use rust_diff_analyzer::{
    analysis::map_changes,
    classifier::rules::{calculate_weight, exceeded_per_type_limits},
    config::{Config, OutputFormat},
    error::FileReadError,
    git::parse_diff,
    output::format_output,
    types::{AnalysisResult, SemanticUnitKind, Summary},
};

/// Semantic analyzer for Rust PR diffs
#[derive(Parser, Debug)]
#[command(name = "rust-diff-analyzer")]
#[command(version, about, long_about = None)]
struct Args {
    /// Path to diff file (reads from stdin if not provided)
    #[arg(short, long)]
    diff_file: Option<PathBuf>,

    /// Path to configuration file
    #[arg(short, long)]
    config: Option<PathBuf>,

    /// Output format
    #[arg(short, long, value_enum)]
    format: Option<OutputFormatArg>,

    /// Maximum production units allowed
    #[arg(long)]
    max_units: Option<usize>,

    /// Maximum weighted score allowed
    #[arg(long)]
    max_score: Option<usize>,

    /// Maximum production lines added
    #[arg(long)]
    max_lines: Option<usize>,

    /// Base directory for resolving file paths
    #[arg(short, long, default_value = ".")]
    base_dir: PathBuf,

    /// Don't exit with code 1 when limits are exceeded
    #[arg(long)]
    no_fail: bool,
}

#[derive(clap::ValueEnum, Clone, Debug)]
enum OutputFormatArg {
    Github,
    Json,
    Human,
    Comment,
}

fn main() {
    if let Err(e) = run() {
        eprintln!("Error: {}", e);
        process::exit(1);
    }
}

fn run() -> Result<(), AppError> {
    let args = Args::parse();

    let mut config = if let Some(config_path) = &args.config {
        Config::from_file(config_path)?
    } else {
        let default_path = Path::new(".rust-diff-analyzer.toml");
        if default_path.exists() {
            Config::from_file(default_path)?
        } else {
            Config::default()
        }
    };

    if let Some(format) = args.format {
        config.output.format = match format {
            OutputFormatArg::Github => OutputFormat::Github,
            OutputFormatArg::Json => OutputFormat::Json,
            OutputFormatArg::Human => OutputFormat::Human,
            OutputFormatArg::Comment => OutputFormat::Comment,
        };
    }

    if let Some(max_units) = args.max_units {
        config.limits.max_prod_units = max_units;
    }

    if let Some(max_score) = args.max_score {
        config.limits.max_weighted_score = max_score;
    }

    if let Some(max_lines) = args.max_lines {
        config.limits.max_prod_lines = Some(max_lines);
    }

    config.validate()?;

    let diff_content = read_diff(&args.diff_file)?;

    let file_diffs = parse_diff(&diff_content)?;

    let base_dir = args.base_dir.clone();
    let map_result = map_changes(&file_diffs, &config, |path| {
        let full_path = base_dir.join(path);
        fs::read_to_string(full_path)
    })?;
    let changes = map_result.changes;
    let scope = map_result.scope;

    let mut summary = Summary::default();

    for change in &changes {
        if change.classification.is_production() {
            match change.unit.kind {
                SemanticUnitKind::Function => summary.prod_functions += 1,
                SemanticUnitKind::Struct | SemanticUnitKind::Enum => summary.prod_structs += 1,
                _ => summary.prod_other += 1,
            }
            summary.prod_lines_added += change.lines_added;
            summary.prod_lines_removed += change.lines_removed;
            summary.weighted_score += calculate_weight(&change.unit, &config);
        } else {
            summary.test_units += 1;
            summary.test_lines_added += change.lines_added;
            summary.test_lines_removed += change.lines_removed;
        }
    }

    summary.exceeds_limit = summary.total_prod_units() > config.limits.max_prod_units
        || summary.weighted_score > config.limits.max_weighted_score
        || config
            .limits
            .max_prod_lines
            .map(|limit| summary.prod_lines_added > limit)
            .unwrap_or(false)
        || !exceeded_per_type_limits(&changes, &config).is_empty();

    let result = AnalysisResult::new(changes, summary, scope);

    let output = format_output(&result, &config)?;
    print!("{}", output);
    io::stdout()
        .flush()
        .map_err(|e| AppError::from(rust_diff_analyzer::error::IoError(e)))?;

    if result.summary.exceeds_limit && config.limits.fail_on_exceed && !args.no_fail {
        process::exit(1);
    }

    Ok(())
}

/// Reads the diff as bytes and converts lossily to UTF-8
///
/// `git diff` embeds raw file content, so a single non-UTF-8 line (e.g. a
/// Latin-1 fixture) must not abort the whole analysis.
fn read_diff(path: &Option<PathBuf>) -> Result<String, AppError> {
    match path {
        Some(p) => fs::read(p)
            .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
            .map_err(|e| AppError::from(FileReadError::new(p.clone(), e))),
        None => {
            let mut buffer = Vec::new();
            io::stdin()
                .read_to_end(&mut buffer)
                .map_err(|e| AppError::from(rust_diff_analyzer::error::IoError(e)))?;
            Ok(String::from_utf8_lossy(&buffer).into_owned())
        }
    }
}