lowcharts 0.5.8

Tool to draw low-resolution graphs in terminal
Documentation
mod app;
mod format;
mod plot;
mod read;
mod stats;

use std::env;

#[macro_use]
extern crate derive_builder;
#[macro_use]
extern crate log;
use chrono::Duration;
use clap::ArgMatches;
use regex::Regex;
use simplelog::{ColorChoice, ConfigBuilder, LevelFilter, TermLogger, TerminalMode};
use yansi::Paint;

/// True if vec has al least 'min' elements
fn assert_data<T>(vec: &[T], min: usize) -> bool {
    if vec.len() < min {
        warn!("Not enough data to process");
    }
    vec.len() >= min
}

/// Sets up color choices and verbosity in the two libraries used for output:
/// simplelog and yansi
fn configure_output(option: &str, verbose: bool) {
    let mut color_choice = ColorChoice::Auto;
    match option {
        "no" => {
            Paint::disable();
            color_choice = ColorChoice::Never;
        }
        "auto" => match env::var("TERM") {
            Ok(value) if value == "dumb" => Paint::disable(),
            _ => {
                if atty::isnt(atty::Stream::Stdout) {
                    Paint::disable();
                }
            }
        },
        "yes" => {
            color_choice = ColorChoice::Always;
        }
        _ => (),
    };
    if let Err(err) = TermLogger::init(
        if verbose {
            LevelFilter::Debug
        } else {
            LevelFilter::Info
        },
        ConfigBuilder::new()
            .set_time_level(LevelFilter::Trace)
            .set_thread_level(LevelFilter::Trace)
            .set_target_level(LevelFilter::Trace)
            .build(),
        TerminalMode::Stderr,
        color_choice,
    ) {
        // We trigger this error when unit testing this fn
        eprintln!("Error: {err}");
    }
}

fn parse_duration(duration: &str) -> Result<Duration, humantime::DurationError> {
    match humantime::parse_duration(duration) {
        Ok(d) => Ok(Duration::milliseconds(d.as_millis() as i64)),
        Err(error) => Err(error),
    }
}

/// Build a reader able to read floats (potentially capturing them with regex)
/// from an input source.
fn get_float_reader(matches: &ArgMatches) -> Result<read::DataReader, ()> {
    let mut builder = read::DataReaderBuilder::default();
    if matches.is_present("min") || matches.is_present("max") {
        let min = matches.value_of_t("min").unwrap_or(f64::NEG_INFINITY);
        let max = matches.value_of_t("max").unwrap_or(f64::INFINITY);
        if min > max {
            error!("Minimum should be smaller than maximum");
            return Err(());
        }
        builder.range(min..max);
    }
    if let Some(string) = matches.value_of("regex") {
        match Regex::new(string) {
            Ok(re) => {
                builder.regex(re);
            }
            _ => {
                error!("Failed to parse regex {}", string);
                return Err(());
            }
        };
    }
    Ok(builder.build().unwrap())
}

/// Implements the hist cli-subcommand
fn histogram(matches: &ArgMatches) -> i32 {
    let reader = match get_float_reader(matches) {
        Ok(r) => r,
        _ => return 2,
    };
    let vec = reader.read(matches.value_of("input").unwrap());
    if !assert_data(&vec, 1) {
        return 1;
    }
    let mut options = plot::HistogramOptions::default();
    let precision_arg: i32 = matches.value_of_t("precision").unwrap();
    if precision_arg > 0 {
        options.precision = Some(precision_arg as usize);
    };
    options.log_scale = matches.is_present("log-scale");
    options.intervals = matches.value_of_t("intervals").unwrap();
    let width = matches.value_of_t("width").unwrap();
    let histogram = plot::Histogram::new(&vec, options);
    print!("{histogram:width$}");
    0
}

/// Implements the plot cli-subcommand
fn plot(matches: &ArgMatches) -> i32 {
    let reader = match get_float_reader(matches) {
        Ok(r) => r,
        _ => return 2,
    };
    let vec = reader.read(matches.value_of("input").unwrap());
    if !assert_data(&vec, 1) {
        return 1;
    }
    let precision_arg: i32 = matches.value_of_t("precision").unwrap();
    let precision = if precision_arg < 0 {
        None
    } else {
        Some(precision_arg as usize)
    };
    let plot = plot::XyPlot::new(
        &vec,
        matches.value_of_t("width").unwrap(),
        matches.value_of_t("height").unwrap(),
        precision,
    );
    print!("{plot}");
    0
}

/// Implements the matches cli-subcommand
fn matchbar(matches: &ArgMatches) -> i32 {
    let reader = read::DataReader::default();
    let width = matches.value_of_t("width").unwrap();
    print!(
        "{:width$}",
        reader.read_matches(
            matches.value_of("input").unwrap(),
            matches.values_of("match").unwrap().collect()
        ),
        width = width
    );
    0
}

