paline 0.5.0

一个用 Rust 编写的代码行数统计命令行工具。递归扫描目录,按编程语言统计行数,并以彩色横向条形图在终端中可视化展示。
//! `paline` is a command-line tool written in Rust that counts lines of code.
//!
//! It recursively scans directories, identifies programming languages by file
//! extension / filename, counts lines per language, and visualizes the results
//! as a colorful horizontal bar chart in the terminal.
//!
//! # Usage
//!
//! ```text
//! paline stats                            # Count the current directory
//! paline stats --path /path/to/proj       # Count a specific directory
//! paline stats --commit abc123            # Count the file tree of a specific commit
//! paline diff --base abc123               # Diff a commit against the worktree
//! paline diff --base abc123 --target def456   # Diff between two commits
//! paline stats -vv                        # Enable debug logging
//! ```

mod analyzer;
mod config;
mod logger;
mod render;
mod utils;

use std::path::Path;
use std::time::Instant;

use clap::{Parser, Subcommand};
use dirs;

use crate::config::{LanguageIndex, LanguagesConfig};
use crate::logger::{logger, Level, Log, Verbosity};

/// Command-line arguments parsed by clap.
#[derive(Parser, Debug)]
#[command(name = "lcount", version)]
#[command(about = "A command-line tool written in Rust that counts lines of code. It recursively scans directories, counts lines by programming language, and visualizes them in a colorful horizontal bar chart in the terminal.", long_about = None)]
#[command(propagate_version = true)]
struct Args {
    /// Path to the file or directory to scan, defaults to the current directory
    #[arg(short, long, default_value = ".", global = true)]
    path: String,

    /// Logging verbosity; repeat for more detail (`-vv` enables per-file tracing)
    #[arg(short, long, action = clap::ArgAction::Count, global = true)]
    verbose: u8,

    /// Subcommand to run
    #[command(subcommand)]
    command: Commands,
}

#[derive(Debug, Subcommand)]
enum Commands {
    /// Count lines in a directory or the file tree of a specific git commit
    Stats {
        /// Scan the file tree of a specific commit (requires a git repository)
        #[arg(short, long, value_name = "COMMIT_HASH")]
        commit: Option<String>,
    },

    /// Show the net line change between a base commit and a target commit or the worktree
    Diff {
        /// The base commit to compare against
        #[arg(short, long)]
        base: String,

        /// The target to compare with; a commit hash, or WORKTREE for the working tree
        #[arg(short, long, default_value_t = String::from("WORKTREE"))]
        target: String,
    },
}

/// 程序入口
fn main() {
    let args = Args::parse();
    let path = Path::new(&args.path);
    let mut cfg_path = match dirs::config_dir() {
        Some(path) => path,
        None => {
            logger(
                Log::new(
                    Level::Error,
                    "Failed to get the root configuration folder".to_string(),
                    Some(Verbosity::Normal),
                ),
                Some(Verbosity::from_u8(&args.verbose)),
            );
            return;
        }
    };
    cfg_path = cfg_path.join(Path::new("paline").join(Path::new("lang.toml")));

    // 如果配置文件不存在,尝试创建默认配置
    if !cfg_path.exists() {
        if let Err(log) = LanguagesConfig::init_default_file(&cfg_path) {
            logger(log, Some(Verbosity::from_u8(&args.verbose)));
            return;
        }
        logger(
            Log::new(
                Level::Info,
                "Created default language config file: lang.toml".to_string(),
                Some(Verbosity::Verbose),
            ),
            Some(Verbosity::from_u8(&args.verbose)),
        );
    }

    let config = match LanguagesConfig::from_file(&cfg_path) {
        Ok(lang_cfg) => lang_cfg,
        Err(log) => {
            logger(log, Some(Verbosity::from_u8(&args.verbose)));
            return;
        }
    };
    let index = config.build_index();

    // 统一校验路径有效性(两个子命令都依赖此检查)
    if !path.exists() || !path.is_dir() {
        logger(
            Log::new(
                Level::Error,
                format!(
                    "The path doesn't exist or isn't a directory: \"{}\". Please check if the path is correct.",
                    &args.path
                ),
                None,
            ),
            None,
        );
        return;
    }

    match args.command {
        Commands::Stats { commit } => handle_stats(path, &args.verbose, commit, &config, &index),
        Commands::Diff { base, target } => {
            handle_diff(path, &args.verbose, base, target, &config, &index)
        }
    }
}

