Skip to main content

piki_core/
plugin.rs

1#![allow(dead_code)]
2
3use crate::document::DocumentStore;
4use std::collections::HashMap;
5
6/// Trait for plugins that dynamically generate note content
7pub trait Plugin: Send + Sync {
8    /// Generate content for this plugin based on the current wiki state
9    fn generate_content(&self, store: &DocumentStore) -> Result<String, String>;
10}
11
12/// Registry for managing wiki plugins
13pub struct PluginRegistry {
14    plugins: HashMap<String, Box<dyn Plugin>>,
15}
16
17impl PluginRegistry {
18    /// Create a new empty plugin registry
19    pub fn new() -> Self {
20        PluginRegistry {
21            plugins: HashMap::new(),
22        }
23    }
24
25    /// Register a plugin with a given name
26    pub fn register(&mut self, name: impl Into<String>, plugin: Box<dyn Plugin>) {
27        self.plugins.insert(name.into(), plugin);
28    }
29
30    /// Check if a plugin exists with the given name
31    pub fn has_plugin(&self, name: &str) -> bool {
32        self.plugins.contains_key(name)
33    }
34
35    /// Generate content using the named plugin
36    pub fn generate(&self, name: &str, store: &DocumentStore) -> Result<String, String> {
37        self.plugins
38            .get(name)
39            .ok_or_else(|| format!("Plugin '{}' not found", name))
40            .and_then(|plugin| plugin.generate_content(store))
41    }
42}
43
44impl Default for PluginRegistry {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50/// Built-in plugin that generates a sorted index of all notes
51pub struct IndexPlugin;
52
53impl Plugin for IndexPlugin {
54    fn generate_content(&self, store: &DocumentStore) -> Result<String, String> {
55        let mut all_docs = store.list_all_documents()?;
56        all_docs.sort();
57
58        let mut content = String::from("# Index\n\n");
59        content.push_str(&format!(
60            "*Dynamically generated index of all {} notes*\n\n",
61            all_docs.len()
62        ));
63
64        if all_docs.is_empty() {
65            content.push_str("No notes found.\n");
66            return Ok(content);
67        }
68
69        // Group by top-level directory
70        let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
71
72        for doc in &all_docs {
73            if let Some(slash_pos) = doc.find('/') {
74                let category = &doc[..slash_pos];
75                grouped
76                    .entry(category.to_string())
77                    .or_default()
78                    .push(doc.clone());
79            } else {
80                grouped
81                    .entry("Root".to_string())
82                    .or_default()
83                    .push(doc.clone());
84            }
85        }
86
87        // Sort categories
88        let mut categories: Vec<_> = grouped.keys().cloned().collect();
89        categories.sort();
90
91        // Always put "Root" first if it exists
92        if let Some(pos) = categories.iter().position(|c| c == "Root") {
93            let root = categories.remove(pos);
94            categories.insert(0, root);
95        }
96
97        // Generate grouped output
98        for category in &categories {
99            if let Some(docs) = grouped.get(category) {
100                if category == "Root" && categories.len() > 1 {
101                    content.push_str("## Root Notes\n\n");
102                } else if category != "Root" {
103                    content.push_str(&format!("## {}\n\n", category));
104                }
105
106                for doc in docs {
107                    content.push_str(&format!("- [[{}]]\n", doc));
108                }
109                content.push('\n');
110            }
111        }
112
113        content.push_str("---\n\n");
114        content.push_str("*This note is generated by the `index` plugin*\n");
115
116        Ok(content)
117    }
118}
119
120/// Built-in plugin that lists all todos found in notes, grouped by note
121pub struct TodoPlugin;
122
123impl Plugin for TodoPlugin {
124    fn generate_content(&self, store: &DocumentStore) -> Result<String, String> {
125        let all_docs = store.list_all_documents()?;
126
127        let mut content = String::from("# Todos\n\n");
128        content.push_str("*All todos found across your wiki*\n\n");
129
130        let mut notes_with_todos = Vec::new();
131
132        // Scan each note for todos
133        for doc_name in &all_docs {
134            match store.load(doc_name) {
135                Ok(doc) => {
136                    let todos = extract_todos(&doc.content);
137                    if !todos.is_empty() {
138                        notes_with_todos.push((doc_name.clone(), todos));
139                    }
140                }
141                Err(_) => continue, // Skip notes that can't be loaded
142            }
143        }
144
145        if notes_with_todos.is_empty() {
146            content.push_str("No todos found in any notes.\n");
147            return Ok(content);
148        }
149
150        // Sort notes alphabetically
151        notes_with_todos.sort_by(|a, b| a.0.cmp(&b.0));
152
153        let note_count = notes_with_todos.len();
154
155        // Display todos grouped by note
156        for (note_name, todos) in notes_with_todos {
157            content.push_str(&format!("## [[{}]]\n\n", note_name));
158            for todo in todos {
159                content.push_str(&format!("{}\n", todo));
160            }
161            content.push('\n');
162        }
163
164        content.push_str("---\n\n");
165        content.push_str(&format!("*Found {} notes with todos*\n\n", note_count));
166        content.push_str("*This note is generated by the `todo` plugin*\n");
167
168        Ok(content)
169    }
170}
171
172/// Extract todo items from markdown content
173fn extract_todos(content: &str) -> Vec<String> {
174    let mut todos = Vec::new();
175
176    for line in content.lines() {
177        let trimmed = line.trim();
178        // Match both unchecked [ ] and checked [x] or [X] todos
179        if trimmed.starts_with("- [ ]")
180            || trimmed.starts_with("* [ ]")
181            || trimmed.starts_with("- [x]")
182            || trimmed.starts_with("- [X]")
183            || trimmed.starts_with("* [x]")
184            || trimmed.starts_with("* [X]")
185        {
186            todos.push(line.to_string());
187        }
188    }
189
190    todos
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use std::path::PathBuf;
197
198    #[test]
199    fn test_plugin_registry() {
200        let mut registry = PluginRegistry::new();
201
202        assert!(!registry.has_plugin("index"));
203
204        registry.register("index", Box::new(IndexPlugin));
205
206        assert!(registry.has_plugin("index"));
207        assert!(!registry.has_plugin("nonexistent"));
208    }
209
210    #[test]
211    fn test_index_plugin_empty() {
212        use std::env;
213        use std::fs;
214
215        let temp_dir = env::temp_dir().join("piki-test-plugin-empty");
216        let _ = fs::remove_dir_all(&temp_dir);
217        fs::create_dir_all(&temp_dir).unwrap();
218
219        let store = DocumentStore::new(temp_dir.clone());
220        let plugin = IndexPlugin;
221
222        // Should handle empty directory gracefully
223        let result = plugin.generate_content(&store);
224        assert!(result.is_ok());
225
226        let content = result.unwrap();
227        assert!(content.contains("# Index"));
228        assert!(content.contains("No notes found"));
229
230        // Cleanup
231        fs::remove_dir_all(&temp_dir).ok();
232    }
233
234    #[test]
235    fn test_index_plugin_with_notes() {
236        let store = DocumentStore::new(PathBuf::from("example-wiki"));
237        let plugin = IndexPlugin;
238
239        let content = plugin.generate_content(&store).unwrap();
240
241        // Should contain header
242        assert!(content.contains("# Index"));
243        // Should be markdown
244        assert!(content.contains("[["));
245    }
246
247    #[test]
248    fn test_extract_todos() {
249        let content = r#"
250# My Note
251
252- [ ] Unchecked todo
253- [x] Checked todo
254- [X] Checked todo uppercase
255* [ ] Unchecked with asterisk
256* [x] Checked with asterisk
257- Regular bullet point
258  - [ ] Indented todo
259
260Some text here.
261
262- [ ] Another todo
263"#;
264
265        let todos = extract_todos(content);
266
267        assert_eq!(todos.len(), 7);
268        assert!(todos[0].contains("[ ] Unchecked todo"));
269        assert!(todos[1].contains("[x] Checked todo"));
270        assert!(todos[2].contains("[X] Checked todo uppercase"));
271        assert!(todos[3].contains("[ ] Unchecked with asterisk"));
272        assert!(todos[4].contains("[x] Checked with asterisk"));
273        assert!(todos[5].contains("[ ] Indented todo"));
274        assert!(todos[6].contains("[ ] Another todo"));
275    }
276
277    #[test]
278    fn test_todo_plugin_empty() {
279        use std::env;
280        use std::fs;
281
282        let temp_dir = env::temp_dir().join("piki-test-todo-empty");
283        let _ = fs::remove_dir_all(&temp_dir);
284        fs::create_dir_all(&temp_dir).unwrap();
285
286        let store = DocumentStore::new(temp_dir.clone());
287        let plugin = TodoPlugin;
288
289        let result = plugin.generate_content(&store);
290        assert!(result.is_ok());
291
292        let content = result.unwrap();
293        assert!(content.contains("# Todos"));
294        assert!(content.contains("No todos found"));
295
296        fs::remove_dir_all(&temp_dir).ok();
297    }
298
299    #[test]
300    fn test_todo_plugin_with_todos() {
301        use crate::Document;
302        use std::env;
303        use std::fs;
304
305        let temp_dir = env::temp_dir().join("piki-test-todo-with-content");
306        let _ = fs::remove_dir_all(&temp_dir);
307        fs::create_dir_all(&temp_dir).unwrap();
308
309        let store = DocumentStore::new(temp_dir.clone());
310
311        // Create test documents
312        let doc1 = Document {
313            name: "shopping".to_string(),
314            path: temp_dir.join("shopping.md"),
315            content: "# Shopping\n- [ ] Buy milk\n- [x] Get eggs\n".to_string(),
316            modified_time: None,
317        };
318        store.save(&doc1).unwrap();
319
320        let doc2 = Document {
321            name: "project".to_string(),
322            path: temp_dir.join("project.md"),
323            content: "# Project\n- [ ] Task 1\n- [ ] Task 2\n".to_string(),
324            modified_time: None,
325        };
326        store.save(&doc2).unwrap();
327
328        let plugin = TodoPlugin;
329        let content = plugin.generate_content(&store).unwrap();
330
331        // Verify structure
332        assert!(content.contains("# Todos"));
333        assert!(content.contains("[[project]]"));
334        assert!(content.contains("[[shopping]]"));
335        assert!(content.contains("- [ ] Buy milk"));
336        assert!(content.contains("- [x] Get eggs"));
337        assert!(content.contains("- [ ] Task 1"));
338        assert!(content.contains("Found 2 notes with todos"));
339
340        fs::remove_dir_all(&temp_dir).ok();
341    }
342}