piki_core/
document.rs

1use std::fs;
2use std::path::PathBuf;
3use std::time::SystemTime;
4
5#[derive(Clone)]
6pub struct Document {
7    pub name: String,
8    pub path: PathBuf,
9    pub content: String,
10    pub modified_time: Option<SystemTime>,
11}
12
13pub struct DocumentStore {
14    base_path: PathBuf,
15}
16
17impl DocumentStore {
18    pub fn new(base_path: PathBuf) -> Self {
19        DocumentStore { base_path }
20    }
21
22    /// Load a document by name (with or without .md extension)
23    /// If the file doesn't exist, creates an empty document that will be saved on first write
24    pub fn load(&self, name: &str) -> Result<Document, String> {
25        let mut path = self.base_path.join(name);
26
27        // Try with .md extension if not already present
28        if path.extension().is_none() {
29            path.set_extension("md");
30        }
31
32        // Read file content and metadata if it exists, otherwise create empty document
33        let (content, modified_time) = if path.exists() {
34            let content = fs::read_to_string(&path)
35                .map_err(|e| format!("Failed to read '{}': {}", name, e))?;
36
37            // Get modification time
38            let mtime = fs::metadata(&path).ok().and_then(|m| m.modified().ok());
39
40            (content, mtime)
41        } else {
42            (String::new(), None)
43        };
44
45        Ok(Document {
46            name: name.to_string(),
47            path,
48            content,
49            modified_time,
50        })
51    }
52
53    /// Recursively list all markdown files in the directory and subdirectories
54    /// Returns relative paths from base_path (e.g., "project-a/standup")
55    pub fn list_all_documents(&self) -> Result<Vec<String>, String> {
56        let mut docs = Vec::new();
57        Self::walk_directory(&self.base_path, "", &mut docs)?;
58        Ok(docs)
59    }
60
61    /// Helper function to recursively walk directories
62    fn walk_directory(dir: &PathBuf, prefix: &str, docs: &mut Vec<String>) -> Result<(), String> {
63        let entries = fs::read_dir(dir)
64            .map_err(|e| format!("Failed to read directory '{}': {}", dir.display(), e))?;
65
66        for entry in entries.flatten() {
67            let path = entry.path();
68
69            if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
70                if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
71                    let full_name = if prefix.is_empty() {
72                        name.to_string()
73                    } else {
74                        format!("{}/{}", prefix, name)
75                    };
76                    docs.push(full_name);
77                }
78            } else if path.is_dir() {
79                // Recursively walk subdirectories
80                if let Some(dir_name) = path.file_name().and_then(|s| s.to_str()) {
81                    let new_prefix = if prefix.is_empty() {
82                        dir_name.to_string()
83                    } else {
84                        format!("{}/{}", prefix, dir_name)
85                    };
86                    Self::walk_directory(&path, &new_prefix, docs)?;
87                }
88            }
89        }
90
91        Ok(())
92    }
93
94    /// Save document content
95    /// Creates parent directories if they don't exist
96    pub fn save(&self, doc: &Document) -> Result<(), String> {
97        // Create parent directories if they don't exist
98        if let Some(parent) = doc.path.parent() {
99            fs::create_dir_all(parent)
100                .map_err(|e| format!("Failed to create directories for '{}': {}", doc.name, e))?;
101        }
102
103        fs::write(&doc.path, &doc.content)
104            .map_err(|e| format!("Failed to save '{}': {}", doc.name, e))
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use std::env;
112
113    #[test]
114    fn test_load_existing_file() {
115        let store = DocumentStore::new("example-wiki".into());
116        let doc = store.load("frontpage").unwrap();
117        assert!(!doc.content.is_empty());
118        assert_eq!(doc.name, "frontpage");
119    }
120
121    #[test]
122    fn test_load_non_existent_file() {
123        let temp_dir = env::temp_dir().join("piki-test-load");
124        let _ = fs::remove_dir_all(&temp_dir);
125        fs::create_dir_all(&temp_dir).unwrap();
126
127        let store = DocumentStore::new(temp_dir.clone());
128        let doc = store.load("non-existent").unwrap();
129
130        assert_eq!(doc.content, "");
131        assert_eq!(doc.name, "non-existent");
132        assert_eq!(doc.path, temp_dir.join("non-existent.md"));
133
134        // Cleanup
135        fs::remove_dir_all(&temp_dir).ok();
136    }
137
138    #[test]
139    fn test_load_nested_path() {
140        let temp_dir = env::temp_dir().join("piki-test-nested");
141        let _ = fs::remove_dir_all(&temp_dir);
142        fs::create_dir_all(&temp_dir).unwrap();
143
144        let store = DocumentStore::new(temp_dir.clone());
145        let doc = store.load("project-a/standup").unwrap();
146
147        assert_eq!(doc.content, "");
148        assert_eq!(doc.name, "project-a/standup");
149        assert_eq!(doc.path, temp_dir.join("project-a/standup.md"));
150
151        // Cleanup
152        fs::remove_dir_all(&temp_dir).ok();
153    }
154
155    #[test]
156    fn test_save_creates_parent_directories() {
157        let temp_dir = env::temp_dir().join("piki-test-save");
158        let _ = fs::remove_dir_all(&temp_dir);
159        fs::create_dir_all(&temp_dir).unwrap();
160
161        let store = DocumentStore::new(temp_dir.clone());
162        let mut doc = store.load("nested/dir/page").unwrap();
163        doc.content = "Test content".to_string();
164
165        store.save(&doc).unwrap();
166
167        // Verify file was created
168        assert!(doc.path.exists());
169        assert_eq!(fs::read_to_string(&doc.path).unwrap(), "Test content");
170
171        // Cleanup
172        fs::remove_dir_all(&temp_dir).ok();
173    }
174
175    #[test]
176    fn test_list_all_documents_recursive() {
177        let temp_dir = env::temp_dir().join("piki-test-list-all");
178        let _ = fs::remove_dir_all(&temp_dir);
179        fs::create_dir_all(&temp_dir).unwrap();
180
181        let store = DocumentStore::new(temp_dir.clone());
182
183        // Create some test files
184        fs::write(temp_dir.join("root.md"), "root").unwrap();
185        fs::create_dir_all(temp_dir.join("dir1")).unwrap();
186        fs::write(temp_dir.join("dir1/page1.md"), "page1").unwrap();
187        fs::create_dir_all(temp_dir.join("dir1/subdir")).unwrap();
188        fs::write(temp_dir.join("dir1/subdir/page2.md"), "page2").unwrap();
189
190        let docs = store.list_all_documents().unwrap();
191
192        assert!(docs.contains(&"root".to_string()));
193        assert!(docs.contains(&"dir1/page1".to_string()));
194        assert!(docs.contains(&"dir1/subdir/page2".to_string()));
195        assert_eq!(docs.len(), 3);
196
197        // Cleanup
198        fs::remove_dir_all(&temp_dir).ok();
199    }
200}