tftio-kb 2.5.3

Personal knowledge base — typed AST with org-mode as projection, SQLite-backed
Documentation
//! Document-level kb metadata as an org property-drawer envelope.
//!
//! kb storage metadata — the node id and its created/updated timestamps —
//! is projected into org text as a leading `:PROPERTIES:` drawer on
//! export and stripped back out on import. The drawer is an envelope: it
//! is synthesized from storage facts at render time and never persisted
//! into the document AST. Both the `kb` CLI and the HTTP server route
//! their `text/org` output and input through these two functions.

use crate::ast::{Block, Document};
use crate::generator;

/// Property-drawer keys carrying kb storage metadata. These are
/// dehydrated into the org export and hydrated back out on import; they
/// are never stored in the document body.
pub const RESERVED_META_KEYS: [&str; 3] = ["ID", "CREATED", "UPDATED"];

/// Render `document` as org text prefixed with a document-level property
/// drawer carrying the node's kb metadata.
///
/// The drawer is built from the supplied storage facts, not from the
/// document AST, so the generator's `Document -> String` round-trip
/// invariant (`tests/roundtrip.rs`) is unaffected.
#[must_use]
pub fn render_with_metadata(
    id: &str,
    created_at: &str,
    updated_at: &str,
    document: &Document,
) -> String {
    // The generator emits property values verbatim (`:KEY:value`), so the
    // `:KEY: value` separator space must live in the value itself. `hydrate`
    // trims it back off on import.
    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)
    )
}

/// Hydrate kb metadata out of an imported document.
///
/// When `document` opens with a property drawer carrying an `ID` entry,
/// the kb-reserved entries ([`RESERVED_META_KEYS`]) are removed so they
/// never reach the stored body. Non-reserved entries in the same drawer
/// are preserved; the drawer block is dropped entirely when only
/// reserved entries remain. Returns the `ID` value when present.
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;
    }
    // Property values are stored verbatim, including the `:KEY: value`
    // separator space; trim it so the returned id is the bare value.
    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::ast::{Inline, Tag, Title};
    use crate::canonical::canonicalize;
    use crate::parser;

    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"));
        // The reserved drawer is gone; the body matches the original doc
        // in canonical form (the parser/generator round-trip).
        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())],
            }]
        );
    }
}