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
17pub 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
27pub 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 pub fn base_path(&self) -> &std::path::Path {
47 &self.base_path
48 }
49
50 pub fn path_for(&self, name: &str) -> PathBuf {
58 self.base_path.join(ensure_md_extension(name))
59 }
60
61 pub fn load(&self, name: &str) -> Result<Document, String> {
64 let path = self.path_for(name);
65
66 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 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 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 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 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 pub fn save(&self, doc: &Document) -> Result<(), String> {
131 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 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 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 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 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 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 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 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 assert!(doc.path.exists());
285 assert_eq!(fs::read_to_string(&doc.path).unwrap(), "Test content");
286
287 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 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 let store = DocumentStore::new(temp_dir.clone());
316 assert!(store.delete("never-existed").is_ok());
317
318 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 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 fs::remove_dir_all(&temp_dir).ok();
346 }
347}