From 7346141de54eca59b7d64c414a41ad483dca0206 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 11:34:17 +0100 Subject: [PATCH] feat: entry file parsing and directory reading --- src/entries.rs | 186 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 2 files changed, 187 insertions(+) create mode 100644 src/entries.rs diff --git a/src/entries.rs b/src/entries.rs new file mode 100644 index 0000000..dda8753 --- /dev/null +++ b/src/entries.rs @@ -0,0 +1,186 @@ +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()); + } +} diff --git a/src/main.rs b/src/main.rs index 48fd603..fb22a04 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod config; +mod entries; fn main() { let config = config::Config::load("config.toml").expect("failed to load config.toml");