/// 处理 `stats` 子命令
fn handle_stats(
    path: &Path,
    verbose: &u8,
    commit: Option<String>,
    _config: &LanguagesConfig,
    index: &LanguageIndex,
) {
    let t_start = Instant::now();

    let stats = if let Some(commit_hash) = &commit {
        // 需要 git 仓库
        let repo = match utils::open_repository(path) {
            Ok(r) => r,
            Err(log) => {
                logger(log, Some(Verbosity::from_u8(verbose)));
                return;
            }
        };
        let commit_obj = match utils::get_commit_from(&repo, commit_hash) {
            Ok(c) => c,
            Err(log) => {
                logger(log, Some(Verbosity::from_u8(verbose)));
                return;
            }
        };
        logger(
            Log::new(
                Level::Info,
                format!(
                    "scanning commit {} ({})",
                    &commit_hash[..12.min(commit_hash.len())],
                    commit_obj.summary().unwrap_or("<no message>")
                ),
                Some(Verbosity::Verbose),
            ),
            Some(Verbosity::from_u8(verbose)),
        );
        analyzer::analyze_project(
            &analyzer::Project::Commit {
                repo: &repo,
                commit: &commit_obj,
            },
            index,
            verbose,
        )
    } else {
        // 扫描磁盘路径
        analyzer::analyze_project(&analyzer::Project::Disk { root: path }, index, verbose)
    };

    let t_analyzed = t_start.elapsed();
    logger(
        Log::new(
            Level::Info,
            format!("analyze: {:.2?}", t_analyzed),
            Some(Verbosity::Verbose),
        ),
        Some(Verbosity::from_u8(verbose)),
    );

    if let Err(log) = render::render(render::Mode::Stats { stats }, index) {
        logger(log, Some(Verbosity::from_u8(verbose)));
    }
}

/// 处理 `diff` 子命令
fn handle_diff(
    path: &Path,
    verbose: &u8,
    base: String,
    target: String,
    _config: &LanguagesConfig,
    index: &LanguageIndex,
) {
    let t_start = Instant::now();

    // 打开仓库
    let repo = match utils::open_repository(path) {
        Ok(r) => r,
        Err(log) => {
            logger(log, Some(Verbosity::from_u8(verbose)));
            return;
        }
    };

    // 解析 base commit
    let base_commit = match utils::get_commit_from(&repo, &base) {
        Ok(c) => c,
        Err(log) => {
            logger(log, Some(Verbosity::from_u8(verbose)));
            return;
        }
    };
    logger(
        Log::new(
            Level::Info,
            format!(
                "diff base commit {} ({})",
                &base[..12.min(base.len())],
                base_commit.summary().unwrap_or("<no message>")
            ),
            Some(Verbosity::Verbose),
        ),
        Some(Verbosity::from_u8(verbose)),
    );

    let base_project = analyzer::Project::Commit {
        repo: &repo,
        commit: &base_commit,
    };
    let base_stats = analyzer::analyze_project(&base_project, index, verbose);

    // 处理 target
    let target_stats = if target.eq_ignore_ascii_case("WORKTREE") {
        logger(
            Log::new(
                Level::Info,
                "target: WORKTREE".to_string(),
                Some(Verbosity::Verbose),
            ),
            Some(Verbosity::from_u8(verbose)),
        );
        analyzer::analyze_project(&analyzer::Project::Disk { root: path }, index, verbose)
    } else {
        let target_commit = match utils::get_commit_from(&repo, &target) {
            Ok(c) => c,
            Err(log) => {
                logger(log, Some(Verbosity::from_u8(verbose)));
                return;
            }
        };
        logger(
            Log::new(
                Level::Info,
                format!(
                    "target commit {} ({})",
                    &target[..12.min(target.len())],
                    target_commit.summary().unwrap_or("<no message>")
                ),
                Some(Verbosity::Verbose),
            ),
            Some(Verbosity::from_u8(verbose)),
        );
        analyzer::analyze_project(
            &analyzer::Project::Commit {
                repo: &repo,
                commit: &target_commit,
            },
            index,
            verbose,
        )
    };

    let t_analyzed = t_start.elapsed();
    logger(
        Log::new(
            Level::Info,
            format!("analyze: {:.2?}", t_analyzed),
            Some(Verbosity::Verbose),
        ),
        Some(Verbosity::from_u8(verbose)),
    );

    if let Err(log) = render::render(
        render::Mode::Diff {
            base: base_stats,
            target: target_stats,
        },
        index,
    ) {
        logger(log, Some(Verbosity::from_u8(verbose)));
    }
}