#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub raw: String,
pub date: Option<String>,
pub title: Option<String>,
pub why: Option<String>,
pub rejected: Option<String>,
pub risk: Option<String>,
pub supersedes: Option<String>,
pub tags: Vec<String>,
}
pub fn parse_entries(text: &str) -> Vec<Entry> {
let mut entries: Vec<Entry> = Vec::new();
let mut current: Vec<&str> = Vec::new();
for line in text.lines() {
if line.starts_with("## ") {
if !current.is_empty() {
entries.push(make_entry(¤t));
current.clear();
}
current.push(line);
} else if !current.is_empty() {
current.push(line);
}
}
if !current.is_empty() {
entries.push(make_entry(¤t));
}
entries
}
fn make_entry(lines: &[&str]) -> Entry {
let mut raw = lines.join("\n");
while raw.ends_with('\n') || raw.ends_with(' ') {
raw.pop();
}
let header = lines.first().copied().unwrap_or("");
let heading_body = header.strip_prefix("## ").unwrap_or(header);
let date = heading_body
.split_whitespace()
.next()
.filter(|d| crate::is_date_shaped(d))
.map(|d| d.to_string());
let title = if date.is_some() {
heading_body
.split_once(" — ")
.map(|(_, t)| t.trim().to_string())
.filter(|t| !t.is_empty())
.or_else(|| Some(heading_body.trim().to_string()))
} else {
let t = heading_body.trim();
(!t.is_empty()).then(|| t.to_string())
};
let why = first_field(lines, "**why:**");
let rejected = first_field(lines, "**rejected:**");
let risk = first_field(lines, "**risk:**");
let supersedes = first_field(lines, "**supersedes:**");
let mut tags: Vec<String> = Vec::new();
for line in lines {
if let Some(rest) = line.strip_prefix("**tags:**") {
tags = rest
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
break;
}
}
Entry {
raw,
date,
title,
why,
rejected,
risk,
supersedes,
tags,
}
}
fn first_field(lines: &[&str], prefix: &str) -> Option<String> {
for line in lines {
if let Some(rest) = line.strip_prefix(prefix) {
let v = rest.trim();
return (!v.is_empty()).then(|| v.to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_returns_no_entries() {
assert_eq!(parse_entries(""), vec![]);
}
#[test]
fn preamble_without_entries_is_ignored() {
let text = "# logbook\n\nSome preamble text\nthat predates any entry.\n";
assert_eq!(parse_entries(text), vec![]);
}
#[test]
fn single_entry_parses_date_and_no_tags() {
let text = "## 2026-05-16 — first decision\n**why:** because\n";
let entries = parse_entries(text);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].date.as_deref(), Some("2026-05-16"));
assert!(entries[0].tags.is_empty());
assert!(entries[0].raw.starts_with("## 2026-05-16"));
}
#[test]
fn entry_with_tags_extracts_them() {
let text = "## 2026-05-16 — t\n**why:** w\n**tags:** refactor, perf, db\n";
let entries = parse_entries(text);
assert_eq!(entries[0].tags, vec!["refactor", "perf", "db"]);
}
#[test]
fn tag_whitespace_and_empties_are_trimmed() {
let text = "## 2026-05-16 — t\n**why:** w\n**tags:** refactor ,, perf ,\n";
let entries = parse_entries(text);
assert_eq!(entries[0].tags, vec!["refactor", "perf"]);
}
#[test]
fn multiple_entries_preserve_document_order() {
let text = "\
## 2026-05-14 — first
**why:** a
## 2026-05-15 — second
**why:** b
## 2026-05-16 — third
**why:** c
";
let entries = parse_entries(text);
let titles: Vec<&str> = entries
.iter()
.map(|e| e.raw.lines().next().unwrap_or(""))
.collect();
assert_eq!(
titles,
vec![
"## 2026-05-14 — first",
"## 2026-05-15 — second",
"## 2026-05-16 — third",
]
);
}
#[test]
fn malformed_date_in_header_is_left_as_none() {
let text = "## not-a-date — wat\n**why:** w\n";
let entries = parse_entries(text);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].date, None);
}
#[test]
fn preamble_followed_by_entry_drops_preamble_only() {
let text = "# logbook\n\nSome preamble.\n\n## 2026-05-16 — real\n**why:** w\n";
let entries = parse_entries(text);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].date.as_deref(), Some("2026-05-16"));
}
#[test]
fn entry_raw_has_no_trailing_whitespace() {
let text = "## 2026-05-16 — t\n**why:** w\n\n\n";
let entries = parse_entries(text);
assert!(!entries[0].raw.ends_with('\n'));
assert!(!entries[0].raw.ends_with(' '));
}
#[test]
fn extracts_title_why_rejected_risk() {
let text = "## 2026-05-16 — switched ORM\n**why:** perf\n**rejected:** redis\n**risk:** migrations\n**tags:** db\n";
let e = &parse_entries(text)[0];
assert_eq!(e.title.as_deref(), Some("switched ORM"));
assert_eq!(e.why.as_deref(), Some("perf"));
assert_eq!(e.rejected.as_deref(), Some("redis"));
assert_eq!(e.risk.as_deref(), Some("migrations"));
}
#[test]
fn absent_optional_fields_are_none() {
let text = "## 2026-05-16 — t\n**why:** w\n";
let e = &parse_entries(text)[0];
assert_eq!(e.why.as_deref(), Some("w"));
assert_eq!(e.rejected, None);
assert_eq!(e.risk, None);
assert_eq!(e.supersedes, None);
}
#[test]
fn extracts_supersedes_date() {
let text = "## 2026-05-16 — t\n**why:** w\n**supersedes:** 2026-05-01\n";
let e = &parse_entries(text)[0];
assert_eq!(e.supersedes.as_deref(), Some("2026-05-01"));
}
#[test]
fn title_with_em_dash_in_body_keeps_full_title() {
let text = "## 2026-05-16 — switched ORM — finally\n**why:** w\n";
let e = &parse_entries(text)[0];
assert_eq!(e.title.as_deref(), Some("switched ORM — finally"));
}
#[test]
fn malformed_header_title_falls_back_to_heading_text() {
let text = "## not-a-date wat\n**why:** w\n";
let e = &parse_entries(text)[0];
assert_eq!(e.date, None);
assert_eq!(e.title.as_deref(), Some("not-a-date wat"));
}
#[test]
fn empty_why_value_is_none() {
let text = "## 2026-05-16 — t\n**why:** \n";
let e = &parse_entries(text)[0];
assert_eq!(e.why, None);
}
#[test]
fn second_tags_line_is_ignored() {
let text = "\
## 2026-05-16 — t
**why:** w
**tags:** real, tags
**tags:** these, should, be, ignored
";
let entries = parse_entries(text);
assert_eq!(entries[0].tags, vec!["real", "tags"]);
}
}