feat: entry file parsing and directory reading

This commit is contained in:
Lewis Wynne 2026-04-09 11:34:17 +01:00
parent 16a9699fc6
commit 7346141de5
2 changed files with 187 additions and 0 deletions

186
src/entries.rs Normal file
View file

@ -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<Self, String> {
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<Entry> {
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<Entry> {
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<String, String> {
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"
+++
<b>Bold</b> and <em>italic</em>.
<em>-- llywelwyn</em>: Thanks!"#;
let entry = Entry::parse("2026-04-09-11223344", contents).unwrap();
assert!(entry.body.contains("<b>Bold</b>"));
assert!(entry.body.contains("<em>-- llywelwyn</em>"));
}
#[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());
}
}