Skip to main content

kb/
ast.rs

1//! Personal knowledge base — typed AST as the canonical document model.
2//!
3//! The AST inherits org-mode's structural vocabulary (ADR-003) while being
4//! format-neutral. Every node kind is enumerated as a constructor in a
5//! closed-world ADT. Newtype phantoms prevent type confusion between
6//! domain identifiers.
7
8use serde::{Deserialize, Serialize};
9
10/// A complete knowledge-base document: a sequence of top-level blocks.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct Document {
13    pub blocks: Vec<Block>,
14}
15
16/// Block-level structural elements.
17///
18/// Every node kind in the org structural vocabulary is enumerated as its own
19/// constructor (ADR-003). Drawers and planning lines are top-level blocks.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub enum Block {
22    /// `Heading level title tags children`
23    Heading {
24        level: u8,
25        title: Title,
26        tags: Vec<Tag>,
27        children: Vec<Block>,
28    },
29    Paragraph {
30        inlines: Vec<Inline>,
31    },
32    /// `SrcBlock language content`
33    SrcBlock {
34        language: String,
35        content: String,
36    },
37    /// `#+begin_example` … `#+end_example` literal example block.
38    ExampleBlock {
39        content: String,
40    },
41    QuoteBlock {
42        children: Vec<Block>,
43    },
44    List {
45        list_type: ListType,
46        items: Vec<ListItem>,
47    },
48    Table {
49        rows: Vec<Vec<TableCell>>,
50    },
51    PropertyDrawer {
52        entries: Vec<(String, String)>,
53    },
54    LogbookDrawer {
55        entries: Vec<LogEntry>,
56    },
57    Planning {
58        entries: Vec<PlanningEntry>,
59    },
60    Comment {
61        text: String,
62    },
63    /// A `#+NAME: value` keyword line (e.g. `#+title:`, `#+filetags:`).
64    ///
65    /// `name` is the keyword identifier; `value` is the verbatim remainder
66    /// after the `:` — leading space included — so the line round-trips
67    /// byte-for-byte.
68    Keyword {
69        name: String,
70        value: String,
71    },
72    /// A single empty source line. Blank lines are represented explicitly
73    /// so document spacing round-trips faithfully.
74    BlankLine,
75    HorizontalRule,
76}
77
78/// Whether a list is ordered (numbered) or unordered (bulleted).
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub enum ListType {
81    /// Numbered list; the value is the first item's ordinal, so a list
82    /// that starts at `2.` round-trips faithfully.
83    Ordered(u64),
84    Unordered,
85}
86
87/// A list item: nested blocks plus checkbox state.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct ListItem {
90    pub content: Vec<Block>,
91    pub checkbox: Checkbox,
92}
93
94/// Checkbox state for a list item.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub enum Checkbox {
97    NoCheckbox,
98    Unchecked,
99    Checked,
100}
101
102/// A single table cell containing inline content.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct TableCell {
105    pub inlines: Vec<Inline>,
106}
107
108/// One entry on a planning line.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub enum PlanningEntry {
111    Scheduled(Timestamp),
112    Deadline(Timestamp),
113    Closed(Timestamp),
114}
115
116/// A logbook entry: a state-change timestamp plus its trailing note.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct LogEntry {
119    pub timestamp: Timestamp,
120    pub note: String,
121}
122
123/// Inline formatting elements.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub enum Inline {
126    Plain(String),
127    Bold(Vec<Inline>),
128    Italic(Vec<Inline>),
129    /// Strikethrough markup, rendered as `+text+` in org projection.
130    Strikethrough(Vec<Inline>),
131    InlineCode(String),
132    Verbatim(String),
133    /// A source line break within a paragraph. Generated as `\n`; lets a
134    /// multi-line paragraph round-trip without re-wrapping.
135    LineBreak,
136    /// `Link target description`. `None` description means the link renders
137    /// its target as its visible text.
138    Link {
139        target: String,
140        description: Option<String>,
141    },
142}
143
144// ── Newtype phantoms ────────────────────────────────────────────────────
145
146/// Unique identifier for a node in the knowledge base.
147#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
148pub struct NodeId(pub String);
149
150/// A heading title.
151#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
152pub struct Title(pub String);
153
154/// A tag applied to a heading.
155#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
156pub struct Tag(pub String);
157
158impl NodeId {
159    /// Borrow the inner identifier string.
160    #[must_use]
161    pub fn as_str(&self) -> &str {
162        &self.0
163    }
164}
165
166impl Title {
167    /// Borrow the inner title string.
168    #[must_use]
169    pub fn as_str(&self) -> &str {
170        &self.0
171    }
172}
173
174impl Tag {
175    /// Borrow the inner tag string.
176    #[must_use]
177    pub fn as_str(&self) -> &str {
178        &self.0
179    }
180}
181
182impl From<String> for NodeId {
183    fn from(s: String) -> Self {
184        Self(s)
185    }
186}
187
188impl From<&str> for NodeId {
189    fn from(s: &str) -> Self {
190        Self(s.to_owned())
191    }
192}
193
194impl From<String> for Title {
195    fn from(s: String) -> Self {
196        Self(s)
197    }
198}
199
200impl From<&str> for Title {
201    fn from(s: &str) -> Self {
202        Self(s.to_owned())
203    }
204}
205
206impl From<String> for Tag {
207    fn from(s: String) -> Self {
208        Self(s)
209    }
210}
211
212impl From<&str> for Tag {
213    fn from(s: &str) -> Self {
214        Self(s.to_owned())
215    }
216}
217
218impl std::fmt::Display for NodeId {
219    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        f.write_str(&self.0)
221    }
222}
223
224impl std::fmt::Display for Title {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        f.write_str(&self.0)
227    }
228}
229
230impl std::fmt::Display for Tag {
231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232        f.write_str(&self.0)
233    }
234}
235
236/// An org timestamp in its source-text form (e.g. `<2026-04-30 Thu>`).
237#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
238pub struct Timestamp(pub String);
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn construct_simple_document() {
246        let doc = Document {
247            blocks: vec![Block::Heading {
248                level: 1,
249                title: Title("Hello".into()),
250                tags: vec![],
251                children: vec![Block::Paragraph {
252                    inlines: vec![Inline::Plain("some text".into())],
253                }],
254            }],
255        };
256        assert_eq!(doc.blocks.len(), 1);
257    }
258
259    #[test]
260    fn ast_types_serialize_roundtrip() {
261        let doc = Document {
262            blocks: vec![
263                Block::Heading {
264                    level: 1,
265                    title: Title("Test".into()),
266                    tags: vec![Tag("rust".into())],
267                    children: vec![
268                        Block::Paragraph {
269                            inlines: vec![
270                                Inline::Plain("Hello ".into()),
271                                Inline::Bold(vec![Inline::Plain("world".into())]),
272                                Inline::Plain(". ".into()),
273                                Inline::Link {
274                                    target: "https://example.com".into(),
275                                    description: Some("link".into()),
276                                },
277                            ],
278                        },
279                        Block::SrcBlock {
280                            language: "rust".into(),
281                            content: "fn main() {}".into(),
282                        },
283                        Block::PropertyDrawer {
284                            entries: vec![("ID".into(), "abc-123".into())],
285                        },
286                    ],
287                },
288                Block::List {
289                    list_type: ListType::Unordered,
290                    items: vec![
291                        ListItem {
292                            content: vec![Block::Paragraph {
293                                inlines: vec![Inline::Plain("first".into())],
294                            }],
295                            checkbox: Checkbox::NoCheckbox,
296                        },
297                        ListItem {
298                            content: vec![Block::Paragraph {
299                                inlines: vec![Inline::Plain("second".into())],
300                            }],
301                            checkbox: Checkbox::Checked,
302                        },
303                    ],
304                },
305            ],
306        };
307
308        let json = serde_json::to_string(&doc).unwrap();
309        let roundtripped: Document = serde_json::from_str(&json).unwrap();
310        assert_eq!(doc, roundtripped);
311    }
312}