Skip to main content

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
17/// Returns true if the name already ends with a (case-insensitive) `.md`
18/// extension.
19///
20/// Unlike `Path::extension`, this treats any other dots in the note name
21/// (e.g. "sprint-q2.6") as part of the name rather than a file extension.
22pub fn has_md_extension(name: &str) -> bool {
23    let bytes = name.as_bytes();
24    bytes.len() >= 3 && bytes[bytes.len() - 3..].eq_ignore_ascii_case(b".md")
25}
26
27/// Append a `.md` extension to a note name unless it already has one.
28///
29/// This intentionally avoids `Path::set_extension`, which would mistake a dot
30/// inside the note name for a file extension (turning "sprint-q2.6" into the
31/// extension-less "sprint-q2.6" or, worse, "sprint-q2.md").
32pub fn ensure_md_extension(name: &str) -> String {
33    if has_md_extension(name) {
34        name.to_string()
35    } else {
36        format!("{name}.md")
37    }
38}
39
40impl DocumentStore {
41    pub fn new(base_path: PathBuf) -> Self {
42        DocumentStore { base_path }
43    }
44
45    /// The root directory this store reads notes from.
46    pub fn base_path(&self) -> &std::path::Path {
47        &self.base_path
48    }
49
50    /// Resolve the on-disk path for a note name (with or without a `.md`
51    /// extension), without reading the file. Used e.g. to move a note when
52    /// renaming it.
53    ///
54    /// We deliberately do not rely on `Path::extension`, which would treat the
55    /// trailing part of a dotted note name (e.g. "sprint-q2.6") as the
56    /// extension and skip adding `.md`.
57    pub fn path_for(&self, name: &str) -> PathBuf {
58        self.base_path.join(ensure_md_extension(name))
59    }
60
61    /// Load a document by name (with or without .md extension)
62    /// If the file doesn't exist, creates an empty document that will be saved on first write
63    pub fn load(&self, name: &str) -> Result<Document, String> {
64        let path = self.path_for(name);
65
66        // Read file content and metadata if it exists, otherwise create empty document
67        let (content, modified_time) = if path.exists() {
68            let content = fs::read_to_string(&path)
69                .map_err(|e| format!("Failed to read '{}': {}", name, e))?;
70
71            // Get modification time
72            let mtime = fs::metadata(&path).ok().and_then(|m| m.modified().ok());
73
74            (content, mtime)
75        } else {
76            (String::new(), None)
77        };
78
79        Ok(Document {
80            name: name.to_string(),
81            path,
82            content,
83            modified_time,
84        })
85    }
86
87    /// Recursively list all markdown files in the directory and subdirectories
88    /// Returns relative paths from base_path (e.g., "project-a/standup")
89    pub fn list_all_documents(&self) -> Result<Vec<String>, String> {
90        let mut docs = Vec::new();
91        Self::walk_directory(&self.base_path, "", &mut docs)?;
92        Ok(docs)
93    }
94
95    /// Helper function to recursively walk directories
96    fn walk_directory(dir: &PathBuf, prefix: &str, docs: &mut Vec<String>) -> Result<(), String> {
97        let entries = fs::read_dir(dir)
98            .map_err(|e| format!("Failed to read directory '{}': {}", dir.display(), e))?;
99
100        for entry in entries.flatten() {
101            let path = entry.path();
102
103            if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
104                if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
105                    let full_name = if prefix.is_empty() {
106                        name.to_string()
107                    } else {
108                        format!("{}/{}", prefix, name)
109                    };
110                    docs.push(full_name);
111                }
112            } else if path.is_dir() {
113                // Recursively walk subdirectories
114                if let Some(dir_name) = path.file_name().and_then(|s| s.to_str()) {
115                    let new_prefix = if prefix.is_empty() {
116                        dir_name.to_string()
117                    } else {
118                        format!("{}/{}", prefix, dir_name)
119                    };
120                    Self::walk_directory(&path, &new_prefix, docs)?;
121                }
122            }
123        }
124
125        Ok(())
126    }
127
128    /// Save document content
129    /// Creates parent directories if they don't exist
130    pub fn save(&self, doc: &Document) -> Result<(), String> {
131        // Create parent directories if they don't exist
132        if let Some(parent) = doc.path.parent() {
133            fs::create_dir_all(parent)
134                .map_err(|e| format!("Failed to create directories for '{}': {}", doc.name, e))?;
135        }
136
137        fs::write(&doc.path, &doc.content)
138            .map_err(|e| format!("Failed to save '{}': {}", doc.name, e))
139    }
140
141    /// Delete a note's file from disk.
142    ///
143    /// A note that was never written (e.g. a brand-new, never-typed-into note)
144    /// has no file yet; a missing file is treated as success so that deleting
145    /// always leaves the note gone. Only a real I/O failure returns an error.
146    pub fn delete(&self, name: &str) -> Result<(), String> {
147        let path = self.path_for(name);
148        match fs::remove_file(&path) {
149            Ok(()) => Ok(()),
150            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
151            Err(e) => Err(format!("Failed to delete '{}': {}", name, e)),
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use std::env;
160
161    #[test]
162    fn test_load_existing_file() {
163        let store = DocumentStore::new("example-wiki".into());
164        let doc = store.load("frontpage").unwrap();
165        assert!(!doc.content.is_empty());
166        assert_eq!(doc.name, "frontpage");
167    }
168
169    #[test]
170    fn test_load_non_existent_file() {
171        let temp_dir = env::temp_dir().join("piki-test-load");
172        let _ = fs::remove_dir_all(&temp_dir);
173        fs::create_dir_all(&temp_dir).unwrap();
174
175        let store = DocumentStore::new(temp_dir.clone());
176        let doc = store.load("non-existent").unwrap();
177
178        assert_eq!(doc.content, "");
179        assert_eq!(doc.name, "non-existent");
180        assert_eq!(doc.path, temp_dir.join("non-existent.md"));
181
182        // Cleanup
183        fs::remove_dir_all(&temp_dir).ok();
184    }
185
186    #[test]
187    fn test_load_dotted_name_gets_md_extension() {
188        let temp_dir = env::temp_dir().join("piki-test-dotted");
189        let _ = fs::remove_dir_all(&temp_dir);
190        fs::create_dir_all(&temp_dir).unwrap();
191
192        let store = DocumentStore::new(temp_dir.clone());
193
194        // A note name with a dot (e.g. "sprint-q2.6") must still get `.md`
195        // appended rather than treating ".6" as the extension.
196        fs::write(temp_dir.join("sprint-q2.6.md"), "hello").unwrap();
197        let doc = store.load("sprint-q2.6").unwrap();
198
199        assert_eq!(doc.path, temp_dir.join("sprint-q2.6.md"));
200        assert_eq!(doc.content, "hello");
201        assert_eq!(doc.name, "sprint-q2.6");
202
203        // Cleanup
204        fs::remove_dir_all(&temp_dir).ok();
205    }
206
207    #[test]
208    fn test_load_name_with_md_extension_not_doubled() {
209        let temp_dir = env::temp_dir().join("piki-test-md-suffix");
210        let _ = fs::remove_dir_all(&temp_dir);
211        fs::create_dir_all(&temp_dir).unwrap();
212
213        let store = DocumentStore::new(temp_dir.clone());
214        let doc = store.load("notes.md").unwrap();
215
216        assert_eq!(doc.path, temp_dir.join("notes.md"));
217
218        // Cleanup
219        fs::remove_dir_all(&temp_dir).ok();
220    }
221
222    #[test]
223    fn test_md_extension_helpers() {
224        assert!(has_md_extension("notes.md"));
225        assert!(has_md_extension("notes.MD"));
226        assert!(!has_md_extension("sprint-q2.6"));
227        assert!(!has_md_extension("md"));
228
229        assert_eq!(ensure_md_extension("sprint-q2.6"), "sprint-q2.6.md");
230        assert_eq!(ensure_md_extension("notes.md"), "notes.md");
231        assert_eq!(ensure_md_extension("notes.MD"), "notes.MD");
232    }
233
234    #[test]
235    fn test_path_for_resolves_without_reading() {
236        let store = DocumentStore::new("/tmp/piki-x".into());
237        // `.md` is appended, an existing one is kept, dotted names are preserved,
238        // and nested names keep their separators.
239        assert_eq!(
240            store.path_for("notes"),
241            PathBuf::from("/tmp/piki-x/notes.md")
242        );
243        assert_eq!(
244            store.path_for("notes.md"),
245            PathBuf::from("/tmp/piki-x/notes.md")
246        );
247        assert_eq!(
248            store.path_for("sprint-q2.6"),
249            PathBuf::from("/tmp/piki-x/sprint-q2.6.md")
250        );
251        assert_eq!(store.path_for("a/b"), PathBuf::from("/tmp/piki-x/a/b.md"));
252    }
253
254    #[test]
255    fn test_load_nested_path() {
256        let temp_dir = env::temp_dir().join("piki-test-nested");
257        let _ = fs::remove_dir_all(&temp_dir);
258        fs::create_dir_all(&temp_dir).unwrap();
259
260        let store = DocumentStore::new(temp_dir.clone());
261        let doc = store.load("project-a/standup").unwrap();
262
263        assert_eq!(doc.content, "");
264        assert_eq!(doc.name, "project-a/standup");
265        assert_eq!(doc.path, temp_dir.join("project-a/standup.md"));
266
267        // Cleanup
268        fs::remove_dir_all(&temp_dir).ok();
269    }
270
271    #[test]
272    fn test_save_creates_parent_directories() {
273        let temp_dir = env::temp_dir().join("piki-test-save");
274        let _ = fs::remove_dir_all(&temp_dir);
275        fs::create_dir_all(&temp_dir).unwrap();
276
277        let store = DocumentStore::new(temp_dir.clone());
278        let mut doc = store.load("nested/dir/note").unwrap();
279        doc.content = "Test content".to_string();
280
281        store.save(&doc).unwrap();
282
283        // Verify file was created
284        assert!(doc.path.exists());
285        assert_eq!(fs::read_to_string(&doc.path).unwrap(), "Test content");
286
287        // Cleanup
288        fs::remove_dir_all(&temp_dir).ok();
289    }
290
291    #[test]
292    fn test_delete_removes_file() {
293        let temp_dir = env::temp_dir().join("piki-test-delete");
294        let _ = fs::remove_dir_all(&temp_dir);
295        fs::create_dir_all(&temp_dir).unwrap();
296
297        let store = DocumentStore::new(temp_dir.clone());
298        fs::write(temp_dir.join("gone.md"), "bye").unwrap();
299        assert!(temp_dir.join("gone.md").exists());
300
301        store.delete("gone").unwrap();
302        assert!(!temp_dir.join("gone.md").exists());
303
304        // Cleanup
305        fs::remove_dir_all(&temp_dir).ok();
306    }
307
308    #[test]
309    fn test_delete_missing_file_is_ok() {
310        let temp_dir = env::temp_dir().join("piki-test-delete-missing");
311        let _ = fs::remove_dir_all(&temp_dir);
312        fs::create_dir_all(&temp_dir).unwrap();
313
314        // A never-saved note has no file yet; deleting it is a no-op success.
315        let store = DocumentStore::new(temp_dir.clone());
316        assert!(store.delete("never-existed").is_ok());
317
318        // Cleanup
319        fs::remove_dir_all(&temp_dir).ok();
320    }
321
322    #[test]
323    fn test_list_all_documents_recursive() {
324        let temp_dir = env::temp_dir().join("piki-test-list-all");
325        let _ = fs::remove_dir_all(&temp_dir);
326        fs::create_dir_all(&temp_dir).unwrap();
327
328        let store = DocumentStore::new(temp_dir.clone());
329
330        // Create some test files
331        fs::write(temp_dir.join("root.md"), "root").unwrap();
332        fs::create_dir_all(temp_dir.join("dir1")).unwrap();
333        fs::write(temp_dir.join("dir1/note1.md"), "note1").unwrap();
334        fs::create_dir_all(temp_dir.join("dir1/subdir")).unwrap();
335        fs::write(temp_dir.join("dir1/subdir/note2.md"), "note2").unwrap();
336
337        let docs = store.list_all_documents().unwrap();
338
339        assert!(docs.contains(&"root".to_string()));
340        assert!(docs.contains(&"dir1/note1".to_string()));
341        assert!(docs.contains(&"dir1/subdir/note2".to_string()));
342        assert_eq!(docs.len(), 3);
343
344        // Cleanup
345        fs::remove_dir_all(&temp_dir).ok();
346    }
347}