use crate::generator;
use tftio_org::ast::{Block, Document};
pub const RESERVED_META_KEYS: [&str; 3] = ["ID", "CREATED", "UPDATED"];
#[must_use]
pub fn render_with_metadata(
id: &str,
created_at: &str,
updated_at: &str,
document: &Document,
) -> String {
let drawer = Document {
blocks: vec![Block::PropertyDrawer {
entries: vec![
("ID".to_string(), format!(" {id}")),
("CREATED".to_string(), format!(" {created_at}")),
("UPDATED".to_string(), format!(" {updated_at}")),
],
}],
};
format!(
"{}{}",
generator::generate(&drawer),
generator::generate(document)
)
}
pub fn hydrate(document: &mut Document) -> Option<String> {
let Some(Block::PropertyDrawer { entries }) = document.blocks.first() else {
return None;
};
if !entries.iter().any(|(k, _)| k == "ID") {
return None;
}
let id = entries
.iter()
.find(|(k, _)| k == "ID")
.map(|(_, v)| v.trim().to_string());
let Some(Block::PropertyDrawer { entries }) = document.blocks.first_mut() else {
unreachable!("checked above")
};
entries.retain(|(k, _)| !RESERVED_META_KEYS.contains(&k.as_str()));
if entries.is_empty() {
document.blocks.remove(0);
}
id
}
#[cfg(test)]
mod tests {
use super::*;
use crate::canonical::canonicalize;
use crate::parser;
use tftio_org::ast::{Inline, Tag, Title};
fn sample_doc() -> Document {
Document {
blocks: vec![Block::Heading {
level: 1,
title: Title("My Note".to_string()),
tags: vec![Tag("rust".to_string())],
children: vec![Block::Paragraph {
inlines: vec![Inline::Plain("body text".to_string())],
}],
}],
}
}
#[test]
fn render_prepends_metadata_drawer() {
let org = render_with_metadata(
"node-1",
"2026-05-19 09:00:00",
"2026-05-19 10:30:00",
&sample_doc(),
);
let expected_prefix = ":PROPERTIES:\n:ID: node-1\n\
:CREATED: 2026-05-19 09:00:00\n:UPDATED: 2026-05-19 10:30:00\n:END:\n";
assert!(
org.starts_with(expected_prefix),
"missing metadata drawer, got: {org}"
);
assert!(org.contains("* My Note :rust:"));
}
#[test]
fn export_round_trips_through_hydrate() {
let org = render_with_metadata("node-1", "t0", "t1", &sample_doc());
let mut doc = parser::parse_document(&org).expect("export must reparse");
let id = hydrate(&mut doc);
assert_eq!(id.as_deref(), Some("node-1"));
assert_eq!(doc, canonicalize(&sample_doc()));
}
#[test]
fn hydrate_noop_without_metadata_drawer() {
let mut doc = sample_doc();
let before = doc.clone();
assert_eq!(hydrate(&mut doc), None);
assert_eq!(doc, before);
}
#[test]
fn hydrate_preserves_non_reserved_drawer_entries() {
let mut doc = Document {
blocks: vec![Block::PropertyDrawer {
entries: vec![
("ID".to_string(), "node-9".to_string()),
("CUSTOM".to_string(), "keep-me".to_string()),
],
}],
};
assert_eq!(hydrate(&mut doc).as_deref(), Some("node-9"));
assert_eq!(
doc.blocks,
vec![Block::PropertyDrawer {
entries: vec![("CUSTOM".to_string(), "keep-me".to_string())],
}]
);
}
}