diff --git a/src/commands/display.rs b/src/commands/display.rs index e37175b..0ebf2a4 100644 --- a/src/commands/display.rs +++ b/src/commands/display.rs @@ -74,6 +74,7 @@ where format.print_formatted( entries, &mut streams.out, + &mut streams.err, facts, ids, )?; diff --git a/src/error.rs b/src/error.rs index b8d23ae..81c1463 100644 --- a/src/error.rs +++ b/src/error.rs @@ -4,6 +4,15 @@ use std::io; use thiserror::Error; use chrono::NaiveDateTime; +use itertools::Itertools; + +fn format_paths(paths: &[PathBuf]) -> String { + paths + .iter() + .map(|p| format!(" - {}", p.display())) + .collect_vec() + .join("\n") +} #[derive(Debug, Error)] pub enum Error { @@ -11,9 +20,9 @@ pub enum Error { UnimplementedCommand(String), /// Sometimes a specific variant for an error is not necessary if the error - /// can only happen in one place in the code. This is that the generic error + /// can only happen in one place in the code. This is what the generic error /// is for and nothing else. - #[error("Error: {0}")] + #[error("{0}")] GenericFailure(String), #[error("Sqlite error: {0}")] @@ -180,6 +189,42 @@ Is there a permissions issue? Here's the underlaying error: #[error("Trying to run system command sqlite3 failed")] Sqlite3CommandFailedUnkown, + + #[error("The specified name for a custom formatter \"{0}\" is not valid. Only ascii +letters, numbers, dots, dashes and underscores are allowed.")] + InvalidCustomFormatter(String), + + #[error("You have specified a custom formatter \"{0}\" but your config file doesn't say +where to look for it. + +You can set a path using + +t config --formatter-search-paths ..")] + NoFormatterSearchPaths(String), + + #[error("Could not find a formatter with name '{name}' in any of the following paths: + +{0} + +which where taken from your config file located at + + {config_at} + +Perhaps is mispelled?", format_paths(.paths))] + FormatterNotFound { + name: String, + paths: Vec, + config_at: PathBuf, + }, + + #[error("The custom formatter located at: + +{0} + +failed with error: + +{1}")] + CustomFormatterFailed(PathBuf, std::io::Error), } pub type Result = result::Result; diff --git a/src/formatters.rs b/src/formatters.rs index b1a8064..785eb61 100644 --- a/src/formatters.rs +++ b/src/formatters.rs @@ -12,6 +12,7 @@ pub mod csv; pub mod json; pub mod ids; pub mod ical; +pub mod custom; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -37,16 +38,18 @@ impl Formatter { /// to the local timezone is given in `offset` to prevent this function from /// using a secondary effect to retrieve the time and conver dates. This /// also makes it easier to test. - pub fn print_formatted(&self, entries: Vec, out: &mut W, facts: &Facts, ids: bool) -> error::Result<()> { + pub fn print_formatted(&self, entries: Vec, out: &mut O, err: &mut E, facts: &Facts, ids: bool) -> error::Result<()> + where + O: Write, + E: Write, + { match &self { Formatter::Text => text::print_formatted(entries, out, facts, ids)?, Formatter::Csv => csv::print_formatted(entries, out, ids)?, Formatter::Json => json::print_formatted(entries, out)?, Formatter::Ids => ids::print_formatted(entries, out)?, Formatter::Ical => ical::print_formatted(entries, out, facts.now)?, - Formatter::Custom(name) => { - panic!("attempted custom formatter with name {} which is not implemented", name); - } + Formatter::Custom(name) => custom::print_formatted(name, entries, out, err, facts)?, } Ok(()) diff --git a/src/formatters/custom.rs b/src/formatters/custom.rs new file mode 100644 index 0000000..84c17e3 --- /dev/null +++ b/src/formatters/custom.rs @@ -0,0 +1,158 @@ +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::io::Read; + +use csv::Writer; +use chrono::SecondsFormat; + +use crate::error::{Result, Error::*}; +use crate::models::Entry; +use crate::commands::Facts; + +/// tries to find a formatter with the given name in one of the possible paths +fn formatter_path(formatters: &[PathBuf], name: &str) -> Option { + formatters.iter().find_map(|f| { + let mut path = f.clone(); + + path.push(name); + + if path.exists() { + Some(path) + } else { + None + } + }) +} + +fn is_valid_formatter_name(formatter: &str) -> bool { + formatter.chars().filter(|&c| { + !(c.is_alphanumeric() || c == '.' || c == '-' || c == '_') + }).count() == 0 +} + +pub fn print_formatted(formatter: &str, entries: Vec, out: &mut O, err: &mut E, facts: &Facts) -> Result<()> +where + O: Write, + E: Write, +{ + if !is_valid_formatter_name(formatter) { + return Err(InvalidCustomFormatter(formatter.into())); + } + + if facts.config.formatter_search_paths.is_empty() { + return Err(NoFormatterSearchPaths(formatter.into())); + } + + let path = formatter_path(&facts.config.formatter_search_paths, formatter).ok_or_else(|| FormatterNotFound { + name: formatter.into(), + paths: facts.config.formatter_search_paths.clone(), + config_at: facts.config.path.clone().unwrap_or_else(|| "in memory".into()), + })?; + + let config = "{}"; + + let mut command = Command::new(&path); + + command + .arg(config) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = command.spawn().map_err(|e| CustomFormatterFailed(path, e))?; + + let stdin = child.stdin.take().expect("Failed to take stdin"); + let mut stdout = child.stdout.take().expect("Failed to take stdout"); + let mut stderr = child.stderr.take().expect("Failed to take stdout"); + + let mut wtr = Writer::from_writer(stdin); + let mut captured_out = Vec::new(); + let mut captured_err = Vec::new(); + + for entry in entries { + // write to process' stdin + wtr.write_record(&[ + entry.id.to_string(), + entry.start.to_rfc3339_opts(SecondsFormat::Micros, true), + entry.end.map(|t| t.to_rfc3339_opts(SecondsFormat::Micros, true)).unwrap_or_else(|| "".into()), + entry.note.unwrap_or_else(|| "".into()), + entry.sheet, + ])?; + + // read process' stdout and stderr + stdout.read_to_end(&mut captured_out)?; + write!(out, "{}", String::from_utf8_lossy(&captured_out))?; + captured_out.clear(); + + stderr.read_to_end(&mut captured_err)?; + write!(err, "{}", String::from_utf8_lossy(&captured_err))?; + captured_err.clear(); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::config::Config; + + use super::*; + + #[test] + fn errors_if_invalid_formatter_name() { + let mut out = Vec::new(); + let mut err = Vec::new(); + let facts = Facts::new(); + let err = print_formatted("pol/lo", Vec::new(), &mut out, &mut err, &facts).unwrap_err(); + + assert_eq!(err.to_string(), "\ +The specified name for a custom formatter \"pol/lo\" is not valid. Only ascii +letters, numbers, dots, dashes and underscores are allowed. +"); + } + + #[test] + fn errors_if_no_formatter_search_paths() { + let mut out = Vec::new(); + let mut err = Vec::new(); + let facts = Facts::new(); + let err = print_formatted("pollo", Vec::new(), &mut out, &mut err, &facts).unwrap_err(); + + assert_eq!(err.to_string(), "\ +You have specified a custom formatter \"pollo\" but your config file doesn't say +where to look for it. + +You can set a path using + +t config --formatter-search-paths .. +"); + } + + #[test] + fn nice_error_if_no_such_formatter() { + let mut out = Vec::new(); + let mut err = Vec::new(); + let facts = Facts::new().with_config(Config { + formatter_search_paths: vec![ + PathBuf::from("/not/a/path"), + PathBuf::from("/tmp/foo/var"), + ], + path: Some(PathBuf::from("/etc/tiempo/config.toml")), + ..Default::default() + }); + let err = print_formatted("pollo", Vec::new(), &mut out, &mut err, &facts).unwrap_err(); + + assert_eq!(err.to_string(), "\ +Could not find a formatter with name 'pollo' in any of the following paths: + + - /not/a/path + - /tmp/foo/var + +which where taken from your config file located at + + /etc/tiempo/config.toml + +Perhaps is mispelled?"); + } +}