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_error::{Error, Result};
34use doing_taskpaper::Entry;
35use doing_template::renderer::RenderOptions;
36use regex::Regex;
37
38pub trait Plugin {
43 fn name(&self) -> &str;
45
46 fn settings(&self) -> PluginSettings;
48}
49
50pub trait ExportPlugin: Plugin {
55 fn render(&self, entries: &[Entry], options: &RenderOptions, config: &Config) -> String;
57}
58
59#[derive(Clone, Debug)]
61pub struct PluginSettings {
62 pub trigger: String,
63}
64
65pub struct Registry<T: Plugin + ?Sized> {
71 plugins: Vec<RegisteredPlugin<T>>,
72}
73
74impl<T: Plugin + ?Sized> Registry<T> {
75 pub fn new() -> Self {
77 Self {
78 plugins: Vec::new(),
79 }
80 }
81
82 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 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 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() -> 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
151fn 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}