use std::fs;
use std::path::PathBuf;
use std::time::SystemTime;
#[derive(Clone)]
pub struct Document {
pub name: String,
pub path: PathBuf,
pub content: String,
pub modified_time: Option<SystemTime>,
}
pub struct DocumentStore {
base_path: PathBuf,
}
pub fn has_md_extension(name: &str) -> bool {
let bytes = name.as_bytes();
bytes.len() >= 3 && bytes[bytes.len() - 3..].eq_ignore_ascii_case(b".md")
}
pub fn ensure_md_extension(name: &str) -> String {
if has_md_extension(name) {
name.to_string()
} else {
format!("{name}.md")
}
}
impl DocumentStore {
pub fn new(base_path: PathBuf) -> Self {
DocumentStore { base_path }
}
pub fn base_path(&self) -> &std::path::Path {
&self.base_path
}
pub fn path_for(&self, name: &str) -> PathBuf {
self.base_path.join(ensure_md_extension(name))
}
pub fn load(&self, name: &str) -> Result<Document, String> {
let path = self.path_for(name);
let (content, modified_time) = if path.exists() {
let content = fs::read_to_string(&path)
.map_err(|e| format!("Failed to read '{}': {}", name, e))?;
let mtime = fs::metadata(&path).ok().and_then(|m| m.modified().ok());
(content, mtime)
} else {
(String::new(), None)
};
Ok(Document {
name: name.to_string(),
path,
content,
modified_time,
})
}
pub fn list_all_documents(&self) -> Result<Vec<String>, String> {
let mut docs = Vec::new();
Self::walk_directory(&self.base_path, "", &mut docs)?;
Ok(docs)
}
fn walk_directory(dir: &PathBuf, prefix: &str, docs: &mut Vec<String>) -> Result<(), String> {
let entries = fs::read_dir(dir)
.map_err(|e| format!("Failed to read directory '{}': {}", dir.display(), e))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
let full_name = if prefix.is_empty() {
name.to_string()
} else {
format!("{}/{}", prefix, name)
};
docs.push(full_name);
}
} else if path.is_dir() {
if let Some(dir_name) = path.file_name().and_then(|s| s.to_str()) {
let new_prefix = if prefix.is_empty() {
dir_name.to_string()
} else {
format!("{}/{}", prefix, dir_name)
};
Self::walk_directory(&path, &new_prefix, docs)?;
}
}
}
Ok(())
}
pub fn save(&self, doc: &Document) -> Result<(), String> {
if let Some(parent) = doc.path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directories for '{}': {}", doc.name, e))?;
}
fs::write(&doc.path, &doc.content)
.map_err(|e| format!("Failed to save '{}': {}", doc.name, e))
}
pub fn delete(&self, name: &str) -> Result<(), String> {
let path = self.path_for(name);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("Failed to delete '{}': {}", name, e)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_load_existing_file() {
let store = DocumentStore::new("example-wiki".into());
let doc = store.load("frontpage").unwrap();
assert!(!doc.content.is_empty());
assert_eq!(doc.name, "frontpage");
}
#[test]
fn test_load_non_existent_file() {
let temp_dir = env::temp_dir().join("piki-test-load");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let store = DocumentStore::new(temp_dir.clone());
let doc = store.load("non-existent").unwrap();
assert_eq!(doc.content, "");
assert_eq!(doc.name, "non-existent");
assert_eq!(doc.path, temp_dir.join("non-existent.md"));
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_load_dotted_name_gets_md_extension() {
let temp_dir = env::temp_dir().join("piki-test-dotted");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let store = DocumentStore::new(temp_dir.clone());
fs::write(temp_dir.join("sprint-q2.6.md"), "hello").unwrap();
let doc = store.load("sprint-q2.6").unwrap();
assert_eq!(doc.path, temp_dir.join("sprint-q2.6.md"));
assert_eq!(doc.content, "hello");
assert_eq!(doc.name, "sprint-q2.6");
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_load_name_with_md_extension_not_doubled() {
let temp_dir = env::temp_dir().join("piki-test-md-suffix");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let store = DocumentStore::new(temp_dir.clone());
let doc = store.load("notes.md").unwrap();
assert_eq!(doc.path, temp_dir.join("notes.md"));
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_md_extension_helpers() {
assert!(has_md_extension("notes.md"));
assert!(has_md_extension("notes.MD"));
assert!(!has_md_extension("sprint-q2.6"));
assert!(!has_md_extension("md"));
assert_eq!(ensure_md_extension("sprint-q2.6"), "sprint-q2.6.md");
assert_eq!(ensure_md_extension("notes.md"), "notes.md");
assert_eq!(ensure_md_extension("notes.MD"), "notes.MD");
}
#[test]
fn test_path_for_resolves_without_reading() {
let store = DocumentStore::new("/tmp/piki-x".into());
assert_eq!(
store.path_for("notes"),
PathBuf::from("/tmp/piki-x/notes.md")
);
assert_eq!(
store.path_for("notes.md"),
PathBuf::from("/tmp/piki-x/notes.md")
);
assert_eq!(
store.path_for("sprint-q2.6"),
PathBuf::from("/tmp/piki-x/sprint-q2.6.md")
);
assert_eq!(store.path_for("a/b"), PathBuf::from("/tmp/piki-x/a/b.md"));
}
#[test]
fn test_load_nested_path() {
let temp_dir = env::temp_dir().join("piki-test-nested");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let store = DocumentStore::new(temp_dir.clone());
let doc = store.load("project-a/standup").unwrap();
assert_eq!(doc.content, "");
assert_eq!(doc.name, "project-a/standup");
assert_eq!(doc.path, temp_dir.join("project-a/standup.md"));
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_save_creates_parent_directories() {
let temp_dir = env::temp_dir().join("piki-test-save");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let store = DocumentStore::new(temp_dir.clone());
let mut doc = store.load("nested/dir/note").unwrap();
doc.content = "Test content".to_string();
store.save(&doc).unwrap();
assert!(doc.path.exists());
assert_eq!(fs::read_to_string(&doc.path).unwrap(), "Test content");
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_delete_removes_file() {
let temp_dir = env::temp_dir().join("piki-test-delete");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let store = DocumentStore::new(temp_dir.clone());
fs::write(temp_dir.join("gone.md"), "bye").unwrap();
assert!(temp_dir.join("gone.md").exists());
store.delete("gone").unwrap();
assert!(!temp_dir.join("gone.md").exists());
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_delete_missing_file_is_ok() {
let temp_dir = env::temp_dir().join("piki-test-delete-missing");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let store = DocumentStore::new(temp_dir.clone());
assert!(store.delete("never-existed").is_ok());
fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_list_all_documents_recursive() {
let temp_dir = env::temp_dir().join("piki-test-list-all");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let store = DocumentStore::new(temp_dir.clone());
fs::write(temp_dir.join("root.md"), "root").unwrap();
fs::create_dir_all(temp_dir.join("dir1")).unwrap();
fs::write(temp_dir.join("dir1/note1.md"), "note1").unwrap();
fs::create_dir_all(temp_dir.join("dir1/subdir")).unwrap();
fs::write(temp_dir.join("dir1/subdir/note2.md"), "note2").unwrap();
let docs = store.list_all_documents().unwrap();
assert!(docs.contains(&"root".to_string()));
assert!(docs.contains(&"dir1/note1".to_string()));
assert!(docs.contains(&"dir1/subdir/note2".to_string()));
assert_eq!(docs.len(), 3);
fs::remove_dir_all(&temp_dir).ok();
}
}