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 an [`ExportRegistry`] 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 interface that export format plugins must implement.
38///
39/// Each plugin provides a trigger pattern used to match `--output FORMAT` values,
40/// settings for configuration, and a render method that formats entries into a string.
41pub trait ExportPlugin {
42  /// Return the canonical name of this export format.
43  fn name(&self) -> &str;
44
45  /// Render the given entries into the plugin's output format.
46  fn render(&self, entries: &[Entry], options: &RenderOptions, config: &Config) -> String;
47
48  /// Return the plugin's settings including trigger pattern and optional templates.
49  fn settings(&self) -> ExportPluginSettings;
50}
51
52/// Settings declared by an export plugin.
53#[derive(Clone, Debug)]
54pub struct ExportPluginSettings {
55  pub trigger: String,
56}
57
58/// A registry that maps format names to export plugin implementations.
59///
60/// Plugins register themselves with a trigger pattern (a regular expression).
61/// When resolving an `--output FORMAT` argument, the registry matches the format
62/// string against each plugin's trigger pattern and returns the first match.
63pub struct ExportRegistry {
64  plugins: Vec<RegisteredPlugin>,
65}
66
67impl ExportRegistry {
68  /// Create an empty registry.
69  pub fn new() -> Self {
70    Self {
71      plugins: Vec::new(),
72    }
73  }
74
75  /// Return a sorted list of all registered format names.
76  pub fn available_formats(&self) -> Vec<&str> {
77    let mut names: Vec<&str> = self.plugins.iter().map(|p| p.name.as_str()).collect();
78    names.sort();
79    names
80  }
81
82  /// Register an export plugin.
83  ///
84  /// The plugin's trigger pattern is compiled into a case-insensitive regex
85  /// that will be used to match format strings during resolution.
86  ///
87  /// # Panics
88  ///
89  /// Panics if the plugin's trigger pattern is not a valid regular expression.
90  pub fn register(&mut self, plugin: Box<dyn ExportPlugin>) {
91    let name = plugin.name().to_string();
92    let settings = plugin.settings();
93    let pattern = normalize_trigger(&settings.trigger);
94    let trigger = Regex::new(&format!("(?i)^(?:{pattern})$"))
95      .unwrap_or_else(|_| panic!("invalid trigger pattern for plugin \"{name}\": {pattern}"));
96    self.plugins.push(RegisteredPlugin {
97      name,
98      plugin,
99      trigger,
100    });
101  }
102
103  /// Resolve a format string to a registered export plugin.
104  ///
105  /// Returns the first plugin whose trigger pattern matches the given format,
106  /// or `None` if no plugin matches.
107  pub fn resolve(&self, format: &str) -> Option<&dyn ExportPlugin> {
108    self
109      .plugins
110      .iter()
111      .find(|p| p.trigger.is_match(format))
112      .map(|p| p.plugin.as_ref())
113  }
114}
115
116impl Default for ExportRegistry {
117  fn default() -> Self {
118    Self::new()
119  }
120}
121
122struct RegisteredPlugin {
123  name: String,
124  plugin: Box<dyn ExportPlugin>,
125  trigger: Regex,
126}
127
128/// Build the default export registry with all built-in export plugins.
129pub fn default_registry() -> ExportRegistry {
130  let mut registry = ExportRegistry::new();
131  registry.register(Box::new(byday::BydayExport));
132  registry.register(Box::new(csv::CsvExport));
133  registry.register(Box::new(dayone::DayoneExport));
134  registry.register(Box::new(dayone::DayoneDaysExport));
135  registry.register(Box::new(dayone::DayoneEntriesExport));
136  registry.register(Box::new(doing::DoingExport));
137  registry.register(Box::new(html::HtmlExport));
138  registry.register(Box::new(json::JsonExport));
139  registry.register(Box::new(markdown::MarkdownExport));
140  registry.register(Box::new(taskpaper::TaskPaperExport));
141  registry.register(Box::new(timeline::TimelineExport));
142  registry
143}
144
145/// Normalize a trigger string for use as a regex pattern.
146fn normalize_trigger(trigger: &str) -> String {
147  trigger.trim().to_string()
148}
149
150#[cfg(test)]
151mod test {
152  use super::*;
153
154  struct MockPlugin {
155    name: String,
156    trigger: String,
157  }
158
159  impl MockPlugin {
160    fn new(name: &str, trigger: &str) -> Self {
161      Self {
162        name: name.into(),
163        trigger: trigger.into(),
164      }
165    }
166  }
167
168  impl ExportPlugin for MockPlugin {
169    fn name(&self) -> &str {
170      &self.name
171    }
172
173    fn render(&self, _entries: &[Entry], _options: &RenderOptions, _config: &Config) -> String {
174      format!("[{}]", self.name)
175    }
176
177    fn settings(&self) -> ExportPluginSettings {
178      ExportPluginSettings {
179        trigger: self.trigger.clone(),
180      }
181    }
182  }
183
184  mod default_registry {
185    use pretty_assertions::assert_eq;
186
187    use super::*;
188
189    #[test]
190    fn it_registers_all_built_in_plugins() {
191      let registry = default_registry();
192
193      assert_eq!(
194        registry.available_formats(),
195        vec![
196          "byday",
197          "csv",
198          "dayone",
199          "dayone-days",
200          "dayone-entries",
201          "doing",
202          "html",
203          "json",
204          "markdown",
205          "taskpaper",
206          "timeline"
207        ]
208      );
209    }
210  }
211
212  mod export_registry_available_formats {
213    use pretty_assertions::assert_eq;
214
215    use super::*;
216
217    #[test]
218    fn it_returns_empty_for_new_registry() {
219      let registry = ExportRegistry::new();
220
221      assert!(registry.available_formats().is_empty());
222    }
223
224    #[test]
225    fn it_returns_sorted_format_names() {
226      let mut registry = ExportRegistry::new();
227      registry.register(Box::new(MockPlugin::new("markdown", "markdown|md")));
228      registry.register(Box::new(MockPlugin::new("csv", "csv")));
229      registry.register(Box::new(MockPlugin::new("taskpaper", "task(?:paper)?|tp")));
230
231      let formats = registry.available_formats();
232
233      assert_eq!(formats, vec!["csv", "markdown", "taskpaper"]);
234    }
235  }
236
237  mod export_registry_register {
238    use pretty_assertions::assert_eq;
239
240    use super::*;
241
242    #[test]
243    fn it_adds_plugin_to_registry() {
244      let mut registry = ExportRegistry::new();
245
246      registry.register(Box::new(MockPlugin::new("csv", "csv")));
247
248      assert_eq!(registry.available_formats(), vec!["csv"]);
249    }
250
251    #[test]
252    #[should_panic(expected = "invalid trigger pattern")]
253    fn it_panics_on_invalid_trigger_pattern() {
254      let mut registry = ExportRegistry::new();
255
256      registry.register(Box::new(MockPlugin::new("bad", "(?invalid")));
257    }
258  }
259
260  mod export_registry_resolve {
261    use pretty_assertions::assert_eq;
262
263    use super::*;
264
265    #[test]
266    fn it_matches_exact_format_name() {
267      let mut registry = ExportRegistry::new();
268      registry.register(Box::new(MockPlugin::new("csv", "csv")));
269
270      let plugin = registry.resolve("csv").unwrap();
271
272      assert_eq!(plugin.name(), "csv");
273    }
274
275    #[test]
276    fn it_matches_alternate_trigger_pattern() {
277      let mut registry = ExportRegistry::new();
278      registry.register(Box::new(MockPlugin::new("taskpaper", "task(?:paper)?|tp")));
279
280      assert!(registry.resolve("taskpaper").is_some());
281      assert!(registry.resolve("task").is_some());
282      assert!(registry.resolve("tp").is_some());
283    }
284
285    #[test]
286    fn it_matches_case_insensitively() {
287      let mut registry = ExportRegistry::new();
288      registry.register(Box::new(MockPlugin::new("csv", "csv")));
289
290      assert!(registry.resolve("CSV").is_some());
291      assert!(registry.resolve("Csv").is_some());
292    }
293
294    #[test]
295    fn it_returns_none_for_unknown_format() {
296      let mut registry = ExportRegistry::new();
297      registry.register(Box::new(MockPlugin::new("csv", "csv")));
298
299      assert!(registry.resolve("json").is_none());
300    }
301
302    #[test]
303    fn it_does_not_match_partial_strings() {
304      let mut registry = ExportRegistry::new();
305      registry.register(Box::new(MockPlugin::new("csv", "csv")));
306
307      assert!(registry.resolve("csvx").is_none());
308      assert!(registry.resolve("xcsv").is_none());
309    }
310  }
311}