1use std::path::Path;
2
3use crate::{
4 entry::{Entry, Frontmatter},
5 error::{Error, Result},
6};
7
8const FENCE: &str = "---";
9
10pub fn parse_entry(path: &Path, source: &str) -> Result<Entry> {
15 let (frontmatter, body) = split_frontmatter(path, source)?;
16 Ok(Entry {
17 path: path.to_path_buf(),
18 frontmatter,
19 body: body.to_owned(),
20 })
21}
22
23pub fn read_entry(path: &Path) -> Result<Entry> {
25 let source = std::fs::read_to_string(path)?;
26 parse_entry(path, &source)
27}
28
29fn split_frontmatter<'a>(_path: &Path, source: &'a str) -> Result<(Frontmatter, &'a str)> {
30 let Some(rest) = source.strip_prefix(FENCE) else {
31 return Err(Error::InvalidEntry("missing frontmatter block".into()));
32 };
33
34 let Some(rest) = rest.strip_prefix('\n') else {
36 return Err(Error::InvalidEntry("missing frontmatter block".into()));
37 };
38
39 let Some(end) = rest.find(&format!("\n{FENCE}")) else {
40 return Err(Error::InvalidEntry(
41 "frontmatter block is not closed".into(),
42 ));
43 };
44
45 let yaml = &rest[..end];
46 let body = &rest[end + 1 + FENCE.len()..]; let body = body.trim_start_matches('\n');
48
49 let frontmatter: Frontmatter = serde_yaml::from_str(yaml)?;
50 Ok((frontmatter, body))
51}
52
53pub fn render_entry(entry: &Entry) -> String {
55 let mut out = String::new();
56
57 let yaml =
58 serde_yaml::to_string(&entry.frontmatter).expect("frontmatter serialization failed");
59 out.push_str("---\n");
60 out.push_str(&yaml);
61 out.push_str("---\n");
62 if !entry.body.is_empty() {
63 out.push('\n');
64 }
65
66 out.push_str(&entry.body);
67 out
68}
69
70pub fn write_entry(entry: &mut Entry) -> Result<()> {
72 entry.frontmatter.updated_at = chrono::Local::now().naive_local();
73 std::fs::write(&entry.path, render_entry(entry))?;
74 Ok(())
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80 use indexmap::IndexMap;
81 use std::path::PathBuf;
82
83 fn managed_path() -> PathBuf {
85 PathBuf::from("0000000_test.md")
86 }
87
88 #[test]
89 fn parses_entry_with_frontmatter() {
90 let src = "---\nid: '0000000'\ntitle: Hello\ntags: [rust, cli]\n---\nsome body\n";
91 let entry = parse_entry(&managed_path(), src).unwrap();
92 assert_eq!(entry.frontmatter.title, "Hello");
93 assert_eq!(entry.frontmatter.tags, vec!["rust", "cli"]);
94 assert_eq!(entry.body, "some body\n");
95 }
96
97 #[test]
98 fn renders_entry_with_task() {
99 use crate::entry::TaskMeta;
100 let src = "---\nid: '0000000'\n---\nbody\n";
101 let mut entry = parse_entry(&managed_path(), src).unwrap();
102 entry.frontmatter.title = "My Task".into();
103 entry.frontmatter.task = Some(TaskMeta {
104 status: "open".into(),
105 due: Some("2026-03-10T00:00:00".parse().unwrap()),
106 started_at: None,
107 closed_at: None,
108 extra: IndexMap::new(),
109 });
110 let rendered = render_entry(&entry);
111 assert!(rendered.contains("title: My Task"));
112 assert!(rendered.contains("status: open"));
113 assert!(rendered.contains("due: 2026-03-10"));
114 }
115}