From a89e39e54cf66441ad1245aff2bce0d5d812aee6 Mon Sep 17 00:00:00 2001 From: JuanLeon Lahoz Date: Tue, 18 May 2021 11:56:25 +0200 Subject: [PATCH] feature: Implement split-timehist visualization --- Makefile | 2 +- README.md | 12 +- resources/split-timehist-example.png | Bin 0 -> 47106 bytes src/app.rs | 54 ++++++- src/main.rs | 33 +++++ src/plot/mod.rs | 27 ++++ src/plot/splittimehist.rs | 213 +++++++++++++++++++++++++++ src/plot/timehist.rs | 20 +-- src/read/dateparser.rs | 11 +- src/read/mod.rs | 2 + src/read/splittimes.rs | 169 +++++++++++++++++++++ src/read/times.rs | 9 +- tests/integration_tests.rs | 34 +++++ 13 files changed, 555 insertions(+), 31 deletions(-) create mode 100644 resources/split-timehist-example.png create mode 100644 src/plot/splittimehist.rs create mode 100644 src/read/splittimes.rs diff --git a/Makefile b/Makefile index 8dcd8e3..84aad07 100644 --- a/Makefile +++ b/Makefile @@ -12,4 +12,4 @@ test: # Sadly, this misses coverage for those integrations tests that use # assert_cmd, as it does not follow forks coverage: - cargo tarpaulin -o Html -- --test-threads 1 + cargo tarpaulin -o Html --ignore-tests -- --test-threads 1 diff --git a/README.md b/README.md index 0237d1b..e7c2f06 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ terminal. Type `lowcharts --help`, or `lowcharts PLOT-TYPE --help` for a complete list of options. -Currently four basic types of plots are supported: +Currently five basic types of plots are supported: #### Bar chart for matches in the input @@ -125,6 +125,16 @@ timezone part of the format string (the autodetection works fine with timezones). +#### Split Time Histogram + +This adds up the time histogram and bar chart in a single visualization. + +This chart is generated using `strace -tt ls -lR 2>&1 | lowcharts split-timehist open mmap close read write --intervals 10`: + +[![Sample plot with lowcharts](resources/split-timehist-example.png)](resources/split-timehist-example.png) + +This graph depicts the relative frequency of search terms in time. + ### Installing #### Via release diff --git a/resources/split-timehist-example.png b/resources/split-timehist-example.png new file mode 100644 index 0000000000000000000000000000000000000000..f6b72cc075619a49c5a7ee6068db1d71e06de270 GIT binary patch literal 47106 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 U5>Ww|lz>2zqH-doLZ80;A8(}vtN;K2 literal 0 HcmV?d00001 diff --git a/src/app.rs b/src/app.rs index aa57d44..790381d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,6 +9,16 @@ fn add_input(app: App) -> App { ) } +fn add_input_as_option(app: App) -> App { + app.arg( + Arg::new("input") + .long("input") + .default_value("-") + .long_about("If not present or a single dash, standard input will be used") + .takes_value(true), + ) +} + fn add_min_max(app: App) -> App { app.arg( Arg::new("max") @@ -109,7 +119,7 @@ pub fn get_app() -> App<'static> { .setting(AppSettings::ColoredHelp) .setting(AppSettings::AllowMissingPositional) .about("Plot barchar with counts of occurences of matches params"); - matches = add_input(add_width(matches)).arg( + matches = add_input_as_option(add_width(matches)).arg( Arg::new("match") .about("Count maches for those strings") .required(true) @@ -139,6 +149,25 @@ pub fn get_app() -> App<'static> { )); timehist = add_input(add_width(add_non_capturing_regex(add_intervals(timehist)))); + let mut splittimehist = App::new("split-timehist") + .version(clap::crate_version!()) + .setting(AppSettings::ColoredHelp) + .about("Plot histogram of with amount of matches over time, split per match type") + .arg( + Arg::new("format") + .long("format") + .short('f') + .about("Use this string formatting") + .takes_value(true), + ); + splittimehist = add_input_as_option(add_width(add_intervals(splittimehist))).arg( + Arg::new("match") + .about("Count maches for those strings") + .required(true) + .takes_value(true) + .multiple(true), + ); + App::new("lowcharts") .author(clap::crate_authors!()) .version(clap::crate_version!()) @@ -166,6 +195,7 @@ pub fn get_app() -> App<'static> { .subcommand(plot) .subcommand(matches) .subcommand(timehist) + .subcommand(splittimehist) } #[cfg(test)] @@ -210,15 +240,22 @@ mod tests { #[test] fn matches_subcommand_arg_parsing() { - let arg_vec = vec!["lowcharts", "matches", "-", "A", "B", "C"]; + let arg_vec = vec!["lowcharts", "matches", "A", "B", "C"]; let m = get_app().get_matches_from(arg_vec); let sub_m = m.subcommand_matches("matches").unwrap(); assert_eq!("-", sub_m.value_of("input").unwrap()); assert_eq!( - // vec![String::from("A"), String::from("B"), String::from("C")], vec!["A", "B", "C"], sub_m.values_of("match").unwrap().collect::>() ); + let arg_vec = vec!["lowcharts", "matches", "A", "--input", "B", "C"]; + let m = get_app().get_matches_from(arg_vec); + let sub_m = m.subcommand_matches("matches").unwrap(); + assert_eq!("B", sub_m.value_of("input").unwrap()); + assert_eq!( + vec!["A", "C"], + sub_m.values_of("match").unwrap().collect::>() + ); } #[test] @@ -229,4 +266,15 @@ mod tests { assert_eq!("some", sub_m.value_of("input").unwrap()); assert_eq!("foo", sub_m.value_of("regex").unwrap()); } + + #[test] + fn splittimehist_subcommand_arg_parsing() { + let arg_vec = vec!["lowcharts", "split-timehist", "foo", "bar"]; + let m = get_app().get_matches_from(arg_vec); + let sub_m = m.subcommand_matches("split-timehist").unwrap(); + assert_eq!( + vec!["foo", "bar"], + sub_m.values_of("match").unwrap().collect::>() + ); + } } diff --git a/src/main.rs b/src/main.rs index deccda3..732007d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -193,6 +193,38 @@ fn timehist(matches: &ArgMatches) -> i32 { 0 } +/// Implements the timehist cli-subcommand +fn splittime(matches: &ArgMatches) -> i32 { + let mut builder = read::SplitTimeReaderBuilder::default(); + let string_list: Vec = 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!("{:width$}", timehist, width = width); + }; + 0 +} + fn main() { let matches = app::get_app().get_matches(); configure_output( @@ -204,6 +236,7 @@ fn main() { Some(("plot", subcommand_matches)) => plot(subcommand_matches), Some(("matches", subcommand_matches)) => matchbar(subcommand_matches), Some(("timehist", subcommand_matches)) => timehist(subcommand_matches), + Some(("split-timehist", subcommand_matches)) => splittime(subcommand_matches), _ => unreachable!("Invalid subcommand"), }); } diff --git a/src/plot/mod.rs b/src/plot/mod.rs index a34f484..1850e7f 100644 --- a/src/plot/mod.rs +++ b/src/plot/mod.rs @@ -1,9 +1,36 @@ pub use self::histogram::Histogram; pub use self::matchbar::{MatchBar, MatchBarRow}; +pub use self::splittimehist::SplitTimeHistogram; pub use self::timehist::TimeHistogram; pub use self::xy::XyPlot; mod histogram; mod matchbar; +mod splittimehist; mod timehist; mod xy; + +/// Returns a datetime formating string with a resolution that makes sense for a +/// given number of seconds +fn date_fmt_string(seconds: i64) -> &'static str { + match seconds { + x if x > 86400 => "%Y-%m-%d %H:%M:%S", + x if x > 300 => "%H:%M:%S", + x if x > 1 => "%H:%M:%S%.3f", + _ => "%H:%M:%S%.6f", + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_fmt_strings() { + assert_eq!(date_fmt_string(100000), "%Y-%m-%d %H:%M:%S"); + assert_eq!(date_fmt_string(1000), "%H:%M:%S"); + assert_eq!(date_fmt_string(10), "%H:%M:%S%.3f"); + assert_eq!(date_fmt_string(0), "%H:%M:%S%.6f"); + } +} diff --git a/src/plot/splittimehist.rs b/src/plot/splittimehist.rs new file mode 100644 index 0000000..88f7dc4 --- /dev/null +++ b/src/plot/splittimehist.rs @@ -0,0 +1,213 @@ +use std::fmt; + +use chrono::{DateTime, Duration, FixedOffset}; +use yansi::Color::{Blue, Cyan, Green, Magenta, Red}; + +use crate::plot::date_fmt_string; + +const COLORS: &[yansi::Color] = &[Red, Blue, Magenta, Green, Cyan]; + +#[derive(Debug)] +struct TimeBucket { + start: DateTime, + count: Vec, +} + +impl TimeBucket { + fn new(start: DateTime, counts: usize) -> TimeBucket { + TimeBucket { + start, + count: vec![0; counts], + } + } + + fn inc(&mut self, index: usize) { + self.count[index] += 1; + } + + fn total(&self) -> usize { + self.count.iter().sum::() + } +} + +#[derive(Debug)] +pub struct SplitTimeHistogram { + vec: Vec, + strings: Vec, + min: DateTime, + max: DateTime, + step: Duration, + last: usize, + nanos: u64, +} + +impl SplitTimeHistogram { + pub fn new( + size: usize, + strings: Vec, + ts: &[(DateTime, usize)], + ) -> SplitTimeHistogram { + let mut vec = Vec::::with_capacity(size); + let min = ts.iter().min().unwrap().0; + let max = ts.iter().max().unwrap().0; + let step = max - min; + let inc = step / size as i32; + for i in 0..size { + vec.push(TimeBucket::new(min + (inc * i as i32), strings.len())); + } + let mut sth = SplitTimeHistogram { + vec, + strings, + min, + max, + step, + last: size - 1, + nanos: (max - min).num_microseconds().unwrap() as u64, + }; + sth.load(ts); + sth + } + + fn load(&mut self, vec: &[(DateTime, usize)]) { + for x in vec { + self.add(x.0, x.1); + } + } + + fn add(&mut self, ts: DateTime, index: usize) { + if let Some(slot) = self.find_slot(ts) { + self.vec[slot].inc(index); + } + } + + fn find_slot(&self, ts: DateTime) -> Option { + if ts < self.min || ts > self.max { + None + } else { + let x = (ts - self.min).num_microseconds().unwrap() as u64; + Some(((x * self.vec.len() as u64 / self.nanos) as usize).min(self.last)) + } + } + + // Clippy gets badly confused necause self.strings and COLORS may have + // different lengths + #[allow(clippy::needless_range_loop)] + fn fmt_row( + &self, + f: &mut fmt::Formatter, + row: &TimeBucket, + divisor: usize, + widths: &[usize], + ts_fmt: &str, + ) -> fmt::Result { + write!( + f, + "[{}] [", + Blue.paint(format!("{}", row.start.format(ts_fmt))) + )?; + for i in 0..self.strings.len() { + write!( + f, + "{}", + COLORS[i].paint(format!("{:width$}", row.count[i], width = widths[i])) + )?; + if i < self.strings.len() - 1 { + write!(f, "/")?; + } + } + write!(f, "] ")?; + for i in 0..self.strings.len() { + write!( + f, + "{}", + COLORS[i].paint("∎".repeat(row.count[i] / divisor).to_string()) + )?; + } + writeln!(f) + } +} + +impl fmt::Display for SplitTimeHistogram { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let width = f.width().unwrap_or(100); + let total = self.vec.iter().map(|r| r.total()).sum::(); + let top = self.vec.iter().map(|r| r.total()).max().unwrap_or(1); + let divisor = 1.max(top / width); + // These are the widths of every count column + let widths: Vec = (0..self.strings.len()) + .map(|i| { + self.vec + .iter() + .map(|r| r.count[i].to_string().len()) + .max() + .unwrap() + }) + .collect(); + + writeln!(f, "Matches: {}.", total)?; + for (i, s) in self.strings.iter().enumerate() { + let total = self.vec.iter().map(|r| r.count[i]).sum::(); + writeln!(f, "{}: {}.", COLORS[i].paint(s), total)?; + } + writeln!( + f, + "Each {} represents a count of {}", + Red.paint("∎"), + divisor + )?; + let ts_fmt = date_fmt_string(self.step.num_seconds()); + for row in self.vec.iter() { + self.fmt_row(f, row, divisor, &widths, ts_fmt)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use yansi::Paint; + + #[test] + fn test_big_time_interval() { + Paint::disable(); + let mut vec = Vec::<(DateTime, usize)>::new(); + vec.push(( + DateTime::parse_from_rfc3339("2021-04-15T04:25:00+00:00").unwrap(), + 1, + )); + vec.push(( + DateTime::parse_from_rfc3339("2022-04-15T04:25:00+00:00").unwrap(), + 1, + )); + vec.push(( + DateTime::parse_from_rfc3339("2022-04-15T04:25:00+00:00").unwrap(), + 0, + )); + vec.push(( + DateTime::parse_from_rfc3339("2022-04-15T04:25:00+00:00").unwrap(), + 2, + )); + for _ in 0..11 { + vec.push(( + DateTime::parse_from_rfc3339("2023-04-15T04:25:00+00:00").unwrap(), + 2, + )); + } + let th = SplitTimeHistogram::new( + 3, + vec!["one".to_string(), "two".to_string(), "three".to_string()], + &vec, + ); + println!("{}", th); + let display = format!("{}", th); + assert!(display.contains("Matches: 15")); + assert!(display.contains("one: 1.")); + assert!(display.contains("two: 2.")); + assert!(display.contains("three: 12.")); + assert!(display.contains("represents a count of 1")); + assert!(display.contains("[2021-04-15 04:25:00] [0/1/ 0] ∎\n")); + assert!(display.contains("[2021-12-14 12:25:00] [1/1/ 1] ∎∎∎\n")); + assert!(display.contains("[2022-08-14 20:25:00] [0/0/11] ∎∎∎∎∎∎∎∎∎∎∎\n")); + } +} diff --git a/src/plot/timehist.rs b/src/plot/timehist.rs index 9d247b9..e7afb51 100644 --- a/src/plot/timehist.rs +++ b/src/plot/timehist.rs @@ -3,13 +3,14 @@ use std::fmt; use chrono::{DateTime, Duration, FixedOffset}; use yansi::Color::{Blue, Green, Red}; +use crate::plot::date_fmt_string; + #[derive(Debug)] struct TimeBucket { start: DateTime, count: usize, } -// TODO: use trait for Bucket and TimeBucket impl TimeBucket { fn new(start: DateTime) -> TimeBucket { TimeBucket { start, count: 0 } @@ -31,7 +32,6 @@ pub struct TimeHistogram { nanos: u64, } -// TODO: use trait for Histogram and TimeHistogram impl TimeHistogram { pub fn new(size: usize, ts: &[DateTime]) -> TimeHistogram { let mut vec = Vec::::with_capacity(size); @@ -74,15 +74,6 @@ impl TimeHistogram { Some(((x * self.vec.len() as u64 / self.nanos) as usize).min(self.last)) } } - - fn date_fmt_string(&self) -> &str { - match self.step.num_seconds() { - x if x > 86400 => "%Y-%m-%d %H:%M:%S", - x if x > 300 => "%H:%M:%S", - x if x > 1 => "%H:%M:%S%.3f", - _ => "%H:%M:%S%.6f", - } - } } impl fmt::Display for TimeHistogram { @@ -104,12 +95,12 @@ impl fmt::Display for TimeHistogram { Red.paint("∎"), Blue.paint(divisor.to_string()), )?; - let fmt = self.date_fmt_string(); + let ts_fmt = date_fmt_string(self.step.num_seconds()); for row in self.vec.iter() { writeln!( f, "[{label}] [{count}] {bar}", - label = Blue.paint(format!("{}", row.start.format(fmt))), + label = Blue.paint(format!("{}", row.start.format(ts_fmt))), count = Green.paint(format!("{:width$}", row.count, width = width_count)), bar = Red.paint(format!("{:∎ Result { + pub fn new(log_line: &str, format_string: &Option) -> Result { + match format_string { + Some(ts_format) => Self::new_with_format(&log_line, &ts_format), + None => Self::new_with_guess(&log_line), + } + } + + fn new_with_guess(log_line: &str) -> Result { // All the guess work assume that datetimes start with a digit, and that // digit is the first digit in the log line. The approach is to locate // the 1st digit and then try to parse as much text as possible with any @@ -50,7 +57,7 @@ impl LogDateParser { Err(format!("Could not parse a timestamp in {}", log_line)) } - pub fn new_with_format(log_line: &str, format_string: &str) -> Result { + fn new_with_format(log_line: &str, format_string: &str) -> Result { // We look for where the timestamp is in logs using a brute force // approach with 1st log line, but capping the max length we scan for for i in 0..log_line.len() { diff --git a/src/read/mod.rs b/src/read/mod.rs index ede5809..b909b17 100644 --- a/src/read/mod.rs +++ b/src/read/mod.rs @@ -1,8 +1,10 @@ pub use self::buckets::{DataReader, DataReaderBuilder}; +pub use self::splittimes::{SplitTimeReader, SplitTimeReaderBuilder}; pub use self::times::TimeReaderBuilder; mod buckets; mod dateparser; +mod splittimes; mod times; use std::fs::File; diff --git a/src/read/splittimes.rs b/src/read/splittimes.rs new file mode 100644 index 0000000..ceb32d1 --- /dev/null +++ b/src/read/splittimes.rs @@ -0,0 +1,169 @@ +use std::io::BufRead; + +use chrono::{DateTime, FixedOffset}; + +use crate::read::dateparser::LogDateParser; +use crate::read::open_file; + +#[derive(Default, Builder)] +pub struct SplitTimeReader { + #[builder(setter(strip_option), default)] + matches: Vec, + #[builder(setter(strip_option), default)] + ts_format: Option, +} + +impl SplitTimeReader { + pub fn read(&self, path: &str) -> Vec<(DateTime, usize)> { + let mut vec: Vec<(DateTime, usize)> = Vec::new(); + let mut iterator = open_file(path).lines(); + let first_line = match iterator.next() { + Some(Ok(as_string)) => as_string, + Some(Err(error)) => { + error!("{}", error); + return vec; + } + _ => return vec, + }; + let parser = match LogDateParser::new(&first_line, &self.ts_format) { + Ok(p) => p, + Err(error) => { + error!("Could not figure out parsing strategy: {}", error); + return vec; + } + }; + if let Ok(x) = parser.parse(&first_line) { + self.push_conditionally(x, &mut vec, &first_line); + } + for line in iterator { + match line { + Ok(string) => { + if let Ok(x) = parser.parse(&string) { + self.push_conditionally(x, &mut vec, &string); + } + } + Err(error) => error!("{}", error), + } + } + vec + } + + fn push_conditionally( + &self, + d: DateTime, + vec: &mut Vec<(DateTime, usize)>, + line: &str, + ) { + for (i, s) in self.matches.iter().enumerate() { + if line.contains(s) { + vec.push((d, i)); + } + } + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn split_time_reader_basic() { + let mut builder = SplitTimeReaderBuilder::default(); + builder.matches(vec![ + "foo".to_string(), + "bar".to_string(), + "gnat".to_string(), + ]); + let reader = builder.build().unwrap(); + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "[2021-04-15T06:25:31+00:00] foo").unwrap(); + writeln!(file, "[2021-04-15T06:26:31+00:00] bar").unwrap(); + writeln!(file, "[2021-04-15T06:27:31+00:00] foobar").unwrap(); + writeln!(file, "[2021-04-15T06:28:31+00:00] none").unwrap(); + writeln!(file, "[2021-04-15T06:29:31+00:00] foo").unwrap(); + writeln!(file, "[2021-04-15T06:30:31+00:00] none again").unwrap(); + writeln!(file, "not even a timestamp").unwrap(); + let ts = reader.read(file.path().to_str().unwrap()); + assert_eq!(ts.len(), 5); + assert_eq!( + ts[0].0, + DateTime::parse_from_rfc3339("2021-04-15T06:25:31+00:00").unwrap() + ); + assert_eq!( + ts[1].0, + DateTime::parse_from_rfc3339("2021-04-15T06:26:31+00:00").unwrap() + ); + assert_eq!( + ts[2].0, + DateTime::parse_from_rfc3339("2021-04-15T06:27:31+00:00").unwrap() + ); + assert_eq!( + ts[3].0, + DateTime::parse_from_rfc3339("2021-04-15T06:27:31+00:00").unwrap() + ); + assert_eq!( + ts[4].0, + DateTime::parse_from_rfc3339("2021-04-15T06:29:31+00:00").unwrap() + ); + assert_eq!(ts[0].1, 0); + assert_eq!(ts[1].1, 1); + assert_eq!(ts[2].1, 0); + assert_eq!(ts[3].1, 1); + assert_eq!(ts[4].1, 0); + } + + #[test] + fn split_time_no_matches() { + let reader = SplitTimeReader::default(); + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "[2021-04-15T06:25:31+00:00] foo").unwrap(); + writeln!(file, "[2021-04-15T06:26:31+00:00] bar").unwrap(); + let ts = reader.read(file.path().to_str().unwrap()); + assert_eq!(ts.len(), 0); + } + + #[test] + fn split_time_zero_matches() { + let mut builder = SplitTimeReaderBuilder::default(); + builder.matches(vec![ + "foo".to_string(), + "bar".to_string(), + "gnat".to_string(), + ]); + builder.ts_format(String::from("%Y_%m_%d %H:%M")); + let reader = builder.build().unwrap(); + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "_2021_04_15 06:25] none").unwrap(); + writeln!(file, "_2021_04_15 06:26] none").unwrap(); + writeln!(file, "[2021-04-15T06:25:31+00:00] foo").unwrap(); + let ts = reader.read(file.path().to_str().unwrap()); + assert_eq!(ts.len(), 0); + } + + #[test] + fn split_time_bad_guess() { + let mut builder = SplitTimeReaderBuilder::default(); + builder.matches(vec![ + "foo".to_string(), + "bar".to_string(), + "gnat".to_string(), + ]); + let reader = builder.build().unwrap(); + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "XXX none").unwrap(); + writeln!(file, "[2021-04-15T06:25:31+00:00] foo").unwrap(); + let ts = reader.read(file.path().to_str().unwrap()); + assert_eq!(ts.len(), 0); + } + + #[test] + fn split_time_bad_file() { + let reader = SplitTimeReader::default(); + let file = NamedTempFile::new().unwrap(); + let ts = reader.read(file.path().to_str().unwrap()); + assert_eq!(ts.len(), 0); + } +} diff --git a/src/read/times.rs b/src/read/times.rs index 9c5b487..bc61702 100644 --- a/src/read/times.rs +++ b/src/read/times.rs @@ -30,7 +30,7 @@ impl TimeReader { } _ => return vec, }; - let parser = match self.build_parser(&first_line) { + let parser = match LogDateParser::new(&first_line, &self.ts_format) { Ok(p) => p, Err(error) => { error!("Could not figure out parsing strategy: {}", error); @@ -69,13 +69,6 @@ impl TimeReader { vec } - fn build_parser(&self, line: &str) -> Result { - match &self.ts_format { - Some(ts_format) => LogDateParser::new_with_format(&line, &ts_format), - None => LogDateParser::new_with_guess(&line), - } - } - fn push_conditionally( &self, d: DateTime, diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 0a734bd..1a039e8 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -103,6 +103,40 @@ fn test_matchbar() { .stdout(predicate::str::contains("\n[bar ] [2] ∎∎\n")); } +#[test] +fn test_splittime() { + let mut cmd = Command::cargo_bin("lowcharts").unwrap(); + cmd.arg("split-timehist") + .arg("1") + .arg("2") + .arg("3") + .arg("4") + .arg("5") + .arg("6") + .assert() + .failure() + .stderr(predicate::str::contains( + "Only 5 different sub-groups are supported", + )); + let mut cmd = Command::cargo_bin("lowcharts").unwrap(); + cmd.arg("split-timehist") + .arg("A") + .arg("B") + .arg("C") + .arg("--intervals") + .arg("2") + .write_stdin("1619655527.888165 A\n1619655528.888165 A\n1619655527.888165 B\n") + .assert() + .success() + .stdout(predicate::str::contains("Matches: 3.")) + .stdout(predicate::str::contains("A: 2")) + .stdout(predicate::str::contains("B: 1.")) + .stdout(predicate::str::contains("C: 0.")) + .stdout(predicate::str::contains("Each ∎ represents a count of 1\n")) + .stdout(predicate::str::contains("[00:18:47.888165] [1/1/0] ∎∎\n")) + .stdout(predicate::str::contains("[00:18:48.388165] [1/0/0] ∎\n")); +} + #[test] fn test_plot() { let mut cmd = Command::cargo_bin("lowcharts").unwrap();