Skip to main content

doing_plugins/
import.rs

1mod calendar;
2mod doing;
3mod json;
4mod timing;
5
6use std::path::Path;
7
8use doing_error::Result;
9use doing_taskpaper::Entry;
10use regex::Regex;
11
12/// The interface that import format plugins must implement.
13///
14/// Each plugin provides a trigger pattern used to match `--type FORMAT` values
15/// and an import method that reads entries from a file path.
16pub trait ImportPlugin {
17  /// Import entries from the file at `path`.
18  fn import(&self, path: &Path) -> Result<Vec<Entry>>;
19
20  /// Return the canonical name of this import format.
21  fn name(&self) -> &str;
22
23  /// Return the plugin's settings including trigger pattern.
24  fn settings(&self) -> ImportPluginSettings;
25}
26
27/// Settings declared by an import plugin.
28#[derive(Clone, Debug)]
29pub struct ImportPluginSettings {
30  pub trigger: String,
31}
32
33/// A registry that maps format names to import plugin implementations.
34///
35/// Plugins register themselves with a trigger pattern (a regular expression).
36/// When resolving a `--type FORMAT` argument, the registry matches the format
37/// string against each plugin's trigger pattern and returns the first match.
38pub struct ImportRegistry {
39  plugins: Vec<RegisteredPlugin>,
40}
41
42impl ImportRegistry {
43  /// Create an empty registry.
44  pub fn new() -> Self {
45    Self {
46      plugins: Vec::new(),
47    }
48  }
49
50  /// Return a sorted list of all registered format names.
51  pub fn available_formats(&self) -> Vec<&str> {
52    let mut names: Vec<&str> = self.plugins.iter().map(|p| p.name.as_str()).collect();
53    names.sort();
54    names
55  }
56
57  /// Register an import plugin.
58  ///
59  /// The plugin's trigger pattern is compiled into a case-insensitive regex
60  /// that will be used to match format strings during resolution.
61  ///
62  /// # Panics
63  ///
64  /// Panics if the plugin's trigger pattern is not a valid regular expression.
65  pub fn register(&mut self, plugin: Box<dyn ImportPlugin>) {
66    let name = plugin.name().to_string();
67    let settings = plugin.settings();
68    let pattern = settings.trigger.trim().to_string();
69    let trigger = Regex::new(&format!("(?i)^(?:{pattern})$"))
70      .unwrap_or_else(|_| panic!("invalid trigger pattern for plugin \"{name}\": {pattern}"));
71    self.plugins.push(RegisteredPlugin {
72      name,
73      plugin,
74      trigger,
75    });
76  }
77
78  /// Resolve a format string to a registered import plugin.
79  ///
80  /// Returns the first plugin whose trigger pattern matches the given format,
81  /// or `None` if no plugin matches.
82  pub fn resolve(&self, format: &str) -> Option<&dyn ImportPlugin> {
83    self
84      .plugins
85      .iter()
86      .find(|p| p.trigger.is_match(format))
87      .map(|p| p.plugin.as_ref())
88  }
89}
90
91impl Default for ImportRegistry {
92  fn default() -> Self {
93    Self::new()
94  }
95}
96
97struct RegisteredPlugin {
98  name: String,
99  plugin: Box<dyn ImportPlugin>,
100  trigger: Regex,
101}
102
103/// Build the default import registry with all built-in import plugins.
104pub fn default_registry() -> ImportRegistry {
105  let mut registry = ImportRegistry::new();
106  registry.register(Box::new(calendar::CalendarImport));
107  registry.register(Box::new(doing::DoingImport));
108  registry.register(Box::new(json::JsonImport));
109  registry.register(Box::new(timing::TimingImport));
110  registry
111}
112
113#[cfg(test)]
114mod test {
115  use std::path::Path;
116
117  use super::*;
118
119  struct MockPlugin {
120    name: String,
121    trigger: String,
122  }
123
124  impl MockPlugin {
125    fn new(name: &str, trigger: &str) -> Self {
126      Self {
127        name: name.into(),
128        trigger: trigger.into(),
129      }
130    }
131  }
132
133  impl ImportPlugin for MockPlugin {
134    fn import(&self, _path: &Path) -> Result<Vec<Entry>> {
135      Ok(Vec::new())
136    }
137
138    fn name(&self) -> &str {
139      &self.name
140    }
141
142    fn settings(&self) -> ImportPluginSettings {
143      ImportPluginSettings {
144        trigger: self.trigger.clone(),
145      }
146    }
147  }
148
149  mod default_registry {
150    use pretty_assertions::assert_eq;
151
152    use super::*;
153
154    #[test]
155    fn it_registers_all_built_in_plugins() {
156      let registry = default_registry();
157
158      assert_eq!(
159        registry.available_formats(),
160        vec!["calendar", "doing", "json", "timing"]
161      );
162    }
163  }
164
165  mod import_registry_available_formats {
166    use pretty_assertions::assert_eq;
167
168    use super::*;
169
170    #[test]
171    fn it_returns_empty_for_new_registry() {
172      let registry = ImportRegistry::new();
173
174      assert!(registry.available_formats().is_empty());
175    }
176
177    #[test]
178    fn it_returns_sorted_format_names() {
179      let mut registry = ImportRegistry::new();
180      registry.register(Box::new(MockPlugin::new("timing", "timing")));
181      registry.register(Box::new(MockPlugin::new("doing", "doing")));
182
183      let formats = registry.available_formats();
184
185      assert_eq!(formats, vec!["doing", "timing"]);
186    }
187  }
188
189  mod import_registry_register {
190    use pretty_assertions::assert_eq;
191
192    use super::*;
193
194    #[test]
195    fn it_adds_plugin_to_registry() {
196      let mut registry = ImportRegistry::new();
197
198      registry.register(Box::new(MockPlugin::new("doing", "doing")));
199
200      assert_eq!(registry.available_formats(), vec!["doing"]);
201    }
202
203    #[test]
204    #[should_panic(expected = "invalid trigger pattern")]
205    fn it_panics_on_invalid_trigger_pattern() {
206      let mut registry = ImportRegistry::new();
207
208      registry.register(Box::new(MockPlugin::new("bad", "(?invalid")));
209    }
210  }
211
212  mod import_registry_resolve {
213    use pretty_assertions::assert_eq;
214
215    use super::*;
216
217    #[test]
218    fn it_matches_exact_format_name() {
219      let mut registry = ImportRegistry::new();
220      registry.register(Box::new(MockPlugin::new("doing", "doing")));
221
222      let plugin = registry.resolve("doing").unwrap();
223
224      assert_eq!(plugin.name(), "doing");
225    }
226
227    #[test]
228    fn it_matches_case_insensitively() {
229      let mut registry = ImportRegistry::new();
230      registry.register(Box::new(MockPlugin::new("doing", "doing")));
231
232      assert!(registry.resolve("DOING").is_some());
233      assert!(registry.resolve("Doing").is_some());
234    }
235
236    #[test]
237    fn it_returns_none_for_unknown_format() {
238      let mut registry = ImportRegistry::new();
239      registry.register(Box::new(MockPlugin::new("doing", "doing")));
240
241      assert!(registry.resolve("csv").is_none());
242    }
243
244    #[test]
245    fn it_does_not_match_partial_strings() {
246      let mut registry = ImportRegistry::new();
247      registry.register(Box::new(MockPlugin::new("doing", "doing")));
248
249      assert!(registry.resolve("doingx").is_none());
250      assert!(registry.resolve("xdoing").is_none());
251    }
252  }
253}