hephaestus/crates/hephd/src/frontmatter.rs
Erich Blume ef56c5d5f2 feat(core,hephd): frontmatter projection — render on read, strip on write (§8.3)
The store-side half of the frontmatter edit surface:
- heph-core `frontmatter::strip` runs in `update_node` before the yrs
  diff, so frontmatter never enters the body or CRDT. Conservative (only
  a leading `---` block whose first line is a YAML key; a prose hrule
  survives) and idempotent → read→write round-trip is a no-op.
- hephd `frontmatter::render` (local-tz dates via new `datespec::fmt_iso`)
  behind `node.get {frontmatter: true}`: id/kind/title/tags, and for a
  task or its canonical-context doc the owning task's scalars + a `task:`
  ref. Subject-task + project-name resolution in dispatch.

Safe against any client (inbound frontmatter always stripped). Tests:
strip unit (incl. hrule/idempotency), render unit, socket round-trip +
task-context-doc projection. The heph.nvim diff-into-RPCs layer is next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:32:59 -07:00

138 lines
4.6 KiB
Rust

//! Render the editable YAML frontmatter projection for a node (tech-spec §8.3).
//!
//! This is the **read** side: the daemon prepends this block to a node's body
//! when a client requests `node.get {frontmatter: true}`. `heph-core` strips it
//! back off on write, so it never persists. Lives in `hephd` (not `heph-core`)
//! because formatting `do_date`/`late_on` for humans needs the local timezone
//! (via [`crate::datespec`]).
//!
//! Schema (a curated, *editable* subset — not the full export snapshot):
//! `id`/`kind` are read-only; `title` and `tags` edit the node itself; when the
//! node **is** a task or backs one (its canonical-context doc), the task's
//! scalars (`state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`) are
//! rendered and a read-only `task:` id is included so the editor knows where to
//! route those edits.
use std::fmt::Write as _;
use heph_core::{Node, Task};
use crate::datespec;
/// Render the frontmatter block (with the `---` fences and a trailing newline)
/// for `node`. `task` is the node's own task or — for a task's
/// canonical-context doc — its owning task. `project_name`/`tags` are resolved
/// by the caller.
pub fn render(
node: &Node,
task: Option<&Task>,
project_name: Option<&str>,
tags: &[String],
) -> String {
let mut fm = String::new();
let _ = writeln!(fm, "---");
let _ = writeln!(fm, "id: {}", node.id);
let _ = writeln!(fm, "kind: {}", node.kind.as_str());
let _ = writeln!(fm, "title: {}", scalar(&node.title));
let _ = writeln!(fm, "tags: {}", flow_seq(tags));
if let Some(t) = task {
let _ = writeln!(fm, "task: {}", t.node_id);
let _ = writeln!(fm, "state: {}", t.state.as_str());
if let Some(a) = t.attention {
let _ = writeln!(fm, "attention: {}", a.as_str());
}
if let Some(d) = t.do_date {
let _ = writeln!(fm, "do_date: {}", datespec::fmt_iso(d));
}
if let Some(l) = t.late_on {
let _ = writeln!(fm, "late_on: {}", datespec::fmt_iso(l));
}
if let Some(r) = &t.recurrence {
let _ = writeln!(fm, "recurrence: {}", scalar(r));
}
if let Some(p) = project_name {
let _ = writeln!(fm, "project: {}", scalar(p));
}
}
let _ = writeln!(fm, "---");
fm
}
/// A YAML scalar, quoted only when bare would be ambiguous (empty, surrounding
/// whitespace, or a character that would break a bare flow scalar).
fn scalar(s: &str) -> String {
let needs_quote = s.is_empty()
|| s != s.trim()
|| s.starts_with([
'#', '-', '[', ']', '{', '}', '&', '*', '!', '|', '>', '\'', '"', '%', '@', '`',
])
|| s.contains([':', ',', '\n']);
if needs_quote {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
} else {
s.to_string()
}
}
/// A YAML flow sequence: `[a, b]`, each element scalar-quoted as needed; `[]`
/// when empty.
fn flow_seq(items: &[String]) -> String {
let inner: Vec<String> = items.iter().map(|s| scalar(s)).collect();
format!("[{}]", inner.join(", "))
}
#[cfg(test)]
mod tests {
use super::*;
use heph_core::{Attention, NodeKind, TaskState};
fn doc(title: &str) -> Node {
Node {
id: "doc1".into(),
owner_id: "u".into(),
kind: NodeKind::Doc,
title: title.into(),
body: Some(String::new()),
created_at: 0,
modified_at: 0,
hlc: "0".into(),
tombstoned: false,
}
}
#[test]
fn renders_a_plain_doc_block() {
let fm = render(&doc("Roof"), None, None, &["house".into(), "urgent".into()]);
assert_eq!(
fm,
"---\nid: doc1\nkind: doc\ntitle: Roof\ntags: [house, urgent]\n---\n"
);
}
#[test]
fn renders_task_scalars_and_project_when_a_task_is_present() {
let task = Task {
node_id: "task1".into(),
attention: Some(Attention::Red),
do_date: None,
late_on: None,
state: TaskState::Outstanding,
recurrence: None,
};
let fm = render(&doc("Fix roof"), Some(&task), Some("Camano"), &[]);
assert!(fm.contains("task: task1"));
assert!(fm.contains("state: outstanding"));
assert!(fm.contains("attention: red"));
assert!(fm.contains("project: Camano"));
assert!(fm.contains("tags: []"));
}
#[test]
fn quotes_titles_that_need_it() {
assert_eq!(scalar("plain"), "plain");
assert_eq!(scalar("has: colon"), "\"has: colon\"");
assert_eq!(scalar(""), "\"\"");
}
}