/// Implements the common-terms cli-subcommand
fn common_terms(matches: &ArgMatches) -> i32 {
    let mut builder = read::DataReaderBuilder::default();
    if let Some(string) = matches.value_of("regex") {
        match Regex::new(string) {
            Ok(re) => {
                builder.regex(re);
            }
            _ => {
                error!("Failed to parse regex {}", string);
                return 1;
            }
        };
    } else {
        builder.regex(Regex::new("(.*)").unwrap());
    };
    let reader = builder.build().unwrap();
    let width = matches.value_of_t("width").unwrap();
    let lines = matches.value_of_t("lines").unwrap();
    if lines < 1 {
        error!("You should specify a potitive number of lines");
        return 2;
    };
    print!(
        "{:width$}",
        reader.read_terms(matches.value_of("input").unwrap(), lines),
        width = width
    );
    0
}

/// Implements the timehist cli-subcommand
fn timehist(matches: &ArgMatches) -> i32 {
    let mut builder = read::TimeReaderBuilder::default();
    if let Some(string) = matches.value_of("regex") {
        match Regex::new(string) {
            Ok(re) => {
                builder.regex(re);
            }
            _ => {
                error!("Failed to parse regex {}", string);
                return 2;
            }
        };
    }
    if let Some(as_str) = matches.value_of("format") {
        builder.ts_format(as_str.to_string());
    }
    builder.early_stop(matches.is_present("early-stop"));
    if let Some(duration) = matches.value_of("duration") {
        match parse_duration(duration) {
            Ok(d) => builder.duration(d),
            Err(err) => {
                error!("Failed to parse duration {}: {}", duration, err);
                return 2;
            }
        };
    };
    let width = matches.value_of_t("width").unwrap();
    let reader = builder.build().unwrap();
    let vec = reader.read(matches.value_of("input").unwrap());
    if assert_data(&vec, 2) {
        let timehist = plot::TimeHistogram::new(matches.value_of_t("intervals").unwrap(), &vec);
        print!("{timehist:width$}");
    };
    0
}

/// Implements the timehist cli-subcommand
fn splittime(matches: &ArgMatches) -> i32 {
    let mut builder = read::SplitTimeReaderBuilder::default();
    let string_list: Vec<String> = match matches.values_of("match") {
        Some(s) => s.map(|s| s.to_string()).collect(),
        None => {
            error!("At least a match is needed");
            return 2;
        }
    };
    if string_list.len() > 5 {
        error!("Only 5 different sub-groups are supported");
        return 2;
    }
    if let Some(as_str) = matches.value_of("format") {
        builder.ts_format(as_str.to_string());
    }
    builder.matches(string_list.iter().map(|s| s.to_string()).collect());
    let width = matches.value_of_t("width").unwrap();
    let reader = builder.build().unwrap();
    let vec = reader.read(matches.value_of("input").unwrap());
    if assert_data(&vec, 2) {
        let timehist = plot::SplitTimeHistogram::new(
            matches.value_of_t("intervals").unwrap(),
            string_list,
            &vec,
        );
        print!("{timehist:width$}");
    };
    0
}

fn main() {
    let matches = app::get_app().get_matches();
    configure_output(
        matches.value_of("color").unwrap(),
        matches.is_present("verbose"),
    );
    std::process::exit(match matches.subcommand() {
        Some(("hist", subcommand_matches)) => histogram(subcommand_matches),
        Some(("plot", subcommand_matches)) => plot(subcommand_matches),
        Some(("matches", subcommand_matches)) => matchbar(subcommand_matches),
        Some(("timehist", subcommand_matches)) => timehist(subcommand_matches),
        Some(("common-terms", subcommand_matches)) => common_terms(subcommand_matches),
        Some(("split-timehist", subcommand_matches)) => splittime(subcommand_matches),
        _ => unreachable!("Invalid subcommand"),
    });
}

#[cfg(test)]
mod tests {
    use super::*;
    use yansi::Color::Blue;

    #[test]
    fn test_output_yes() {
        Paint::enable();
        configure_output("yes", true);
        let display = format!("{}", Blue.paint("blue"));
        assert_eq!("\u{1b}[34mblue\u{1b}[0m", display);
        assert_eq!(LevelFilter::Debug, log::max_level());
    }

    #[test]
    fn test_output_no() {
        Paint::enable();
        configure_output("no", false);
        let display = format!("{}", Blue.paint("blue"));
        assert_eq!("blue", display);
        assert_eq!(LevelFilter::Info, log::max_level());
    }

    #[test]
    fn test_output_auto() {
        Paint::enable();
        env::set_var("TERM", "dumb");
        configure_output("auto", false);
        let display = format!("{}", Blue.paint("blue"));
        assert_eq!("blue", display);
    }

    #[test]
    fn test_duration() {
        assert_eq!(
            parse_duration("2h 30m 5s 100ms"),
            Ok(Duration::milliseconds(
                2 * 60 * 60000 + 30 * 60000 + 5000 + 100
            ))
        );
        assert_eq!(parse_duration("3days"), Ok(Duration::days(3)));
        assert!(parse_duration("bananas").is_err());
    }

    #[test]
    fn test_assert_data() {
        let v = vec![true];
        assert!(assert_data(&v, 1));
        assert!(!assert_data(&v, 2));
        let v = Vec::<bool>::new();
        assert!(!assert_data(&v, 1));
    }
}