piki-core 0.6.0

Core library for Piki personal wiki application
Documentation
#![allow(dead_code)]

use crate::document::DocumentStore;
use std::collections::HashMap;

/// Trait for plugins that dynamically generate note content
pub trait Plugin: Send + Sync {
    /// Generate content for this plugin based on the current wiki state
    fn generate_content(&self, store: &DocumentStore) -> Result<String, String>;
}

/// Registry for managing wiki plugins
pub struct PluginRegistry {
    plugins: HashMap<String, Box<dyn Plugin>>,
}

impl PluginRegistry {
    /// Create a new empty plugin registry
    pub fn new() -> Self {
        PluginRegistry {
            plugins: HashMap::new(),
        }
    }

    /// Register a plugin with a given name
    pub fn register(&mut self, name: impl Into<String>, plugin: Box<dyn Plugin>) {
        self.plugins.insert(name.into(), plugin);
    }

    /// Check if a plugin exists with the given name
    pub fn has_plugin(&self, name: &str) -> bool {
        self.plugins.contains_key(name)
    }

    /// Generate content using the named plugin
    pub fn generate(&self, name: &str, store: &DocumentStore) -> Result<String, String> {
        self.plugins
            .get(name)
            .ok_or_else(|| format!("Plugin '{}' not found", name))
            .and_then(|plugin| plugin.generate_content(store))
    }
}

impl Default for PluginRegistry {
    fn default() -> Self {
        Self::new()
    }
}

/// Built-in plugin that generates a sorted index of all notes
pub struct IndexPlugin;

impl Plugin for IndexPlugin {
    fn generate_content(&self, store: &DocumentStore) -> Result<String, String> {
        let mut all_docs = store.list_all_documents()?;
        all_docs.sort();

        let mut content = String::from("# Index\n\n");
        content.push_str(&format!(
            "*Dynamically generated index of all {} notes*\n\n",
            all_docs.len()
        ));

        if all_docs.is_empty() {
            content.push_str("No notes found.\n");
            return Ok(content);
        }

        // Group by top-level directory
        let mut grouped: HashMap<String, Vec<String>> = HashMap::new();

        for doc in &all_docs {
            if let Some(slash_pos) = doc.find('/') {
                let category = &doc[..slash_pos];
                grouped
                    .entry(category.to_string())
                    .or_default()
                    .push(doc.clone());
            } else {
                grouped
                    .entry("Root".to_string())
                    .or_default()
                    .push(doc.clone());
            }
        }

        // Sort categories
        let mut categories: Vec<_> = grouped.keys().cloned().collect();
        categories.sort();

        // Always put "Root" first if it exists
        if let Some(pos) = categories.iter().position(|c| c == "Root") {
            let root = categories.remove(pos);
            categories.insert(0, root);
        }

        // Generate grouped output
        for category in &categories {
            if let Some(docs) = grouped.get(category) {
                if category == "Root" && categories.len() > 1 {
                    content.push_str("## Root Notes\n\n");
                } else if category != "Root" {
                    content.push_str(&format!("## {}\n\n", category));
                }

                for doc in docs {
                    content.push_str(&format!("- [[{}]]\n", doc));
                }
                content.push('\n');
            }
        }

        content.push_str("---\n\n");
        content.push_str("*This note is generated by the `index` plugin*\n");

        Ok(content)
    }
}

/// Built-in plugin that lists all todos found in notes, grouped by note
pub struct TodoPlugin;

impl Plugin for TodoPlugin {
    fn generate_content(&self, store: &DocumentStore) -> Result<String, String> {
        let all_docs = store.list_all_documents()?;

        let mut content = String::from("# Todos\n\n");
        content.push_str("*All todos found across your wiki*\n\n");

        let mut notes_with_todos = Vec::new();

        // Scan each note for todos
        for doc_name in &all_docs {
            match store.load(doc_name) {
                Ok(doc) => {
                    let todos = extract_todos(&doc.content);
                    if !todos.is_empty() {
                        notes_with_todos.push((doc_name.clone(), todos));
                    }
                }
                Err(_) => continue, // Skip notes that can't be loaded
            }
        }

        if notes_with_todos.is_empty() {
            content.push_str("No todos found in any notes.\n");
            return Ok(content);
        }

        // Sort notes alphabetically
        notes_with_todos.sort_by(|a, b| a.0.cmp(&b.0));

        let note_count = notes_with_todos.len();

        // Display todos grouped by note
        for (note_name, todos) in notes_with_todos {
            content.push_str(&format!("## [[{}]]\n\n", note_name));
            for todo in todos {
                content.push_str(&format!("{}\n", todo));
            }
            content.push('\n');
        }

        content.push_str("---\n\n");
        content.push_str(&format!("*Found {} notes with todos*\n\n", note_count));
        content.push_str("*This note is generated by the `todo` plugin*\n");

        Ok(content)
    }
}

