use chrono::prelude::*;
use hmmcli::{entries::Entries, format::Format, Result};
use human_panic::setup_panic;
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::PathBuf;
use std::process::exit;
use structopt::StructOpt;
#[derive(Debug, StructOpt)]
#[structopt(name = "hmmq", about = "Query your hmm file")]
struct Opt {
#[structopt(long = "path")]
path: Option<PathBuf>,
#[structopt(
long = "format",
default_value = "╭ {{ color \"blue\" (strftime \"%Y-%m-%d %H:%M\" datetime) }}\n{{ indent (markdown message) }}╰─────────────────"
)]
format: String,
#[structopt(long = "format-file")]
format_file: Option<PathBuf>,
#[structopt(long = "random")]
random: bool,
#[structopt(short = "c", long = "count")]
count: bool,
#[structopt(long = "raw")]
raw: bool,
#[structopt(long = "first")]
first: Option<i64>,
#[structopt(long = "last")]
last: Option<i64>,
#[structopt(short = "s", long = "start", parse(try_from_str = parse_date_arg))]
start: Option<DateTime<FixedOffset>>,
#[structopt(short = "e", long = "end", parse(try_from_str = parse_date_arg))]
end: Option<DateTime<FixedOffset>>,
#[structopt(long = "contains")]
contains: Option<String>,
#[structopt(long = "regex")]
regex: Option<String>,
}
fn main() {
setup_panic!();
if let Err(e) = app(Opt::from_args()) {
eprintln!("{}", e);
exit(1);
}
}
fn app(opt: Opt) -> Result<()> {
let mut formatter = if let Some(path) = opt.format_file {
let mut f = File::open(path)?;
let mut contents = String::new();
f.read_to_string(&mut contents)?;
Format::with_template(&contents)?
} else {
Format::with_template(&opt.format)?
};
let path = opt
.path
.unwrap_or_else(|| dirs::home_dir().unwrap().join(".hmm"));
let mut fopts = std::fs::OpenOptions::new();
fopts.create(true);
fopts.read(true);
fopts.write(true);
let f = fopts.open(&path).map_err(|e| {
format!(
"Couldn't open or create file at {}: {}",
path.to_string_lossy(),
e
)
})?;
let mut entries = Entries::new(BufReader::new(f));
if opt.random {
if let Some(entry) = entries.rand_entry()? {
println!("{}", formatter.format_entry(&entry)?);
}
return Ok(());
}
if opt.regex.is_some() && opt.contains.is_some() {
return Err("You can only specify one of --contains and --regex".into());
}
let regex = match opt.regex {
None => None,
Some(s) => Some(regex::Regex::new(&s)?),
};
if opt.first.is_some() && opt.last.is_some() {
return Err("cannot specify --first and --last at the same time".into());
}
if let Some(first) = opt.first {
if first < 1 {
return Err("--first must be greater than 0".into());
}
}
if let Some(last) = opt.last {
if last < 1 {
return Err("--last must be greater than 0".into());
}
}
if let Some(ref start_date) = opt.start {
entries.seek_to_first(start_date)?;
}
if let Some(last) = opt.last {
if let Some(ref end_date) = opt.end {
entries.seek_to_first(end_date)?;
} else {
let len = entries.len()?;
entries.at(len)?;
}
for _ in 0..last {
entries.seek_to_prev()?;
}
}
let mut count = 0;
loop {
if opt.first.is_some() && count >= opt.first.unwrap() {
break;
}
match entries.next_entry()? {
None => break,
Some(entry) => {
if opt.end.is_some() && opt.end.as_ref().unwrap() <= entry.datetime() {
break;
}
if opt.contains.is_some()
&& !entry.message().contains(opt.contains.as_ref().unwrap())
{
continue;
}
if regex.is_some() && !regex.as_ref().unwrap().is_match(entry.message()) {
continue;
}
if !opt.count {
if opt.raw {
print!("{}", entry.to_csv_row()?);
} else {
println!("{}", formatter.format_entry(&entry)?);
}
}
count += 1;
}
};
}
if opt.count {
println!("{}", count);
}
Ok(())
}
fn parse_date_arg(s: &str) -> Result<DateTime<FixedOffset>> {
if let Ok(d) = parse_local_datetime_str(&format!("{}-01-01T00:00:00", s), "%Y-%m-%dT%H:%M:%S") {
return Ok(d.into());
}
if let Ok(d) = parse_local_datetime_str(&format!("{}-01T00:00:00", s), "%Y-%m-%dT%H:%M:%S") {
return Ok(d.into());
}
if let Ok(d) = parse_local_datetime_str(&format!("{}T00:00:00", s), "%Y-%m-%dT%H:%M:%S") {
return Ok(d.into());
}
if let Ok(d) = parse_local_datetime_str(&format!("{}:00:00", s), "%Y-%m-%dT%H:%M:%S") {
return Ok(d.into());
}
if let Ok(d) = parse_local_datetime_str(&format!("{}:00", s), "%Y-%m-%dT%H:%M:%S") {
return Ok(d.into());
}
if let Ok(d) = parse_local_datetime_str(s, "%Y-%m-%dT%H:%M:%S") {
return Ok(d.into());
}
Err(format!("unrecognised date format: \"{}\", accepted formats include things like:\n - 2012\n - 2012-01\n - 2012-01-24\n - 2012-01-24T16\n - 2012-01-24T16:20\n - 2012-01-24T16:20:30", s).into())
}
fn parse_local_datetime_str(s: &str, format: &str) -> Result<DateTime<Utc>> {
let d = NaiveDateTime::parse_from_str(s, format)?;
let local_result = Utc.from_local_datetime(&d);
Ok(local_result.earliest().unwrap_or_else(|| {
local_result
.latest()
.unwrap_or_else(|| local_result.unwrap())
}))
}
#[cfg(test)]
mod tests {
use super::*;
use assert_cmd::{assert::Assert, prelude::*};
use escargot::{CargoBuild, CargoRun};
use lazy_static::lazy_static;
use std::path::PathBuf;
use std::{io::Write, path::Path};
use tempfile::NamedTempFile;
use test_case::test_case;
lazy_static! {
static ref HMMQ: CargoRun = CargoBuild::new()
.bin("hmmq")
.current_release()
.current_target()
.run()
.unwrap();
}
fn run_with_path(path: &Path, args: Vec<&str>) -> Assert {
HMMQ.command()
.arg("--path")
.arg(path.as_os_str())
.args(args)
.assert()
}
fn new_tempfile(content: &str) -> PathBuf {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f.keep().unwrap().1
}
#[test_case("2012" => "2012-01-01T00:00:00+00:00" ; "y")]
#[test_case("2012-02" => "2012-02-01T00:00:00+00:00" ; "ym")]
#[test_case("2012-02-02" => "2012-02-02T00:00:00+00:00" ; "ymd")]
#[test_case("2012-02-02T02" => "2012-02-02T02:00:00+00:00" ; "ymdh")]
#[test_case("2012-02-02T02:02" => "2012-02-02T02:02:00+00:00" ; "ymdhm")]
#[test_case("2012-02-02T02:02:02" => "2012-02-02T02:02:02+00:00" ; "ymdhms")]
fn test_parse_date_arg(s: &str) -> String {
parse_date_arg(s).unwrap().to_rfc3339()
}
const TESTDATA: &str = "2020-01-01T00:01:00.899849209+00:00,\"\"\"1\"\"\"
2020-02-12T23:08:40.987613062+00:00,\"\"\"2\"\"\"
2020-03-12T00:00:00+00:00,\"\"\"3\"\"\"
2020-04-12T23:28:45.726598931+00:00,\"\"\"4\"\"\"
2020-05-12T23:28:48.495151445+00:00,\"\"\"5\"\"\"
2020-06-13T10:12:53.353050231+00:00,\"\"\"6\"\"\"
";
#[test_case(vec!["--first", "1", "--raw"] => "2020-01-01T00:01:00.899849209+00:00,\"\"\"1\"\"\"\n")]
#[test_case(vec!["--first", "2", "--format", "{{ message }}"] => "1\n2\n" ; "get first two lines")]
#[test_case(vec!["--first", "1", "--start", "2020-02", "--format", "{{ message }}"] => "2\n")]
#[test_case(vec!["--last", "1", "--raw"] => "2020-06-13T10:12:53.353050231+00:00,\"\"\"6\"\"\"\n")]
#[test_case(vec!["--last", "2", "--format", "{{ message }}"] => "5\n6\n" ; "get last two lines")]
#[test_case(vec!["--start", "2021", "--end", "2020"] => "")]
#[test_case(vec!["--first", "1", "--format", "{{ indent message }}"] => "│ 1\n")]
#[test_case(vec!["--first", "1", "--format", "{{ strftime \"%Y-%m-%d\" datetime }}"] => "2020-01-01\n")]
#[test_case(vec!["--start", "2020-01-01T00:01:00", "--end", "2020-03-12T00:00:00", "--format", "{{ message }}"] => "1\n2\n")]
#[test_case(vec!["--last", "1", "--end", "2020-03-12T00:00:00", "--format", "{{ message }}"] => "2\n")]
#[test_case(vec!["--start", "2020-06-13", "--end", "2020-06-14", "--format", "{{ message }}"] => "6\n")]
#[test_case(vec!["--contains", "1", "--format", "{{ message }}"] => "1\n")]
#[test_case(vec!["--regex", "(1|2)", "--format", "{{ message }}"] => "1\n2\n")]
#[test_case(vec!["--raw"] => TESTDATA)]
#[test_case(vec!["--count"] => "6\n")]
#[test_case(vec!["--first", "1", "--count"] => "1\n")]
#[test_case(vec!["--contains", "4", "--count"] => "1\n")]
#[test_case(vec!["--contains", "nope", "--count"] => "0\n")]
fn test_hmmq(args: Vec<&str>) -> String {
let path = new_tempfile(TESTDATA);
let assert = run_with_path(&path, args);
String::from_utf8(assert.get_output().stdout.clone()).unwrap()
}
#[test_case(vec!["--path", "/this/path/does/not/exist"], "Couldn't open or create file at")]
#[test_case(vec!["--path", "something", "--path", "something"], "The argument '--path <path>' was provided more than once")]
#[test_case(vec!["--nonexistent"], "Found argument '--nonexistent' which wasn't expected")]
#[test_case(vec!["--contains", "a", "--regex", "b"], "You can only specify one of --contains and --regex")]
#[test_case(vec!["--regex", "("], "regex parse error")]
#[test_case(vec!["--path", new_tempfile("").to_str().unwrap(), "--first=-1"], "--first must be greater than 0")]
#[test_case(vec!["--path", new_tempfile("").to_str().unwrap(), "--first", "0"], "--first must be greater than 0")]
#[test_case(vec!["--path", new_tempfile("").to_str().unwrap(), "--last=-1"], "--last must be greater than 0")]
#[test_case(vec!["--path", new_tempfile("").to_str().unwrap(), "--last", "0"], "--last must be greater than 0")]
#[test_case(vec!["--path", new_tempfile("").to_str().unwrap(), "--start", "nope"], "unrecognised date format")]
#[test_case(vec!["--path", new_tempfile("").to_str().unwrap(), "--end", "nope"], "unrecognised date format")]
#[test_case(vec!["--path", new_tempfile("").to_str().unwrap(), "--format", "{{"], "invalid handlebars syntax")]
fn test_hmmq_errors(args: Vec<&str>, error: &str) {
let assert = HMMQ.command().args(args).assert();
let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap();
assert.failure();
assert!(
stderr.contains(error),
"could not find \"{}\" in \"{}\"",
error,
stderr
);
}
}