186 lines
5.4 KiB
Rust
186 lines
5.4 KiB
Rust
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());
|
|
}
|
|
}
|