Skip to main content

doing_plugins/
lib.rs

1//! Export and import plugins for the doing CLI.
2//!
3//! This crate implements the `--output FORMAT` export system and the `import`
4//! subcommand's format readers. Each export format implements the [`ExportPlugin`]
5//! trait and registers itself with a [`Registry`] via a trigger pattern
6//! (a regex matched against the user's `--output` value).
7//!
8//! # Built-in export formats
9//!
10//! `byday`, `csv`, `dayone`, `dayone-days`, `dayone-entries`, `doing`, `html`,
11//! `json`, `markdown`, `taskpaper`, `timeline`.
12//!
13//! Use [`default_registry`] to get a registry pre-loaded with all built-in plugins.
14//!
15//! # Import formats
16//!
17//! The [`import`] module provides readers for `json`, `doing`, `calendar` (ICS),
18//! and `timing` (Timing.app JSON) files.
19
20mod byday;
21mod csv;
22mod dayone;
23mod doing;
24pub mod helpers;
25pub mod html;
26pub mod import;
27mod json;
28mod markdown;
29mod taskpaper;
30mod timeline;
31
32use doing_config::Config;
33use doing_error::{Error, Result};
34use doing_taskpaper::Entry;
35use doing_template::renderer::RenderOptions;
36use regex::Regex;
37
38/// The base trait for all plugins (export and import).
39///
40/// Provides the common interface needed by [`Registry`] to register and
41/// resolve plugins by name and trigger pattern.
42pub trait Plugin {
43  /// Return the canonical name of this plugin.
44  fn name(&self) -> &str;
45
46  /// Return the plugin's settings including trigger pattern.
47  fn settings(&self) -> PluginSettings;
48}
49
50/// The interface that export format plugins must implement.
51///
52/// Each plugin provides a trigger pattern used to match `--output FORMAT` values,
53/// settings for configuration, and a render method that formats entries into a string.
54pub trait ExportPlugin: Plugin {
55  /// Render the given entries into the plugin's output format.
56  fn render(&self, entries: &[Entry], options: &RenderOptions, config: &Config) -> String;
57}
58
59/// Settings declared by a plugin.
60#[derive(Clone, Debug)]
61pub struct PluginSettings {
62  pub trigger: String,
63}
64
65/// A registry that maps format names to plugin implementations.
66///
67/// Plugins register themselves with a trigger pattern (a regular expression).
68/// When resolving a format argument, the registry matches the format
69/// string against each plugin's trigger pattern and returns the first match.
70pub struct Registry<T: Plugin + ?Sized> {
71  plugins: Vec<RegisteredPlugin<T>>,
72}
73
74impl<T: Plugin + ?Sized> Registry<T> {
75  /// Create an empty registry.
76  pub fn new() -> Self {
77    Self {
78      plugins: Vec::new(),
79    }
80  }
81
82  /// Return a sorted list of all registered format names.
83  pub fn available_formats(&self) -> Vec<&str> {
84    let mut names: Vec<&str> = self.plugins.iter().map(|p| p.name.as_str()).collect();
85    names.sort();
86    names
87  }
88
89  /// Register a plugin.
90  ///
91  /// The plugin's trigger pattern is compiled into a case-insensitive regex
92  /// that will be used to match format strings during resolution.
93  ///
94  /// Returns an error if the plugin's trigger pattern is not a valid regular expression.
95  pub fn register(&mut self, plugin: Box<T>) -> Result<()> {
96    let name = plugin.name().to_string();
97    let settings = plugin.settings();
98    let pattern = normalize_trigger(&settings.trigger);
99    let trigger = Regex::new(&format!("(?i)^(?:{pattern})$"))
100      .map_err(|_| Error::Plugin(format!("invalid trigger pattern for plugin \"{name}\": {pattern}")))?;
101    self.plugins.push(RegisteredPlugin {
102      name,
103      plugin,
104      trigger,
105    });
106    Ok(())
107  }
108
109  /// Resolve a format string to a registered plugin.
110  ///
111  /// Returns the first plugin whose trigger pattern matches the given format,
112  /// or `None` if no plugin matches.
113  pub fn resolve(&self, format: &str) -> Option<&T> {
114    self
115      .plugins
116      .iter()
117      .find(|p| p.trigger.is_match(format))
118      .map(|p| p.plugin.as_ref())
119  }
120}
121
122impl<T: Plugin + ?Sized> Default for Registry<T> {
123  fn default() -> Self {
124    Self::new()
125  }
126}
127
128struct RegisteredPlugin<T: Plugin + ?Sized> {
129  name: String,
130  plugin: Box<T>,
131  trigger: Regex,
132}
133
134/// Build the default export registry with all built-in export plugins.
135pub fn default_registry() -> Result<Registry<dyn ExportPlugin>> {
136  let mut registry: Registry<dyn ExportPlugin> = Registry::new();
137  registry.register(Box::new(byday::BydayExport))?;
138  registry.register(Box::new(csv::CsvExport))?;
139  registry.register(Box::new(dayone::DayoneExport))?;
140  registry.register(Box::new(dayone::DayoneDaysExport))?;
141  registry.register(Box::new(dayone::DayoneEntriesExport))?;
142  registry.register(Box::new(doing::DoingExport))?;
143  registry.register(Box::new(html::HtmlExport))?;
144  registry.register(Box::new(json::JsonExport))?;
145  registry.register(Box::new(markdown::MarkdownExport))?;
146  registry.register(Box::new(taskpaper::TaskPaperExport))?;
147  registry.register(Box::new(timeline::TimelineExport))?;
148  Ok(registry)
149}
150
151/// Normalize a trigger string for use as a regex pattern.
152fn normalize_trigger(trigger: &str) -> String {
153  trigger.trim().to_string()
154}
155
156#[cfg(test)]
157pub(crate) mod test_helpers {
158  use chrono::{Local, TimeZone};
159  use doing_template::renderer::RenderOptions;
160
161  pub fn sample_date(day: u32, hour: u32, minute: u32) -> chrono::DateTime<Local> {
162    Local.with_ymd_and_hms(2024, 3, day, hour, minute, 0).unwrap()
163  }
164
165  pub fn sample_options() -> RenderOptions {
166    RenderOptions {
167      date_format: "%Y-%m-%d %H:%M".into(),
168      include_notes: true,
169      template: String::new(),
170      wrap_width: 0,
171    }
172  }
173}
174
175#[cfg(test)]
176mod test {
177  use super::*;
178
179  struct MockPlugin {
180    name: String,
181    trigger: String,
182  }
183
184  impl MockPlugin {
185    fn new(name: &str, trigger: &str) -> Self {
186      Self {
187        name: name.into(),
188        trigger: trigger.into(),
189      }
190    }
191  }
192
193  impl Plugin for MockPlugin {
194    fn name(&self) -> &str {
195      &self.name
196    }
197
198    fn settings(&self) -> PluginSettings {
199      PluginSettings {
200        trigger: self.trigger.clone(),
201      }
202    }
203  }
204
205  impl ExportPlugin for MockPlugin {
206    fn render(&self, _entries: &[Entry], _options: &RenderOptions, _config: &Config) -> String {
207      format!("[{}]", self.name)
208    }
209  }
210
211  mod default_registry {
212    use pretty_assertions::assert_eq;
213
214    use super::*;
215
216    #[test]
217    fn it_registers_all_built_in_plugins() {
218      let registry = default_registry().unwrap();
219
220      assert_eq!(
221        registry.available_formats(),
222        vec![
223          "byday",
224          "csv",
225          "dayone",
226          "dayone-days",
227          "dayone-entries",
228          "doing",
229          "html",
230          "json",
231          "markdown",
232          "taskpaper",
233          "timeline"
234        ]
235      );
236    }
237  }
238
239  mod registry_available_formats {
240    use pretty_assertions::assert_eq;
241
242    use super::*;
243
244    #[test]
245    fn it_returns_empty_for_new_registry() {
246      let registry = Registry::<dyn ExportPlugin>::new();
247
248      assert!(registry.available_formats().is_empty());
249    }
250
251    #[test]
252    fn it_returns_sorted_format_names() {
253      let mut registry = Registry::<dyn ExportPlugin>::new();
254      registry
255        .register(Box::new(MockPlugin::new("markdown", "markdown|md")))
256        .unwrap();
257      registry.register(Box::new(MockPlugin::new("csv", "csv"))).unwrap();
258      registry
259        .register(Box::new(MockPlugin::new("taskpaper", "task(?:paper)?|tp")))
260        .unwrap();
261
262      let formats = registry.available_formats();
263
264      assert_eq!(formats, vec!["csv", "markdown", "taskpaper"]);
265    }
266  }
267
268  mod registry_register {
269    use pretty_assertions::assert_eq;
270
271    use super::*;
272
273    #[test]
274    fn it_adds_plugin_to_registry() {
275      let mut registry = Registry::<dyn ExportPlugin>::new();
276
277      registry.register(Box::new(MockPlugin::new("csv", "csv"))).unwrap();
278
279      assert_eq!(registry.available_formats(), vec!["csv"]);
280    }
281
282    #[test]
283    fn it_returns_error_on_invalid_trigger_pattern() {
284      let mut registry = Registry::<dyn ExportPlugin>::new();
285
286      let result = registry.register(Box::new(MockPlugin::new("bad", "(?invalid")));
287
288      assert!(result.is_err());
289      assert!(result.unwrap_err().to_string().contains("invalid trigger pattern"));
290    }
291  }
292
293  mod registry_resolve {
294    use pretty_assertions::assert_eq;
295
296    use super::*;
297
298    #[test]
299    fn it_matches_exact_format_name() {
300      let mut registry = Registry::<dyn ExportPlugin>::new();
301      registry.register(Box::new(MockPlugin::new("csv", "csv"))).unwrap();
302
303      let plugin = registry.resolve("csv").unwrap();
304
305      assert_eq!(plugin.name(), "csv");
306    }
307
308    #[test]
309    fn it_matches_alternate_trigger_pattern() {
310      let mut registry = Registry::<dyn ExportPlugin>::new();
311      registry
312        .register(Box::new(MockPlugin::new("taskpaper", "task(?:paper)?|tp")))
313        .unwrap();
314
315      assert!(registry.resolve("taskpaper").is_some());
316      assert!(registry.resolve("task").is_some());
317      assert!(registry.resolve("tp").is_some());
318    }
319
320    #[test]
321    fn it_matches_case_insensitively() {
322      let mut registry = Registry::<dyn ExportPlugin>::new();
323      registry.register(Box::new(MockPlugin::new("csv", "csv"))).unwrap();
324
325      assert!(registry.resolve("CSV").is_some());
326      assert!(registry.resolve("Csv").is_some());
327    }
328
329    #[test]
330    fn it_returns_none_for_unknown_format() {
331      let mut registry = Registry::<dyn ExportPlugin>::new();
332      registry.register(Box::new(MockPlugin::new("csv", "csv"))).unwrap();
333
334      assert!(registry.resolve("json").is_none());
335    }
336
337    #[test]
338    fn it_does_not_match_partial_strings() {
339      let mut registry = Registry::<dyn ExportPlugin>::new();
340      registry.register(Box::new(MockPlugin::new("csv", "csv"))).unwrap();
341
342      assert!(registry.resolve("csvx").is_none());
343      assert!(registry.resolve("xcsv").is_none());
344    }
345  }
346}