use chrono::{DateTime, Utc};
use super::types::{Entity, EntityHandle, EntityKind};
pub(crate) fn compose(entity: &Entity, notes: &str) -> String {
let mut out = String::from("---\n");
out.push_str(&format!("id: {}\n", entity.id));
out.push_str(&format!("kind: {}\n", entity.kind.as_str()));
if let Some(name) = entity.display_name.as_deref() {
out.push_str(&format!("display_name: {}\n", yaml_string(name)));
}
if !entity.aliases.is_empty() {
out.push_str("aliases:\n");
for a in &entity.aliases {
out.push_str(&format!(" - {}\n", yaml_string(a)));
}
}
if !entity.emails.is_empty() {
out.push_str("emails:\n");
for e in &entity.emails {
out.push_str(&format!(" - {}\n", yaml_string(e)));
}
}
if !entity.handles.is_empty() {
out.push_str("handles:\n");
for h in &entity.handles {
out.push_str(&format!(
" - kind: {}\n value: {}\n",
yaml_string(&h.kind),
yaml_string(&h.value)
));
}
}
out.push_str(&format!("created_at: {}\n", entity.created_at.to_rfc3339()));
out.push_str(&format!("updated_at: {}\n", entity.updated_at.to_rfc3339()));
out.push_str("---\n\n");
out.push_str(notes);
if !notes.ends_with('\n') {
out.push('\n');
}
out
}
fn yaml_string(s: &str) -> String {
let needs_quote = s
.chars()
.any(|c| matches!(c, ':' | '#' | '\n' | '"' | '\'' | '[' | ']' | '{' | '}'));
if needs_quote {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
} else {
s.to_string()
}
}
fn unquote(s: &str) -> String {
s.strip_prefix('"')
.and_then(|x| x.strip_suffix('"'))
.map(|x| x.replace("\\\"", "\"").replace("\\\\", "\\"))
.unwrap_or_else(|| s.to_string())
}
fn split_front_matter(text: &str) -> Option<(&str, &str)> {
let rest = text.strip_prefix("---\n")?;
let end = rest.find("\n---\n")?;
let (yaml, after) = rest.split_at(end);
let body = after.strip_prefix("\n---\n").unwrap_or(after);
Some((yaml, body))
}
pub(crate) fn extract_notes(text: &str) -> String {
split_front_matter(text)
.map(|(_, body)| body.to_string())
.unwrap_or_default()
}
pub(crate) fn parse(text: &str) -> Option<Entity> {
let (yaml, body) = split_front_matter(text)?;
let mut id = String::new();
let mut kind: Option<EntityKind> = None;
let mut display_name: Option<String> = None;
let mut aliases = Vec::new();
let mut emails = Vec::new();
let mut handles = Vec::new();
let mut created_at: Option<DateTime<Utc>> = None;
let mut updated_at: Option<DateTime<Utc>> = None;
let mut current_list: Option<&'static str> = None;
let mut handle_buf: Option<EntityHandle> = None;
for raw in yaml.lines() {
if raw.starts_with(" - kind:") {
if let Some(h) = handle_buf.take() {
handles.push(h);
}
let v = raw.trim_start_matches(" - kind:").trim();
handle_buf = Some(EntityHandle {
kind: unquote(v),
value: String::new(),
});
current_list = Some("handles");
continue;
}
if raw.starts_with(" value:") {
let v = raw.trim_start_matches(" value:").trim();
if let Some(h) = handle_buf.as_mut() {
h.value = unquote(v);
}
continue;
}
if let Some(v) = raw.strip_prefix(" - ") {
let v = unquote(v.trim());
match current_list {
Some("aliases") => aliases.push(v),
Some("emails") => emails.push(v),
_ => {}
}
continue;
}
if !raw.starts_with(' ') && !raw.starts_with(" - kind") {
if let Some(h) = handle_buf.take() {
handles.push(h);
}
current_list = None;
}
let Some((k, v)) = raw.split_once(':') else {
continue;
};
let v = v.trim();
match k.trim() {
"id" => id = unquote(v),
"kind" => kind = EntityKind::parse(&unquote(v)).ok(),
"display_name" => display_name = Some(unquote(v)),
"aliases" => current_list = Some("aliases"),
"emails" => current_list = Some("emails"),
"handles" => current_list = Some("handles"),
"created_at" => {
created_at = DateTime::parse_from_rfc3339(&unquote(v))
.ok()
.map(|d| d.with_timezone(&Utc))
}
"updated_at" => {
updated_at = DateTime::parse_from_rfc3339(&unquote(v))
.ok()
.map(|d| d.with_timezone(&Utc))
}
_ => {}
}
}
if let Some(h) = handle_buf {
handles.push(h);
}
let now = Utc::now();
let _ = body; Some(Entity {
id,
kind: kind?,
display_name,
aliases,
emails,
handles,
created_at: created_at.unwrap_or(now),
updated_at: updated_at.unwrap_or(now),
})
}
#[cfg(test)]
#[path = "frontmatter_tests.rs"]
mod tests;