/// Extract todo items from markdown content
fn extract_todos(content: &str) -> Vec<String> {
    let mut todos = Vec::new();

    for line in content.lines() {
        let trimmed = line.trim();
        // Match both unchecked [ ] and checked [x] or [X] todos
        if trimmed.starts_with("- [ ]")
            || trimmed.starts_with("* [ ]")
            || trimmed.starts_with("- [x]")
            || trimmed.starts_with("- [X]")
            || trimmed.starts_with("* [x]")
            || trimmed.starts_with("* [X]")
        {
            todos.push(line.to_string());
        }
    }

    todos
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    #[test]
    fn test_plugin_registry() {
        let mut registry = PluginRegistry::new();

        assert!(!registry.has_plugin("index"));

        registry.register("index", Box::new(IndexPlugin));

        assert!(registry.has_plugin("index"));
        assert!(!registry.has_plugin("nonexistent"));
    }

    #[test]
    fn test_index_plugin_empty() {
        use std::env;
        use std::fs;

        let temp_dir = env::temp_dir().join("piki-test-plugin-empty");
        let _ = fs::remove_dir_all(&temp_dir);
        fs::create_dir_all(&temp_dir).unwrap();

        let store = DocumentStore::new(temp_dir.clone());
        let plugin = IndexPlugin;

        // Should handle empty directory gracefully
        let result = plugin.generate_content(&store);
        assert!(result.is_ok());

        let content = result.unwrap();
        assert!(content.contains("# Index"));
        assert!(content.contains("No notes found"));

        // Cleanup
        fs::remove_dir_all(&temp_dir).ok();
    }

    #[test]
    fn test_index_plugin_with_notes() {
        let store = DocumentStore::new(PathBuf::from("example-wiki"));
        let plugin = IndexPlugin;

        let content = plugin.generate_content(&store).unwrap();

        // Should contain header
        assert!(content.contains("# Index"));
        // Should be markdown
        assert!(content.contains("[["));
    }

    #[test]
    fn test_extract_todos() {
        let content = r#"
# My Note

- [ ] Unchecked todo
- [x] Checked todo
- [X] Checked todo uppercase
* [ ] Unchecked with asterisk
* [x] Checked with asterisk
- Regular bullet point
  - [ ] Indented todo

Some text here.

- [ ] Another todo
"#;

        let todos = extract_todos(content);

        assert_eq!(todos.len(), 7);
        assert!(todos[0].contains("[ ] Unchecked todo"));
        assert!(todos[1].contains("[x] Checked todo"));
        assert!(todos[2].contains("[X] Checked todo uppercase"));
        assert!(todos[3].contains("[ ] Unchecked with asterisk"));
        assert!(todos[4].contains("[x] Checked with asterisk"));
        assert!(todos[5].contains("[ ] Indented todo"));
        assert!(todos[6].contains("[ ] Another todo"));
    }

    #[test]
    fn test_todo_plugin_empty() {
        use std::env;
        use std::fs;

        let temp_dir = env::temp_dir().join("piki-test-todo-empty");
        let _ = fs::remove_dir_all(&temp_dir);
        fs::create_dir_all(&temp_dir).unwrap();

        let store = DocumentStore::new(temp_dir.clone());
        let plugin = TodoPlugin;

        let result = plugin.generate_content(&store);
        assert!(result.is_ok());

        let content = result.unwrap();
        assert!(content.contains("# Todos"));
        assert!(content.contains("No todos found"));

        fs::remove_dir_all(&temp_dir).ok();
    }

    #[test]
    fn test_todo_plugin_with_todos() {
        use crate::Document;
        use std::env;
        use std::fs;

        let temp_dir = env::temp_dir().join("piki-test-todo-with-content");
        let _ = fs::remove_dir_all(&temp_dir);
        fs::create_dir_all(&temp_dir).unwrap();

        let store = DocumentStore::new(temp_dir.clone());

        // Create test documents
        let doc1 = Document {
            name: "shopping".to_string(),
            path: temp_dir.join("shopping.md"),
            content: "# Shopping\n- [ ] Buy milk\n- [x] Get eggs\n".to_string(),
            modified_time: None,
        };
        store.save(&doc1).unwrap();

        let doc2 = Document {
            name: "project".to_string(),
            path: temp_dir.join("project.md"),
            content: "# Project\n- [ ] Task 1\n- [ ] Task 2\n".to_string(),
            modified_time: None,
        };
        store.save(&doc2).unwrap();

        let plugin = TodoPlugin;
        let content = plugin.generate_content(&store).unwrap();

        // Verify structure
        assert!(content.contains("# Todos"));
        assert!(content.contains("[[project]]"));
        assert!(content.contains("[[shopping]]"));
        assert!(content.contains("- [ ] Buy milk"));
        assert!(content.contains("- [x] Get eggs"));
        assert!(content.contains("- [ ] Task 1"));
        assert!(content.contains("Found 2 notes with todos"));

        fs::remove_dir_all(&temp_dir).ok();
    }
}