mod byday;
mod csv;
mod dayone;
mod doing;
pub mod helpers;
pub mod html;
pub mod import;
mod json;
mod markdown;
mod taskpaper;
mod timeline;
use doing_config::Config;
use doing_error::{Error, Result};
use doing_taskpaper::Entry;
use doing_template::renderer::RenderOptions;
use regex::Regex;
pub trait Plugin {
fn name(&self) -> &str;
fn settings(&self) -> PluginSettings;
}
pub trait ExportPlugin: Plugin {
fn render(&self, entries: &[Entry], options: &RenderOptions, config: &Config) -> String;
}
#[derive(Clone, Debug)]
pub struct PluginSettings {
pub trigger: String,
}
pub struct Registry<T: Plugin + ?Sized> {
plugins: Vec<RegisteredPlugin<T>>,
}
impl<T: Plugin + ?Sized> Registry<T> {
pub fn new() -> Self {
Self {
plugins: Vec::new(),
}
}
pub fn available_formats(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.plugins.iter().map(|p| p.name.as_str()).collect();
names.sort();
names
}
pub fn register(&mut self, plugin: Box<T>) -> Result<()> {
let name = plugin.name().to_string();
let settings = plugin.settings();
let pattern = normalize_trigger(&settings.trigger);
let trigger = Regex::new(&format!("(?i)^(?:{pattern})$"))
.map_err(|_| Error::Plugin(format!("invalid trigger pattern for plugin \"{name}\": {pattern}")))?;
self.plugins.push(RegisteredPlugin {
name,
plugin,
trigger,
});
Ok(())
}
pub fn resolve(&self, format: &str) -> Option<&T> {
self
.plugins
.iter()
.find(|p| p.trigger.is_match(format))
.map(|p| p.plugin.as_ref())
}
}
impl<T: Plugin + ?Sized> Default for Registry<T> {
fn default() -> Self {
Self::new()
}
}
struct RegisteredPlugin<T: Plugin + ?Sized> {
name: String,
plugin: Box<T>,
trigger: Regex,
}
pub fn default_registry() -> Result<Registry<dyn ExportPlugin>> {
let mut registry: Registry<dyn ExportPlugin> = Registry::new();
registry.register(Box::new(byday::BydayExport))?;
registry.register(Box::new(csv::CsvExport))?;
registry.register(Box::new(dayone::DayoneExport))?;
registry.register(Box::new(dayone::DayoneDaysExport))?;
registry.register(Box::new(dayone::DayoneEntriesExport))?;
registry.register(Box::new(doing::DoingExport))?;
registry.register(Box::new(html::HtmlExport))?;
registry.register(Box::new(json::JsonExport))?;
registry.register(Box::new(markdown::MarkdownExport))?;
registry.register(Box::new(taskpaper::TaskPaperExport))?;
registry.register(Box::new(timeline::TimelineExport))?;
Ok(registry)
}
fn normalize_trigger(trigger: &str) -> String {
trigger.trim().to_string()
}
#[cfg(test)]
pub(crate) mod test_helpers {
use chrono::{Local, TimeZone};
use doing_template::renderer::RenderOptions;
pub fn sample_date(day: u32, hour: u32, minute: u32) -> chrono::DateTime<Local> {
Local.with_ymd_and_hms(2024, 3, day, hour, minute, 0).unwrap()
}
pub fn sample_options() -> RenderOptions {
RenderOptions {
date_format: "%Y-%m-%d %H:%M".into(),
include_notes: true,
template: String::new(),
wrap_width: 0,
}
}
}
#[cfg(test)]
mod test {
use super::*;
struct MockPlugin {
name: String,
trigger: String,
}
impl MockPlugin {
fn new(name: &str, trigger: &str) -> Self {
Self {
name: name.into(),
trigger: trigger.into(),
}
}
}
impl Plugin for MockPlugin {
fn name(&self) -> &str {
&self.name
}
fn settings(&self) -> PluginSettings {
PluginSettings {
trigger: self.trigger.clone(),
}
}
}
impl ExportPlugin for MockPlugin {
fn render(&self, _entries: &[Entry], _options: &RenderOptions, _config: &Config) -> String {
format!("[{}]", self.name)
}
}
mod default_registry {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn it_registers_all_built_in_plugins() {
let registry = default_registry().unwrap();
assert_eq!(
registry.available_formats(),
vec![
"byday",
"csv",
"dayone",
"dayone-days",
"dayone-entries",
"doing",
"html",
"json",
"markdown",
"taskpaper",
"timeline"
]
);
}
}
mod registry_available_formats {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn it_returns_empty_for_new_registry() {
let registry = Registry::<dyn ExportPlugin>::new();
assert!(registry.available_formats().is_empty());
}
#[test]
fn it_returns_sorted_format_names() {
let mut registry = Registry::<dyn ExportPlugin>::new();
registry
.register(Box::new(MockPlugin::new("markdown", "markdown|md")))
.unwrap();
registry.register(Box::new(MockPlugin::new("csv", "csv"))).unwrap();
registry
.register(Box::new(MockPlugin::new("taskpaper", "task(?:paper)?|tp")))
.unwrap();
let formats = registry.available_formats();
assert_eq!(formats, vec!["csv", "markdown", "taskpaper"]);
}
}
mod registry_register {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn it_adds_plugin_to_registry() {
let mut registry = Registry::<dyn ExportPlugin>::new();
registry.register(Box::new(MockPlugin::new("csv", "csv"))).unwrap();
assert_eq!(registry.available_formats(), vec!["csv"]);
}
#[test]
fn it_returns_error_on_invalid_trigger_pattern() {
let mut registry = Registry::<dyn ExportPlugin>::new();
let result = registry.register(Box::new(MockPlugin::new("bad", "(?invalid")));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("invalid trigger pattern"));
}
}
mod registry_resolve {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn it_matches_exact_format_name() {
let mut registry = Registry::<dyn ExportPlugin>::new();
registry.register(Box::new(MockPlugin::new("csv", "csv"))).unwrap();
let plugin = registry.resolve("csv").unwrap();
assert_eq!(plugin.name(), "csv");
}
#[test]
fn it_matches_alternate_trigger_pattern() {
let mut registry = Registry::<dyn ExportPlugin>::new();
registry
.register(Box::new(MockPlugin::new("taskpaper", "task(?:paper)?|tp")))
.unwrap();
assert!(registry.resolve("taskpaper").is_some());
assert!(registry.resolve("task").is_some());
assert!(registry.resolve("tp").is_some());
}
#[test]
fn it_matches_case_insensitively() {
let mut registry = Registry::<dyn ExportPlugin>::new();
registry.register(Box::new(MockPlugin::new("csv", "csv"))).unwrap();
assert!(registry.resolve("CSV").is_some());
assert!(registry.resolve("Csv").is_some());
}
#[test]
fn it_returns_none_for_unknown_format() {
let mut registry = Registry::<dyn ExportPlugin>::new();
registry.register(Box::new(MockPlugin::new("csv", "csv"))).unwrap();
assert!(registry.resolve("json").is_none());
}
#[test]
fn it_does_not_match_partial_strings() {
let mut registry = Registry::<dyn ExportPlugin>::new();
registry.register(Box::new(MockPlugin::new("csv", "csv"))).unwrap();
assert!(registry.resolve("csvx").is_none());
assert!(registry.resolve("xcsv").is_none());
}
}
}