1mod 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
37pub trait Plugin {
42 fn name(&self) -> &str;
44
45 fn settings(&self) -> PluginSettings;
47}
48
49pub trait ExportPlugin: Plugin {
54 fn render(&self, entries: &[Entry], options: &RenderOptions, config: &Config) -> String;
56}
57
58#[derive(Clone, Debug)]
60pub struct PluginSettings {
61 pub trigger: String,
62}
63
64pub struct Registry<T: Plugin + ?Sized> {
70 plugins: Vec<RegisteredPlugin<T>>,
71}
72
73impl<T: Plugin + ?Sized> Registry<T> {
74 pub fn new() -> Self {
76 Self {
77 plugins: Vec::new(),
78 }
79 }
80
81 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 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 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
134pub 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
151fn 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}