shellist 0.1.2

Shell history analysis. Parses .bash_history, counts commands, and ranks by frequency.
Documentation
//! Shellist — shell history analysis CLI.
//!
//! Reads `.bash_history`, ranks commands by frequency, prints a table.
//!
//! ```text
//! $ shellist
//! Rank  Command  Count
//! 1     ls       120
//! 2     git      95
//! 3     cd       80
//! ```
//!
//! Options:
//! - `--top N`            Show only the top N commands
//! - `--ignore X,Y`       Exclude commands (comma-separated)
//! - `--no-default-ignore` Disable the built-in ignore list
//! - `--min N`            Only commands with count >= N
//! - `--path P`           Read from `P` instead of `~/.bash_history`
//! - `--help`             Print this

use std::env;
use std::process;

use shellist::{
    analyze, default_history_path, filter_by_min_frequency, filter_commands, load_history_file,
    top_n,
};

/// Bash builtins that often leak into `.bash_history` from shell init scripts.
/// These are internal calls the shell makes, not user-typed commands.
const DEFAULT_IGNORE: &[&str] = &["set", "shopt"];

struct Args {
    top: Option<usize>,
    ignore: Vec<String>,
    no_default_ignore: bool,
    min_freq: Option<usize>,
    path: Option<String>,
}

fn parse_args() -> Args {
    let mut args = Args {
        top: None,
        ignore: Vec::new(),
        no_default_ignore: false,
        min_freq: None,
        path: None,
    };

    let mut iter = env::args().skip(1);
    while let Some(arg) = iter.next() {
        match arg.as_str() {
            "--help" => {
                print_help();
                process::exit(0);
            }
            "--top" => {
                let val = iter.next().unwrap_or_else(|| {
                    eprintln!("shellist: --top requires a number");
                    process::exit(1);
                });
                args.top = Some(val.parse().unwrap_or_else(|_| {
                    eprintln!("shellist: --top expects a number, got '{val}'");
                    process::exit(1);
                }));
            }
            "--ignore" => {
                let val = iter.next().unwrap_or_else(|| {
                    eprintln!("shellist: --ignore requires a comma-separated list");
                    process::exit(1);
                });
                args.ignore = val.split(',').map(|s| s.trim().to_string()).collect();
            }
            "--no-default-ignore" => {
                args.no_default_ignore = true;
            }
            "--min" => {
                let val = iter.next().unwrap_or_else(|| {
                    eprintln!("shellist: --min requires a number");
                    process::exit(1);
                });
                args.min_freq = Some(val.parse().unwrap_or_else(|_| {
                    eprintln!("shellist: --min expects a number, got '{val}'");
                    process::exit(1);
                }));
            }
            "--path" => {
                let val = iter.next().unwrap_or_else(|| {
                    eprintln!("shellist: --path requires a file path");
                    process::exit(1);
                });
                args.path = Some(val);
            }
            other => {
                eprintln!("shellist: unknown flag '{other}'");
                process::exit(1);
            }
        }
    }

    args
}

fn print_help() {
    eprintln!(
        "shellist — shell history analysis

USAGE:
    shellist [OPTIONS]

OPTIONS:
    --top N            Show only the top N commands
    --ignore X,Y       Exclude commands (comma-separated)
    --no-default-ignore Disable built-in ignore list ({})
    --min N            Only commands used at least N times
    --path PATH        Read from PATH instead of ~/.bash_history
    --help             Print this help",
        DEFAULT_IGNORE.join(", ")
    );
}

fn print_table(commands: &[(String, usize)]) {
    if commands.is_empty() {
        return;
    }

    let rank_width = commands.len().to_string().len().max(4);
    let cmd_width = commands
        .iter()
        .map(|(c, _)| c.len())
        .max()
        .unwrap_or(7)
        .max(7);
    let count_width = commands
        .iter()
        .map(|(_, n)| n.to_string().len())
        .max()
        .unwrap_or(5)
        .max(5);

    println!(
        "{:>rw$}  {:<cw$}  {:>nw$}",
        "Rank",
        "Command",
        "Count",
        rw = rank_width,
        cw = cmd_width,
        nw = count_width
    );
    println!(
        "{:-<rw$}  {:-<cw$}  {:-<nw$}",
        "",
        "",
        "",
        rw = rank_width,
        cw = cmd_width,
        nw = count_width
    );

    for (i, (cmd, count)) in commands.iter().enumerate() {
        println!(
            "{:>rw$}  {:<cw$}  {:>nw$}",
            i + 1,
            cmd,
            count,
            rw = rank_width,
            cw = cmd_width,
            nw = count_width
        );
    }
}

fn run() -> Result<(), Box<dyn std::error::Error>> {
    let args = parse_args();

    let content = if let Some(ref path) = args.path {
        load_history_file(path)?
    } else {
        let path = default_history_path();
        load_history_file(path.to_str().unwrap())?
    };

    let mut ranked = analyze(&content);

    let ignore: Vec<String> = if args.no_default_ignore {
        args.ignore
    } else {
        let mut merged = DEFAULT_IGNORE
            .iter()
            .map(|&s| s.to_string())
            .collect::<Vec<_>>();
        merged.extend(args.ignore);
        merged
    };
    if !ignore.is_empty() {
        ranked = filter_commands(ranked, &ignore);
    }
    if let Some(min) = args.min_freq {
        ranked = filter_by_min_frequency(ranked, min);
    }
    if let Some(n) = args.top {
        ranked = top_n(ranked, n);
    }

    if ranked.is_empty() {
        let path_display = args
            .path
            .clone()
            .unwrap_or_else(|| default_history_path().to_string_lossy().into_owned());
        if content.trim().is_empty() {
            eprintln!("shellist: no history found in {path_display}");
        } else {
            eprintln!(
                "shellist: all commands were filtered out (default ignores: {}). \
                 Use --no-default-ignore to show everything.",
                DEFAULT_IGNORE.join(", ")
            );
        }
    } else {
        print_table(&ranked);
    }
    Ok(())
}

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