shellist 0.2.0

Shell history analysis. Parses bash, zsh, and fish history, counts commands, and ranks by frequency.
Documentation
//! Shellist — shell history analysis CLI.
//!
//! Reads shell history (bash, zsh, fish), ranks commands by frequency, and
//! prints a table. Supports multiple output formats, subcommand depth,
//! regex filtering, date ranges, trends, and shell completions.
//!
//! ```text
//! $ shellist --top 3 --bars
//! Rank  Command  Count  Bars
//! ----  -------  -----  ------------------------------
//!    1  ls         120  ##############################
//!    2  git         95  #######################
//!    3  cd          80  ####################
//! ```

use std::env;
use std::io::{IsTerminal, Read};
use std::process;

use shellist::{
    Bucket, HistoryParser, Shell, TableOptions, completions, count_commands_at_depth,
    default_history_path, detect_shell, filter_by_min_frequency, filter_commands, format_csv,
    format_json, format_stats, format_table, format_trend, grep_filter, load_history_file,
    man_page, parse_date_to_unix, rank_commands, rank_commands_ascending, top_n,
};

use regex::Regex;

/// Bash builtins that often leak into `.bash_history` from shell init scripts.
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>,
    shell: Option<Shell>,
    depth: Option<usize>,
    json: bool,
    csv: bool,
    bars: bool,
    percent: bool,
    stats: bool,
    grep: Option<String>,
    asc: bool,
    since: Option<String>,
    until: Option<String>,
    trend: bool,
    trend_bucket: Option<Bucket>,
    output: Option<String>,
    completions: Option<Shell>,
    man: bool,
}

impl Args {
    const fn empty() -> Self {
        Self {
            top: None,
            ignore: Vec::new(),
            no_default_ignore: false,
            min_freq: None,
            path: None,
            shell: None,
            depth: None,
            json: false,
            csv: false,
            bars: false,
            percent: false,
            stats: false,
            grep: None,
            asc: false,
            since: None,
            until: None,
            trend: false,
            trend_bucket: None,
            output: None,
            completions: None,
            man: false,
        }
    }
}

fn need_value(iter: &mut impl Iterator<Item = String>, flag: &str) -> String {
    iter.next().unwrap_or_else(|| {
        eprintln!("shellist: {flag} requires a value");
        process::exit(1);
    })
}

fn parse_usize(val: String, flag: &str) -> usize {
    val.parse().unwrap_or_else(|_| {
        eprintln!("shellist: {flag} expects a number, got '{val}'");
        process::exit(1);
    })
}

fn parse_args() -> Args {
    let mut args = Args::empty();
    let mut iter = env::args().skip(1);

    while let Some(arg) = iter.next() {
        match arg.as_str() {
            "-" => args.path = Some("-".to_string()),
            "--help" => {
                print_help();
                process::exit(0);
            }
            "--top" => args.top = Some(parse_usize(need_value(&mut iter, "--top"), "--top")),
            "--min" => args.min_freq = Some(parse_usize(need_value(&mut iter, "--min"), "--min")),
            "--depth" => {
                args.depth = Some(parse_usize(need_value(&mut iter, "--depth"), "--depth"))
            }
            "--ignore" => {
                let val = need_value(&mut iter, "--ignore");
                args.ignore = val.split(',').map(|s| s.trim().to_lowercase()).collect();
            }
            "--no-default-ignore" => args.no_default_ignore = true,
            "--path" => args.path = Some(need_value(&mut iter, "--path")),
            "--shell" => {
                let val = need_value(&mut iter, "--shell");
                args.shell = Some(parse_shell(&val, "--shell"));
            }
            "--json" => args.json = true,
            "--csv" => args.csv = true,
            "--bars" => args.bars = true,
            "--percent" => args.percent = true,
            "--stats" => args.stats = true,
            "--grep" => args.grep = Some(need_value(&mut iter, "--grep")),
            "--asc" => args.asc = true,
            "--since" => args.since = Some(need_value(&mut iter, "--since")),
            "--until" => args.until = Some(need_value(&mut iter, "--until")),
            "--trend" => args.trend = true,
            "--trend-bucket" => {
                let val = need_value(&mut iter, "--trend-bucket");
                args.trend_bucket = Some(parse_bucket(&val));
            }
            "--output" => args.output = Some(need_value(&mut iter, "--output")),
            "--completions" => {
                let val = need_value(&mut iter, "--completions");
                args.completions = Some(parse_shell(&val, "--completions"));
            }
            "--man" => args.man = true,
            other => {
                eprintln!("shellist: unknown flag '{other}'");
                process::exit(1);
            }
        }
    }

    args
}

fn parse_shell(val: &str, flag: &str) -> Shell {
    Shell::from_name(val).unwrap_or_else(|| {
        eprintln!("shellist: {flag} expects bash, zsh, or fish, got '{val}'");
        process::exit(1);
    })
}

fn parse_bucket(val: &str) -> Bucket {
    Bucket::from_name(val).unwrap_or_else(|| {
        eprintln!("shellist: --trend-bucket expects day, week, or month, got '{val}'");
        process::exit(1);
    })
}

