use serde::{Deserialize, Serialize}; use std::path::Path; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Status { Pending, Approved, Denied, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EntryMeta { pub name: String, pub date: String, #[serde(default)] pub website: String, pub status: Status, } #[derive(Debug, Clone)] pub struct Entry { pub id: String, pub meta: EntryMeta, pub body: String, } impl Entry { /// Parse an entry from file contents. `id` is derived from the filename. pub fn parse(id: &str, contents: &str) -> Result { let contents = contents.trim(); if !contents.starts_with("+++") { return Err("missing opening +++".into()); } let rest = &contents[3..]; let end = rest.find("+++").ok_or("missing closing +++")?; let frontmatter = &rest[..end]; let body = rest[end + 3..].trim_start_matches('\n').to_string(); let meta: EntryMeta = toml::from_str(frontmatter).map_err(|e| format!("bad frontmatter: {e}"))?; Ok(Entry { id: id.to_string(), meta, body, }) } /// Serialize entry back to file format. pub fn to_file_contents(&self) -> String { let frontmatter = toml::to_string_pretty(&self.meta).unwrap(); format!("+++\n{frontmatter}+++\n{}", self.body) } } /// Read all entries from the given directory. pub fn read_entries(dir: &Path) -> Vec { let mut entries = Vec::new(); let read_dir = match std::fs::read_dir(dir) { Ok(rd) => rd, Err(_) => return entries, }; for item in read_dir { let Ok(item) = item else { continue }; let path = item.path(); if path.extension().and_then(|e| e.to_str()) != Some("txt") { continue; } let id = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("") .to_string(); let Ok(contents) = std::fs::read_to_string(&path) else { continue; }; if let Ok(entry) = Entry::parse(&id, &contents) { entries.push(entry); } } entries.sort_by(|a, b| b.meta.date.cmp(&a.meta.date)); entries } /// Read approved entries only. pub fn read_approved(dir: &Path) -> Vec { read_entries(dir) .into_iter() .filter(|e| e.meta.status == Status::Approved) .collect() } /// Find an entry file by short ID prefix and update its status. pub fn set_status(dir: &Path, short_id: &str, status: Status) -> Result { let read_dir = std::fs::read_dir(dir).map_err(|e| e.to_string())?; for item in read_dir { let Ok(item) = item else { continue }; let path = item.path(); let fname = path.file_name().and_then(|s| s.to_str()).unwrap_or(""); if fname.contains(short_id) && fname.ends_with(".txt") { let contents = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; let id = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("") .to_string(); let mut entry = Entry::parse(&id, &contents)?; entry.meta.status = status; std::fs::write(&path, entry.to_file_contents()).map_err(|e| e.to_string())?; return Ok(entry.meta.name.clone()); } } Err("Not found.".into()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_entry() { let contents = r#"+++ name = "alice" date = "2026-04-09" website = "https://example.com" status = "approved" +++ Hello world!"#; let entry = Entry::parse("2026-04-09-abcd1234", contents).unwrap(); assert_eq!(entry.id, "2026-04-09-abcd1234"); assert_eq!(entry.meta.name, "alice"); assert_eq!(entry.meta.date, "2026-04-09"); assert_eq!(entry.meta.website, "https://example.com"); assert_eq!(entry.meta.status, Status::Approved); assert_eq!(entry.body, "Hello world!"); } #[test] fn test_parse_entry_no_website() { let contents = r#"+++ name = "bob" date = "2026-04-09" status = "pending" +++ Just a message."#; let entry = Entry::parse("2026-04-09-ef567890", contents).unwrap(); assert_eq!(entry.meta.website, ""); assert_eq!(entry.meta.status, Status::Pending); } #[test] fn test_parse_entry_with_html() { let contents = r#"+++ name = "carol" date = "2026-04-09" status = "approved" +++ Bold and italic. -- llywelwyn: Thanks!"#; let entry = Entry::parse("2026-04-09-11223344", contents).unwrap(); assert!(entry.body.contains("Bold")); assert!(entry.body.contains("-- llywelwyn")); } #[test] fn test_roundtrip() { let contents = r#"+++ name = "alice" date = "2026-04-09" website = "https://example.com" status = "approved" +++ Hello world!"#; let entry = Entry::parse("test", contents).unwrap(); let serialized = entry.to_file_contents(); let reparsed = Entry::parse("test", &serialized).unwrap(); assert_eq!(entry.meta.name, reparsed.meta.name); assert_eq!(entry.body, reparsed.body); } #[test] fn test_parse_missing_frontmatter() { let result = Entry::parse("x", "no frontmatter here"); assert!(result.is_err()); } }