tftio-kb 2.5.3

Personal knowledge base — typed AST with org-mode as projection, SQLite-backed
Documentation
//! Personal knowledge base — typed AST as the canonical document model.
//!
//! The AST inherits org-mode's structural vocabulary (ADR-003) while being
//! format-neutral. Every node kind is enumerated as a constructor in a
//! closed-world ADT. Newtype phantoms prevent type confusion between
//! domain identifiers.

use serde::{Deserialize, Serialize};

/// A complete knowledge-base document: a sequence of top-level blocks.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Document {
    pub blocks: Vec<Block>,
}

/// Block-level structural elements.
///
/// Every node kind in the org structural vocabulary is enumerated as its own
/// constructor (ADR-003). Drawers and planning lines are top-level blocks.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Block {
    /// `Heading level title tags children`
    Heading {
        level: u8,
        title: Title,
        tags: Vec<Tag>,
        children: Vec<Block>,
    },
    Paragraph {
        inlines: Vec<Inline>,
    },
    /// `SrcBlock language content`
    SrcBlock {
        language: String,
        content: String,
    },
    /// `#+begin_example` … `#+end_example` literal example block.
    ExampleBlock {
        content: String,
    },
    QuoteBlock {
        children: Vec<Block>,
    },
    List {
        list_type: ListType,
        items: Vec<ListItem>,
    },
    Table {
        rows: Vec<Vec<TableCell>>,
    },
    PropertyDrawer {
        entries: Vec<(String, String)>,
    },
    LogbookDrawer {
        entries: Vec<LogEntry>,
    },
    Planning {
        entries: Vec<PlanningEntry>,
    },
    Comment {
        text: String,
    },
    /// A `#+NAME: value` keyword line (e.g. `#+title:`, `#+filetags:`).
    ///
    /// `name` is the keyword identifier; `value` is the verbatim remainder
    /// after the `:` — leading space included — so the line round-trips
    /// byte-for-byte.
    Keyword {
        name: String,
        value: String,
    },
    /// A single empty source line. Blank lines are represented explicitly
    /// so document spacing round-trips faithfully.
    BlankLine,
    HorizontalRule,
}

/// Whether a list is ordered (numbered) or unordered (bulleted).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ListType {
    /// Numbered list; the value is the first item's ordinal, so a list
    /// that starts at `2.` round-trips faithfully.
    Ordered(u64),
    Unordered,
}

/// A list item: nested blocks plus checkbox state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListItem {
    pub content: Vec<Block>,
    pub checkbox: Checkbox,
}

/// Checkbox state for a list item.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Checkbox {
    NoCheckbox,
    Unchecked,
    Checked,
}

/// A single table cell containing inline content.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TableCell {
    pub inlines: Vec<Inline>,
}

/// One entry on a planning line.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlanningEntry {
    Scheduled(Timestamp),
    Deadline(Timestamp),
    Closed(Timestamp),
}

/// A logbook entry: a state-change timestamp plus its trailing note.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogEntry {
    pub timestamp: Timestamp,
    pub note: String,
}

/// Inline formatting elements.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Inline {
    Plain(String),
    Bold(Vec<Inline>),
    Italic(Vec<Inline>),
    /// Strikethrough markup, rendered as `+text+` in org projection.
    Strikethrough(Vec<Inline>),
    InlineCode(String),
    Verbatim(String),
    /// A source line break within a paragraph. Generated as `\n`; lets a
    /// multi-line paragraph round-trip without re-wrapping.
    LineBreak,
    /// `Link target description`. `None` description means the link renders
    /// its target as its visible text.
    Link {
        target: String,
        description: Option<String>,
    },
}

// ── Newtype phantoms ────────────────────────────────────────────────────

/// Unique identifier for a node in the knowledge base.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct NodeId(pub String);

/// A heading title.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Title(pub String);

/// A tag applied to a heading.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Tag(pub String);

impl NodeId {
    /// Borrow the inner identifier string.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl Title {
    /// Borrow the inner title string.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl Tag {
    /// Borrow the inner tag string.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl From<String> for NodeId {
    fn from(s: String) -> Self {
        Self(s)
    }
}

impl From<&str> for NodeId {
    fn from(s: &str) -> Self {
        Self(s.to_owned())
    }
}

impl From<String> for Title {
    fn from(s: String) -> Self {
        Self(s)
    }
}

impl From<&str> for Title {
    fn from(s: &str) -> Self {
        Self(s.to_owned())
    }
}

impl From<String> for Tag {
    fn from(s: String) -> Self {
        Self(s)
    }
}

impl From<&str> for Tag {
    fn from(s: &str) -> Self {
        Self(s.to_owned())
    }
}

impl std::fmt::Display for NodeId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::fmt::Display for Title {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::fmt::Display for Tag {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

/// An org timestamp in its source-text form (e.g. `<2026-04-30 Thu>`).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Timestamp(pub String);

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn construct_simple_document() {
        let doc = Document {
            blocks: vec![Block::Heading {
                level: 1,
                title: Title("Hello".into()),
                tags: vec![],
                children: vec![Block::Paragraph {
                    inlines: vec![Inline::Plain("some text".into())],
                }],
            }],
        };
        assert_eq!(doc.blocks.len(), 1);
    }

    #[test]
    fn ast_types_serialize_roundtrip() {
        let doc = Document {
            blocks: vec![
                Block::Heading {
                    level: 1,
                    title: Title("Test".into()),
                    tags: vec![Tag("rust".into())],
                    children: vec![
                        Block::Paragraph {
                            inlines: vec![
                                Inline::Plain("Hello ".into()),
                                Inline::Bold(vec![Inline::Plain("world".into())]),
                                Inline::Plain(". ".into()),
                                Inline::Link {
                                    target: "https://example.com".into(),
                                    description: Some("link".into()),
                                },
                            ],
                        },
                        Block::SrcBlock {
                            language: "rust".into(),
                            content: "fn main() {}".into(),
                        },
                        Block::PropertyDrawer {
                            entries: vec![("ID".into(), "abc-123".into())],
                        },
                    ],
                },
                Block::List {
                    list_type: ListType::Unordered,
                    items: vec![
                        ListItem {
                            content: vec![Block::Paragraph {
                                inlines: vec![Inline::Plain("first".into())],
                            }],
                            checkbox: Checkbox::NoCheckbox,
                        },
                        ListItem {
                            content: vec![Block::Paragraph {
                                inlines: vec![Inline::Plain("second".into())],
                            }],
                            checkbox: Checkbox::Checked,
                        },
                    ],
                },
            ],
        };

        let json = serde_json::to_string(&doc).unwrap();
        let roundtripped: Document = serde_json::from_str(&json).unwrap();
        assert_eq!(doc, roundtripped);
    }
}