Skip to main content

mq_db/
document.rs

1use std::{collections::HashSet, path::PathBuf};
2
3use crate::block::{Block, BlockType, DocumentId, PropertyValue};
4
5/// Statistical metadata per document used for query pruning (Zone Maps).
6///
7/// Before scanning a document's blocks, the query engine checks these
8/// statistics to decide whether the document can be skipped entirely.
9#[derive(Debug, Clone, Default, PartialEq)]
10pub struct ZoneMaps {
11    /// Maximum heading depth (1–6) found in the document.
12    pub max_heading_depth: u8,
13    /// Set of heading slugs (lowercased, hyphenated) present in the document.
14    /// Used to skip documents that don't contain a requested heading.
15    pub heading_slugs: HashSet<String>,
16    /// Set of heading content strings (plain text) for exact-match skipping.
17    pub heading_contents: HashSet<String>,
18    /// Set of code-block language tags present (e.g. `"rust"`, `"python"`).
19    pub code_languages: HashSet<String>,
20    /// Set of top-level front-matter keys.
21    pub frontmatter_keys: HashSet<String>,
22    /// Document title – from front-matter `title` field or first H1.
23    pub title: Option<String>,
24    /// Tags list from front-matter `tags` array.
25    pub tags: Vec<String>,
26}
27
28/// A parsed Markdown document stored in the database.
29#[derive(Debug, Clone, PartialEq)]
30pub struct Document {
31    pub id: DocumentId,
32    /// Source file path, if the document was loaded from disk.
33    pub path: Option<PathBuf>,
34    /// Flattened, interval-indexed block list.
35    pub blocks: Vec<Block>,
36    /// Authoritative block count — valid even when `blocks` is empty (lazy mode).
37    pub block_count: u32,
38    /// Per-document statistics for query pruning.
39    pub zone_maps: ZoneMaps,
40    /// First page of this document's block chain. Used for on-demand block loading.
41    pub(crate) first_block_page: u32,
42    /// First page of the persisted secondary index. 0 = not stored.
43    pub(crate) index_start_page: u32,
44}
45
46impl Document {
47    pub fn new(id: DocumentId, path: Option<PathBuf>, blocks: Vec<Block>) -> Self {
48        let zone_maps = ZoneMaps::build(&blocks);
49        Self::from_parts(id, path, blocks, zone_maps)
50    }
51
52    pub fn from_parts(
53        id: DocumentId,
54        path: Option<PathBuf>,
55        blocks: Vec<Block>,
56        zone_maps: ZoneMaps,
57    ) -> Self {
58        let block_count = blocks.len() as u32;
59        Self {
60            id,
61            path,
62            blocks,
63            block_count,
64            zone_maps,
65            first_block_page: 0,
66            index_start_page: 0,
67        }
68    }
69
70    /// Construct from catalog metadata only; `blocks` will be empty.
71    pub fn from_catalog(
72        id: DocumentId,
73        path: Option<PathBuf>,
74        block_count: u32,
75        zone_maps: ZoneMaps,
76    ) -> Self {
77        Self {
78            id,
79            path,
80            blocks: Vec::new(),
81            block_count,
82            zone_maps,
83            first_block_page: 0,
84            index_start_page: 0,
85        }
86    }
87
88    /// Construct from catalog metadata for lazy block loading.
89    pub(crate) fn from_catalog_lazy(
90        id: DocumentId,
91        path: Option<PathBuf>,
92        block_count: u32,
93        zone_maps: ZoneMaps,
94        first_block_page: u32,
95        index_start_page: u32,
96    ) -> Self {
97        Self {
98            id,
99            path,
100            blocks: Vec::new(),
101            block_count,
102            zone_maps,
103            first_block_page,
104            index_start_page,
105        }
106    }
107
108    /// Returns the block immediately following `block` in document order
109    /// after its entire section (i.e. the next sibling section), or `None`.
110    ///
111    /// Uses the interval index: the next sibling has `pre == block.post + 1`.
112    pub fn next_sibling<'a>(&'a self, block: &Block) -> Option<&'a Block> {
113        let target_pre = block.post + 1;
114        self.blocks.iter().find(|b| b.pre == target_pre)
115    }
116
117    /// Returns the first content block INSIDE `block`'s section, or `None`.
118    ///
119    /// Uses the interval index: the first child has `pre == block.pre + 1`.
120    /// For leaf blocks (no children), this returns `None`.
121    pub fn first_child<'a>(&'a self, block: &Block) -> Option<&'a Block> {
122        let target_pre = block.pre + 1;
123        self.blocks.iter().find(|b| b.pre == target_pre)
124    }
125
126    /// Returns all blocks that are direct or indirect descendants of
127    /// `ancestor` in the section hierarchy.
128    pub fn descendants_of<'a>(&'a self, ancestor: &Block) -> impl Iterator<Item = &'a Block> {
129        let (anc_pre, anc_post) = (ancestor.pre, ancestor.post);
130        self.blocks
131            .iter()
132            .filter(move |b| b.is_under_interval(anc_pre, anc_post))
133    }
134}
135
136impl ZoneMaps {
137    pub fn build(blocks: &[Block]) -> Self {
138        let mut maps = ZoneMaps::default();
139
140        for block in blocks {
141            match &block.block_type {
142                BlockType::Heading => {
143                    if let Some(d) = block.heading_depth() {
144                        maps.max_heading_depth = maps.max_heading_depth.max(d);
145                    }
146                    maps.heading_contents.insert(block.content.clone());
147                    if let Some(PropertyValue::String(slug)) = block.properties.get("slug") {
148                        maps.heading_slugs.insert(slug.clone());
149                    }
150                    // First H1 becomes the document title if not set via frontmatter
151                    if block.heading_depth() == Some(1) && maps.title.is_none() {
152                        maps.title = Some(block.content.clone());
153                    }
154                }
155                BlockType::Code => {
156                    if let Some(lang) = block.code_lang() {
157                        maps.code_languages.insert(lang.to_string());
158                    }
159                }
160                BlockType::Yaml | BlockType::Toml => {
161                    for (k, v) in block.properties.iter() {
162                        maps.frontmatter_keys.insert(k.clone());
163                        if k == "title"
164                            && let PropertyValue::String(s) = v
165                        {
166                            maps.title = Some(s.clone());
167                        }
168                        if k == "tags"
169                            && let PropertyValue::Array(arr) = v
170                        {
171                            maps.tags = arr
172                                .iter()
173                                .filter_map(|pv| pv.as_str().map(|s| s.to_string()))
174                                .collect();
175                        }
176                    }
177                }
178                _ => {}
179            }
180        }
181
182        maps
183    }
184}