use chrono::prelude::*;
use fs2::FileExt;
use hmmcli::{entries::Entries, entry::Entry, Result};
use human_panic::setup_panic;
use std::fs::File;
use std::io::{BufReader, BufWriter, Read};
use std::path::PathBuf;
use std::process::{exit, Command};
use structopt::StructOpt;
use tempfile::NamedTempFile;
#[derive(Debug, StructOpt)]
#[structopt(name = "hmm", about = "Command line note taking")]
struct Opt {
#[structopt(long = "path")]
path: Option<PathBuf>,
#[structopt(long = "editor", env)]
editor: Option<String>,
message: Vec<String>,
}
fn main() {
setup_panic!();
if let Err(e) = app(Opt::from_args()) {
eprintln!("{}", e);
exit(1);
}
}
fn app(opt: Opt) -> Result<()> {
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);
fopts.append(true);
let mut f = match fopts.open(&path) {
Ok(f) => f,
Err(e) => {
return Err(format!(
"Couldn't open or create file at {}: {}",
path.to_string_lossy(),
e
)
.into())
}
};
let mut msg = itertools::join(opt.message, " ");
if msg.is_empty() {
if opt.editor.is_none() {
return Err("Unable to find an editor, set your EDITOR environment variable".into());
}
msg = compose_entry(&opt.editor.unwrap())?;
}
f.lock_exclusive()?;
let mut entries = Entries::new(BufReader::new(&mut f));
if entries.len()? > 0 {
entries.seek_to_end()?;
let entry = entries.prev_entry()?.unwrap();
if entry.datetime() > &Utc::now() {
return Err("clock skew detected, writing an entry now would break the ordering of your hmm file, please try again in a moment".into());
}
entries.seek_to_end()?;
entries.prev_entry()?;
}
let res = Entry::with_message(&msg).write(BufWriter::new(&f));
f.unlock()?;
res
}
fn compose_entry(editor: &str) -> Result<String> {
let f = NamedTempFile::new()?;
let path = f.into_temp_path();
let args = shellwords::split(editor).map_err(|_| "mismatched quotes in editor command")?;
let mut cmd = match args.as_slice() {
[] => return Err("no editor specified".into()),
[cmd] => {
let mut c = Command::new(cmd);
c.arg(&path);
c
}
[cmd, args @ ..] => {
let mut c = Command::new(cmd);
c.args(args).arg(&path);
c
}
};
let status = cmd.status()?;
if !status.success() {
return Err("something went wrong composing entry, please try again".into());
}
let mut s = String::new();
File::open(path)?.read_to_string(&mut s)?;
Ok(s)
}
#[cfg(test)]
mod tests {
use super::*;
use assert_cmd::{assert::Assert, prelude::*};
use escargot::{CargoBuild, CargoRun};
use hmmcli::entries::Entries;
use lazy_static::lazy_static;
use std::io::BufReader;
use std::path::PathBuf;
use std::{fs::File, path::Path};
use tempfile::NamedTempFile;
use test_case::test_case;
lazy_static! {
static ref HMM: CargoRun = CargoBuild::new()
.bin("hmm")
.current_release()
.current_target()
.run()
.unwrap();
}
fn run_with_path(path: &Path, args: Vec<&str>) -> Assert {
HMM.command()
.arg("--path")
.arg(path.as_os_str())
.args(args)
.assert()
}
fn new_tempfile_path() -> PathBuf {
NamedTempFile::new().unwrap().keep().unwrap().1
}
#[test_case(vec!["hello world"] => "hello world" ; "single argument, single line entry")]
#[test_case(vec!["hello", "world"] => "hello world" ; "multiple argument, single line entry")]
#[test_case(vec!["hello\nworld"] => "hello\nworld" ; "single argument, multiple line entry")]
#[test_case(vec!["hello\n", "world"] => "hello\n world" ; "multiple argument, multiple line entry")]
#[test_case(vec!["--editor", "cat"] => "" ; "the editor argument works")]
#[test_case(vec!["--editor", "perl -e \"my $f = $ARGV[0]; open(my $fh, '>', $f) or die 'could not open file'; print $fh 'hello world'\""] => "hello world" ; "the editor argument actually creates entries")]
fn test_hmm_single_invocation(args: Vec<&str>) -> String {
let path = new_tempfile_path();
let assert = run_with_path(&path, args);
assert.success();
let mut entries = Entries::new(BufReader::new(File::open(&path).unwrap()));
entries.next_entry().unwrap().unwrap().message().to_owned()
}
#[test_case(vec!["1", "2"] => vec!["1", "2"] ; "two invocations")]
#[test_case(vec!["1", "2", "3"] => vec!["1", "2", "3"] ; "three invocations")]
#[test_case(vec!["1", "2", "3", "4"] => vec!["1", "2", "3", "4"] ; "four invocations")]
fn test_hmm_multiple_invocations(messages: Vec<&str>) -> Vec<String> {
let path = new_tempfile_path();
for message in &messages {
let assert = run_with_path(&path, vec![message]);
assert!(
assert.get_output().stdout.is_empty(),
"expected stdout to be empty"
);
assert.success();
}
let mut date: DateTime<FixedOffset> =
DateTime::parse_from_rfc3339("1970-01-01T00:00:00+00:00").unwrap();
let r = BufReader::new(File::open(&path).unwrap());
let entries = Entries::new(r);
let mut messages: Vec<String> = Vec::with_capacity(messages.len());
for result in entries {
let entry = result.unwrap();
messages.push(entry.message().to_owned());
assert!(&date <= entry.datetime());
date = entry.datetime().to_owned();
}
messages
}
#[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")]
fn test_hmm_errors(args: Vec<&str>, error: &str) {
let assert = HMM.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
);
}
}