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_taskpaper::Entry;
34use doing_template::renderer::RenderOptions;
35use regex::Regex;
36
37/// The base trait for all plugins (export and import).
38///
39/// Provides the common interface needed by [`Registry`] to register and
40/// resolve plugins by name and trigger pattern.
41pub trait Plugin {
42  /// Return the canonical name of this plugin.
43  fn name(&self) -> &str;
44
45  /// Return the plugin's settings including trigger pattern.
46  fn settings(&self) -> PluginSettings;
47}
48
49/// The interface that export format plugins must implement.
50///
51/// Each plugin provides a trigger pattern used to match `--output FORMAT` values,
52/// settings for configuration, and a render method that formats entries into a string.
53pub trait ExportPlugin: Plugin {
54  /// Render the given entries into the plugin's output format.
55  fn render(&self, entries: &[Entry], options: &RenderOptions, config: &Config) -> String;
56}
57
58/// Settings declared by a plugin.
59#[derive(Clone, Debug)]
60pub struct PluginSettings {
61  pub trigger: String,
62}
63
64/// A registry that maps format names to plugin implementations.
65///
66/// Plugins register themselves with a trigger pattern (a regular expression).
67/// When resolving a format argument, the registry matches the format
68/// string against each plugin's trigger pattern and returns the first match.
69pub struct Registry<T: Plugin + ?Sized> {
70  plugins: Vec<RegisteredPlugin<T>>,
71}
72
73impl<T: Plugin + ?Sized> Registry<T> {
74  /// Create an empty registry.
75  pub fn new() -> Self {
76    Self {
77      plugins: Vec::new(),
78    }
79  }
80
81  /// Return a sorted list of all registered format names.
82  pub fn available_formats(&self) -> Vec<&str> {
83    let mut names: Vec<&str> = self.plugins.iter().map(|p| p.name.as_str()).collect();
84    names.sort();
85    names
86  }
87
88  /// Register a plugin.
89  ///
90  /// The plugin's trigger pattern is compiled into a case-insensitive regex
91  /// that will be used to match format strings during resolution.
92  ///
93  /// # Panics
94  ///
95  /// Panics if the plugin's trigger pattern is not a valid regular expression.
96  pub fn register(&mut self, plugin: Box<T>) {
97    let name = plugin.name().to_string();
98    let settings = plugin.settings();
99    let pattern = normalize_trigger(&settings.trigger);
100    let trigger = Regex::new(&format!("(?i)^(?:{pattern})$"))
101      .unwrap_or_else(|_| panic!("invalid trigger pattern for plugin \"{name}\": {pattern}"));
102    self.plugins.push(RegisteredPlugin {
103      name,
104      plugin,
105      trigger,
106    });
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() -> 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  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)]
157mod test {
158  use super::*;
159
160  struct MockPlugin {
161    name: String,
162    trigger: String,
163  }
164
165  impl MockPlugin {
166    fn new(name: &str, trigger: &str) -> Self {
167      Self {
168        name: name.into(),
169        trigger: trigger.into(),
170      }
171    }
172  }
173
174  impl Plugin for MockPlugin {
175    fn name(&self) -> &str {
176      &self.name
177    }
178
179    fn settings(&self) -> PluginSettings {
180      PluginSettings {
181        trigger: self.trigger.clone(),
182      }
183    }
184  }
185
186  impl ExportPlugin for MockPlugin {
187    fn render(&self, _entries: &[Entry], _options: &RenderOptions, _config: &Config) -> String {
188      format!("[{}]", self.name)
189    }
190  }
191
192  mod default_registry {
193    use pretty_assertions::assert_eq;
194
195    use super::*;
196
197    #[test]
198    fn it_registers_all_built_in_plugins() {
199      let registry = default_registry();
200
201      assert_eq!(
202        registry.available_formats(),
203        vec![
204          "byday",
205          "csv",
206          "dayone",
207          "dayone-days",
208          "dayone-entries",
209          "doing",
210          "html",
211          "json",
212          "markdown",
213          "taskpaper",
214          "timeline"
215        ]
216      );
217    }
218  }
219
220  mod registry_available_formats {
221    use pretty_assertions::assert_eq;
222
223    use super::*;
224
225    #[test]
226    fn it_returns_empty_for_new_registry() {
227      let registry = Registry::<dyn ExportPlugin>::new();
228
229      assert!(registry.available_formats().is_empty());
230    }
231
232    #[test]
233    fn it_returns_sorted_format_names() {
234      let mut registry = Registry::<dyn ExportPlugin>::new();
235      registry.register(Box::new(MockPlugin::new("markdown", "markdown|md")));
236      registry.register(Box::new(MockPlugin::new("csv", "csv")));
237      registry.register(Box::new(MockPlugin::new("taskpaper", "task(?:paper)?|tp")));
238
239      let formats = registry.available_formats();
240
241      assert_eq!(formats, vec!["csv", "markdown", "taskpaper"]);
242    }
243  }
244
245  mod registry_register {
246    use pretty_assertions::assert_eq;
247
248    use super::*;
249
250    #[test]
251    fn it_adds_plugin_to_registry() {
252      let mut registry = Registry::<dyn ExportPlugin>::new();
253
254      registry.register(Box::new(MockPlugin::new("csv", "csv")));
255
256      assert_eq!(registry.available_formats(), vec!["csv"]);
257    }
258
259    #[test]
260    #[should_panic(expected = "invalid trigger pattern")]
261    fn it_panics_on_invalid_trigger_pattern() {
262      let mut registry = Registry::<dyn ExportPlugin>::new();
263
264      registry.register(Box::new(MockPlugin::new("bad", "(?invalid")));
265    }
266  }
267
268  mod registry_resolve {
269    use pretty_assertions::assert_eq;
270
271    use super::*;
272
273    #[test]
274    fn it_matches_exact_format_name() {
275      let mut registry = Registry::<dyn ExportPlugin>::new();
276      registry.register(Box::new(MockPlugin::new("csv", "csv")));
277
278      let plugin = registry.resolve("csv").unwrap();
279
280      assert_eq!(plugin.name(), "csv");
281    }
282
283    #[test]
284    fn it_matches_alternate_trigger_pattern() {
285      let mut registry = Registry::<dyn ExportPlugin>::new();
286      registry.register(Box::new(MockPlugin::new("taskpaper", "task(?:paper)?|tp")));
287
288      assert!(registry.resolve("taskpaper").is_some());
289      assert!(registry.resolve("task").is_some());
290      assert!(registry.resolve("tp").is_some());
291    }
292
293    #[test]
294    fn it_matches_case_insensitively() {
295      let mut registry = Registry::<dyn ExportPlugin>::new();
296      registry.register(Box::new(MockPlugin::new("csv", "csv")));
297
298      assert!(registry.resolve("CSV").is_some());
299      assert!(registry.resolve("Csv").is_some());
300    }
301
302    #[test]
303    fn it_returns_none_for_unknown_format() {
304      let mut registry = Registry::<dyn ExportPlugin>::new();
305      registry.register(Box::new(MockPlugin::new("csv", "csv")));
306
307      assert!(registry.resolve("json").is_none());
308    }
309
310    #[test]
311    fn it_does_not_match_partial_strings() {
312      let mut registry = Registry::<dyn ExportPlugin>::new();
313      registry.register(Box::new(MockPlugin::new("csv", "csv")));
314
315      assert!(registry.resolve("csvx").is_none());
316      assert!(registry.resolve("xcsv").is_none());
317    }
318  }
319}