fn print_help() {
    println!(
        "shellist {version} — shell history analysis

USAGE:
    shellist [OPTIONS]

INPUT:
    --path PATH          Read history from PATH (default: per-shell file)
    --shell bash|zsh|fish  Force a parser (default: auto-detect)
    -                    Read history from stdin (or pipe in)

FILTERING:
    --top N              Show only the top N commands
    --ignore X,Y         Exclude commands (comma-separated)
    --no-default-ignore  Don't filter bash internals ({ignores})
    --min N              Only commands used at least N times
    --grep PATTERN       Keep commands matching a regex
    --depth N            Treat first N tokens as the command key (default 1)
    --since DATE         Only on/after DATE (YYYY-MM-DD, needs timestamps)
    --until DATE         Only on/before DATE (YYYY-MM-DD, needs timestamps)
    --asc                Sort ascending

OUTPUT:
    --bars               Add an ASCII bar chart column
    --percent            Add a percentage column
    --json               Output as JSON
    --csv                Output as CSV
    --stats              Print summary statistics
    --trend              Usage bucketed over time (needs timestamps)
    --trend-bucket day|week|month  Bucket for --trend (default: day)
    --output FILE        Write output to FILE instead of stdout

INTEGRATION:
    --completions bash|zsh|fish  Print a completion script
    --man                Print the man page
    --help               Print this help",
        version = env!("CARGO_PKG_VERSION"),
        ignores = DEFAULT_IGNORE.join(", ")
    );
}

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

    if args.man {
        print!("{}", man_page());
        return Ok(());
    }
    if let Some(shell) = args.completions {
        print!("{}", completions(shell));
        return Ok(());
    }

    let grep_re = match args.grep.as_deref() {
        Some(p) => Some(Regex::new(p).map_err(|e| format!("invalid --grep pattern: {e}"))?),
        None => None,
    };
    let since = match args.since.as_deref() {
        Some(d) => Some(
            parse_date_to_unix(d)
                .ok_or_else(|| format!("invalid --since date '{d}' (use YYYY-MM-DD)"))?,
        ),
        None => None,
    };
    let until = match args.until.as_deref() {
        Some(d) => Some(
            parse_date_to_unix(d)
                .ok_or_else(|| format!("invalid --until date '{d}' (use YYYY-MM-DD)"))?,
        ),
        None => None,
    };

    let content = read_input(&args)?;
    let shell = args.shell.unwrap_or_else(|| detect_shell(&content));
    let mut entries = match shell {
        Shell::Bash => shellist::DefaultHistoryParser::new().parse(&content),
        Shell::Zsh => shellist::ZshHistoryParser::new().parse(&content),
        Shell::Fish => shellist::FishHistoryParser::new().parse(&content),
    };

    if since.is_some() || until.is_some() {
        entries.retain(|e| match e.timestamp {
            Some(t) => since.is_none_or(|s| t as i64 >= s) && until.is_none_or(|u| t as i64 <= u),
            None => false,
        });
    }

    if args.trend {
        let bucket = args.trend_bucket.unwrap_or(Bucket::Day);
        let out = format_trend(&entries, bucket);
        if out.is_empty() {
            eprintln!(
                "shellist: no timestamped entries for --trend \
                 (need zsh extended, fish, or timestamped history)"
            );
        } else {
            write_output(&out, &args.output)?;
        }
        return Ok(());
    }

    let depth = args.depth.unwrap_or(1);
    let counts = count_commands_at_depth(&entries, depth);
    let mut ranked = if args.asc {
        rank_commands_ascending(counts)
    } else {
        rank_commands(counts)
    };

    if let Some(re) = grep_re {
        ranked = grep_filter(&ranked, &re);
    }

    let ignore: Vec<String> = if args.no_default_ignore {
        std::mem::take(&mut args.ignore)
    } else {
        let mut merged = DEFAULT_IGNORE
            .iter()
            .map(|s| (*s).to_string())
            .collect::<Vec<_>>();
        merged.extend(std::mem::take(&mut 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);
    }

    let (output, was_empty_table) = if args.json {
        (format_json(&ranked), false)
    } else if args.csv {
        (format_csv(&ranked), false)
    } else if args.stats {
        (format_stats(&ranked), false)
    } else if ranked.is_empty() {
        (String::new(), true)
    } else {
        let opts = TableOptions {
            percent: args.percent,
            bars: args.bars,
        };
        (format_table(&ranked, &opts), false)
    };

    if was_empty_table {
        let source = source_label(&args);
        eprintln!("shellist: no commands to show from {source}");
    } else {
        write_output(&output, &args.output)?;
    }

    Ok(())
}

fn read_input(args: &Args) -> Result<String, Box<dyn std::error::Error>> {
    if let Some(path) = &args.path {
        if path == "-" {
            return read_stdin();
        }
        return load_history_file(path).map_err(Into::into);
    }
    if !std::io::stdin().is_terminal() {
        return read_stdin();
    }
    let path = match args.shell {
        Some(shell) => shell.default_history_path(),
        None => default_history_path(),
    };
    load_history_file(path.to_str().unwrap()).map_err(Into::into)
}

fn read_stdin() -> Result<String, Box<dyn std::error::Error>> {
    let mut buf = String::new();
    std::io::stdin().read_to_string(&mut buf)?;
    Ok(buf)
}

fn source_label(args: &Args) -> String {
    if let Some(p) = &args.path {
        if p == "-" {
            return "stdin".to_string();
        }
        return p.clone();
    }
    if !std::io::stdin().is_terminal() {
        return "stdin".to_string();
    }
    match args.shell {
        Some(shell) => shell.default_history_path().to_string_lossy().into_owned(),
        None => default_history_path().to_string_lossy().into_owned(),
    }
}

fn write_output(content: &str, output: &Option<String>) -> Result<(), Box<dyn std::error::Error>> {
    match output {
        Some(path) => std::fs::write(path, content)?,
        None => print!("{content}"),
    }
    Ok(())
}

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