Skip to main content

archelon_core/
parser.rs

1use std::path::Path;
2
3use crate::{
4    entry::{Entry, Frontmatter},
5    error::{Error, Result},
6};
7
8const FENCE: &str = "---";
9
10/// Parse a Markdown file into an [`Entry`].
11///
12/// Frontmatter is optional. If the file starts with `---`, everything until
13/// the closing `---` is parsed as YAML. The rest is the body.
14pub 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
23/// Read a file from disk and parse it.
24pub 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    // The opening `---` must be followed by a newline.
35    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()..]; // skip `\n---`
47    let body = body.trim_start_matches('\n');
48
49    let frontmatter: Frontmatter = serde_yaml::from_str(yaml)?;
50    Ok((frontmatter, body))
51}
52
53/// Serialize an [`Entry`] back to Markdown source.
54pub 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
70/// Write an [`Entry`] back to its source file, updating `updated_at` first.
71pub 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    /// A valid archelon-managed path with CarettaId "0000000" (NIL).
84    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}