Skip to main content

omgbase_surface/
read.rs

1//! Reads (`spec/surface/README.md` §2): ref resolution, whole-document reads,
2//! block hydration at a resolution, the outline wire format, and the two
3//! path-ordered list surfaces. Port of `packages/core/src/core/read/*`.
4//! Result keys follow the reference's spelling (`id`, `path`, `raw`, the row
5//! rule of §1.2; `renderedHashMatch`, …) — the fixtures are generated from it.
6
7use std::collections::{BTreeMap, HashMap};
8
9use omgbase_format::hash::hex;
10use omgbase_store::{Store, is_id_ref};
11use rusqlite::{Connection, OptionalExtension, params};
12use serde_json::{Map, Value as Json, json};
13
14use crate::context::glob_to_like;
15use crate::cursor::{decode_path_cursor, encode_cursor};
16use crate::error::Result;
17use crate::paths::{reference_path, storage_path};
18
19/// Max refs honored per `docs_read_many` / `blocks_read_many` call.
20pub const MANY_CAP: usize = 100;
21/// `docs_list` / `docs_tree` default page size.
22pub const LIST_DEFAULT_LIMIT: usize = 200;
23
24/// `ceil(JSON length / 4)`: the token estimate every budget uses.
25#[must_use]
26pub fn token_cost(v: &Json) -> usize {
27    v.to_string().chars().count().div_ceil(4)
28}
29
30// ---- documents --------------------------------------------------------------------------
31
32/// A live document's identity (`findDoc`).
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct DocInfo {
35    pub doc_id: String,
36    pub repo_id: String,
37    /// The STORAGE form (`a/b.md`): the library's handle on a document; the
38    /// surface roots what it returns (`spec/surface` §1 "Paths").
39    pub path: String,
40    pub format: String,
41    pub current_rev: Option<String>,
42}
43
44fn doc_row(conn: &Connection, sql: &str, p: &[&dyn rusqlite::ToSql]) -> Result<Option<DocInfo>> {
45    Ok(conn
46        .query_row(sql, p, |r| {
47            Ok(DocInfo {
48                doc_id: r.get(0)?,
49                repo_id: r.get(1)?,
50                path: r.get(2)?,
51                format: r
52                    .get::<_, Option<String>>(3)?
53                    .unwrap_or_else(|| "markdown".to_owned()),
54                current_rev: r.get(4)?,
55            })
56        })
57        .optional()?)
58}
59
60/// A live doc by id.
61pub fn find_doc_by_id(conn: &Connection, doc_id: &str) -> Result<Option<DocInfo>> {
62    doc_row(
63        conn,
64        "SELECT doc_id, repo_id, path, format, current_rev FROM docs WHERE doc_id = ?1 AND deleted_commit IS NULL",
65        &[&doc_id],
66    )
67}
68
69/// A live doc by repo + path, in either form (`spec/surface` §1 "Paths").
70pub fn find_doc_by_path(conn: &Connection, repo_id: &str, path: &str) -> Result<Option<DocInfo>> {
71    doc_row(
72        conn,
73        "SELECT doc_id, repo_id, path, format, current_rev FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
74        &[&repo_id, &storage_path(path)],
75    )
76}
77
78/// The id-or-path dispatch every doc-ref surface routes through: a `d_` id
79/// is looked up by id only (never falling through to a path).
80pub fn find_doc_by_ref(conn: &Connection, repo_id: &str, r: &str) -> Result<Option<DocInfo>> {
81    if is_id_ref(r, "d") {
82        return find_doc_by_id(conn, r);
83    }
84    find_doc_by_path(conn, repo_id, r)
85}
86
87/// The prefix of an id-shaped ref (`^[a-z]+_[alphabet]{1,7}$`, so the
88/// fixture minter's `b_0` counts, as the reference's `isValidId` after
89/// `spec/mutate`).
90fn id_prefix(r: &str) -> Option<&str> {
91    let (p, _) = r.split_once('_')?;
92    is_id_ref(r, p).then_some(p)
93}
94
95/// `resolve_ref`'s answer.
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub enum ResolvedRef {
98    Block { doc_id: String, block_id: String },
99    Document { doc_id: String },
100}
101
102impl ResolvedRef {
103    #[must_use]
104    pub fn doc_id(&self) -> &str {
105        match self {
106            ResolvedRef::Block { doc_id, .. } | ResolvedRef::Document { doc_id } => doc_id,
107        }
108    }
109}
110
111fn is_node_id(s: &str) -> bool {
112    s.len() == 14
113        && s.starts_with("n_")
114        && s[2..]
115            .bytes()
116            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
117}
118
119/// §2 `resolve_ref`: an `n_` node id → its live block, else its doc; a `b_`
120/// id → its live block; a `d_` id → the live doc; anything else → the live
121/// doc at that path; `None` when nothing matches.
122pub fn resolve_ref(conn: &Connection, repo_id: &str, r: &str) -> Result<Option<ResolvedRef>> {
123    if is_node_id(r) {
124        let node: Option<(String, Option<String>)> = conn
125            .query_row(
126                "SELECT doc_id, block_id FROM nodes WHERE node_id = ?1",
127                params![r],
128                |row| Ok((row.get(0)?, row.get(1)?)),
129            )
130            .optional()?;
131        let Some((doc_id, block_id)) = node else {
132            return Ok(None);
133        };
134        if let Some(b) = block_id {
135            let live: bool = conn
136                .prepare_cached(
137                    "SELECT 1 FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
138                )?
139                .exists(params![b])?;
140            if live {
141                return Ok(Some(ResolvedRef::Block {
142                    doc_id,
143                    block_id: b,
144                }));
145            }
146        }
147        return Ok(Some(ResolvedRef::Document { doc_id }));
148    }
149    match id_prefix(r) {
150        Some("b") => {
151            let doc: Option<String> = conn
152                .query_row(
153                    "SELECT doc_id FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
154                    params![r],
155                    |row| row.get(0),
156                )
157                .optional()?;
158            Ok(doc.map(|doc_id| ResolvedRef::Block {
159                doc_id,
160                block_id: r.to_owned(),
161            }))
162        }
163        Some("d") => {
164            Ok(find_doc_by_id(conn, r)?.map(|i| ResolvedRef::Document { doc_id: i.doc_id }))
165        }
166        _ => {
167            Ok(find_doc_by_path(conn, repo_id, r)?
168                .map(|i| ResolvedRef::Document { doc_id: i.doc_id }))
169        }
170    }
171}
172
173// ---- block forest -------------------------------------------------------------------------
174
175/// A live block with its children (the containment forest).
176#[derive(Clone, Debug, PartialEq)]
177pub struct BlockNode {
178    pub block_id: String,
179    pub doc_id: String,
180    pub parent_block: Option<String>,
181    pub ordinal: i64,
182    pub depth: i64,
183    pub kind: String,
184    pub attrs: Json,
185    pub text: String,
186    pub raw_hash_hex: String,
187    pub children: Vec<BlockNode>,
188}
189
190/// A document's live blocks as an ordered forest (`ORDER BY parent_block,
191/// order_key`; a row whose parent is not live is a root).
192pub fn load_doc_blocks(conn: &Connection, doc_id: &str) -> Result<Vec<BlockNode>> {
193    struct Row {
194        block_id: String,
195        parent_block: Option<String>,
196        ordinal: i64,
197        depth: i64,
198        kind: String,
199        attrs: String,
200        text: String,
201        raw_hash: Vec<u8>,
202    }
203    let rows: Vec<Row> = {
204        let mut stmt = conn.prepare_cached(
205            "SELECT block_id, parent_block, ordinal, depth, type, attrs, text, raw_hash
206             FROM blocks WHERE doc_id = ?1 AND deleted_commit IS NULL
207             ORDER BY parent_block, order_key",
208        )?;
209        let it = stmt.query_map(params![doc_id], |r| {
210            Ok(Row {
211                block_id: r.get(0)?,
212                parent_block: r.get(1)?,
213                ordinal: r.get(2)?,
214                depth: r.get(3)?,
215                kind: r.get(4)?,
216                attrs: r.get(5)?,
217                text: r.get(6)?,
218                raw_hash: r.get(7)?,
219            })
220        })?;
221        it.collect::<std::result::Result<Vec<_>, _>>()?
222    };
223    let ids: Vec<String> = rows.iter().map(|r| r.block_id.clone()).collect();
224    let index: HashMap<&str, usize> = ids
225        .iter()
226        .enumerate()
227        .map(|(i, id)| (id.as_str(), i))
228        .collect();
229    let mut nodes: Vec<Option<BlockNode>> = rows
230        .iter()
231        .map(|r| {
232            Some(BlockNode {
233                block_id: r.block_id.clone(),
234                doc_id: doc_id.to_owned(),
235                parent_block: r.parent_block.clone(),
236                ordinal: r.ordinal,
237                depth: r.depth,
238                kind: r.kind.clone(),
239                attrs: serde_json::from_str(&r.attrs).unwrap_or(Json::Object(Map::new())),
240                text: r.text.clone(),
241                raw_hash_hex: hex(&r.raw_hash),
242                children: Vec::new(),
243            })
244        })
245        .collect();
246    // Children in row order under their parent; roots in row order.
247    let mut children_of: Vec<Vec<usize>> = vec![Vec::new(); rows.len()];
248    let mut roots: Vec<usize> = Vec::new();
249    for (i, r) in rows.iter().enumerate() {
250        match r.parent_block.as_deref().and_then(|p| index.get(p)) {
251            Some(&p) => children_of[p].push(i),
252            None => roots.push(i),
253        }
254    }
255    fn build(i: usize, nodes: &mut [Option<BlockNode>], children_of: &[Vec<usize>]) -> BlockNode {
256        let kids: Vec<BlockNode> = children_of[i]
257            .iter()
258            .map(|&c| build(c, nodes, children_of))
259            .collect();
260        let mut n = nodes[i].take().expect("each node is built once");
261        n.children = kids;
262        n
263    }
264    Ok(roots
265        .into_iter()
266        .map(|i| build(i, &mut nodes, &children_of))
267        .collect())
268}
269
270fn find_block<'n>(roots: &'n [BlockNode], block_id: &str) -> Option<&'n BlockNode> {
271    for n in roots {
272        if n.block_id == block_id {
273            return Some(n);
274        }
275        if let Some(f) = find_block(&n.children, block_id) {
276            return Some(f);
277        }
278    }
279    None
280}
281
282/// A block's raw bytes by hex hash (`""` when unknown).
283pub fn block_raw(conn: &Connection, raw_hash_hex: &str) -> Result<String> {
284    let bytes: Vec<u8> = (0..raw_hash_hex.len() / 2)
285        .filter_map(|i| u8::from_str_radix(&raw_hash_hex[2 * i..2 * i + 2], 16).ok())
286        .collect();
287    Ok(omgbase_store::read::blob_text(conn, &bytes)?)
288}
289
290// ---- docs_read ---------------------------------------------------------------------------
291
292/// The surface's `properties` (§2, 5.0): the `spec/properties` §5 grouped
293/// shape with the computed group's keys spelled as the attributes that read
294/// them — the store writes the computed rows under `$title`/`$tags`
295/// (`spec/properties` §3.3, unchanged), the surface has no `$` names and its
296/// engine names are `_`-prefixed, so `properties.computed` is `{ _title,
297/// _tags }`. Authored groups are untouched (bare keys; an authored `$x:` key
298/// keeps its sigil, as `_frontmatter.$x` reads it). The reference's
299/// `surfaceProperties`.
300fn surface_properties(mut grouped: Json) -> Json {
301    if let Some(computed) = grouped.get_mut("computed").and_then(Json::as_object_mut) {
302        let rekeyed: Map<String, Json> = std::mem::take(computed)
303            .into_iter()
304            .map(|(k, v)| {
305                (
306                    k.strip_prefix('$').map_or(k.clone(), |r| format!("_{r}")),
307                    v,
308                )
309            })
310            .collect();
311        *computed = rekeyed;
312    }
313    grouped
314}
315
316/// §2 `docs_read`: `{ _id, _path, rev, properties, _raw }` (+ `ids`,
317/// `hashes`, `parents` with ids) — the document's row attributes under the
318/// surface's names (§1.2, 5.0); `None` for a missing doc.
319pub fn docs_read(store: &Store, doc_id: &str, include_ids: bool) -> Result<Option<Json>> {
320    let conn = store.conn();
321    let Some(info) = find_doc_by_id(conn, doc_id)? else {
322        return Ok(None);
323    };
324    let Some(content) = store.reconstruct(doc_id)? else {
325        return Ok(None);
326    };
327    let mut m = Map::new();
328    m.insert("_id".to_owned(), json!(info.doc_id));
329    m.insert("_path".to_owned(), json!(reference_path(&info.path)));
330    m.insert("rev".to_owned(), json!(info.current_rev));
331    m.insert(
332        "properties".to_owned(),
333        surface_properties(store.properties_grouped(doc_id)?),
334    );
335    m.insert("_raw".to_owned(), json!(content));
336    if include_ids {
337        let mut ids = Vec::new();
338        let mut hashes = Map::new();
339        let mut parents = Map::new();
340        fn collect(
341            nodes: &[BlockNode],
342            ids: &mut Vec<Json>,
343            hashes: &mut Map<String, Json>,
344            parents: &mut Map<String, Json>,
345        ) {
346            for n in nodes {
347                ids.push(json!(n.block_id));
348                hashes.insert(n.block_id.clone(), json!(n.raw_hash_hex));
349                parents.insert(n.block_id.clone(), json!(n.parent_block));
350                collect(&n.children, ids, hashes, parents);
351            }
352        }
353        collect(
354            &load_doc_blocks(conn, doc_id)?,
355            &mut ids,
356            &mut hashes,
357            &mut parents,
358        );
359        m.insert("ids".to_owned(), Json::Array(ids));
360        m.insert("hashes".to_owned(), Json::Object(hashes));
361        m.insert("parents".to_owned(), Json::Object(parents));
362    }
363    Ok(Some(Json::Object(m)))
364}
365
366/// §2 `docs_read_many`: `{ items, errors, truncated }`.
367pub fn docs_read_many(
368    store: &Store,
369    repo_id: &str,
370    refs: &[String],
371    include_ids: bool,
372    budget_tokens: Option<usize>,
373) -> Result<Json> {
374    let capped = &refs[..refs.len().min(MANY_CAP)];
375    let mut truncated = refs.len() > MANY_CAP;
376    let mut items = Vec::new();
377    let mut errors = Vec::new();
378    let mut seen: Vec<&str> = Vec::new();
379    let mut tokens = 0usize;
380    for r in capped {
381        if seen.contains(&r.as_str()) {
382            continue;
383        }
384        seen.push(r);
385        let info = find_doc_by_ref(store.conn(), repo_id, r)?;
386        let read = match info {
387            Some(i) => docs_read(store, &i.doc_id, include_ids)?,
388            None => None,
389        };
390        let Some(read) = read else {
391            errors.push(json!({ "ref": r, "error": "doc_not_found" }));
392            continue;
393        };
394        // §2 (1.2): the budget is checked from the second item onward — a
395        // first item that alone exceeds it is still emitted (the
396        // `docs_list`/`docs_tree` floor, one list contract).
397        let cost = token_cost(&read);
398        if !items.is_empty() && budget_tokens.is_some_and(|b| tokens + cost > b) {
399            truncated = true;
400            break;
401        }
402        tokens += cost;
403        items.push(read);
404    }
405    Ok(json!({ "items": items, "errors": errors, "truncated": truncated }))
406}
407
408/// §2 `docs_read { at }` / `docs_read_at` (4.7): the document AS OF a
409/// revision — `spec/store` §6.2 reconstruct, the path as of the revision in
410/// the reference form. No `properties`: the properties table is current-state
411/// only (`spec/properties`), and recomputing the bag from the reconstructed
412/// bytes is not a one-call affair of the format layer (the inline fields come
413/// out of the ingest's node projection) — so the time-travel read carries the
414/// bytes and nothing it would have to guess at (through 4.6 `docs_read_at`
415/// returned the CURRENT bag flagged `propertiesAreCurrent: true`). `None`
416/// when `rev` is not a revision of the (live) document.
417pub fn docs_read_at(store: &Store, doc_id: &str, rev: &str) -> Result<Option<Json>> {
418    let Some(r) = store.read_at_revision(doc_id, rev)? else {
419        return Ok(None);
420    };
421    Ok(Some(json!({
422        "_id": doc_id,
423        "_path": reference_path(&r.path),
424        "rev": rev,
425        "_raw": r.content,
426        "renderedHashMatch": r.rendered_hash_match,
427    })))
428}
429
430// ---- blocks_read ----------------------------------------------------------------------------
431
432/// The resolution ladder.
433#[derive(Clone, Copy, Debug, PartialEq, Eq)]
434pub enum Resolution {
435    Skeleton,
436    Outline,
437    Text,
438    Raw,
439    Full,
440}
441
442impl Resolution {
443    /// The wire spelling; `None` for an unknown word.
444    #[must_use]
445    pub fn parse(s: &str) -> Option<Self> {
446        Some(match s {
447            "skeleton" => Resolution::Skeleton,
448            "outline" => Resolution::Outline,
449            "text" => Resolution::Text,
450            "raw" => Resolution::Raw,
451            "full" => Resolution::Full,
452            _ => return None,
453        })
454    }
455}
456
457/// The first `n` whitespace-separated words, `…` when cut.
458#[must_use]
459pub fn first_words(text: &str, n: usize) -> String {
460    let words: Vec<&str> = text.split_whitespace().collect();
461    if words.len() <= n {
462        words.join(" ")
463    } else {
464        format!("{}…", words[..n].join(" "))
465    }
466}
467
468fn project(
469    conn: &Connection,
470    node: &BlockNode,
471    resolution: Resolution,
472    include_children: bool,
473) -> Result<Json> {
474    // A block row under the surface's names (§1.2, 5.0): `_id`, the raw bytes
475    // `_raw` and their hash `_hash`; `_type`, `_text`, `_attrs`, `_ordinal`,
476    // `_depth` are the block's columns, `_children` its relation, and
477    // `label`/`parent_id` roles.
478    let mut m = Map::new();
479    m.insert("_id".to_owned(), json!(node.block_id));
480    m.insert("_type".to_owned(), json!(node.kind));
481    match resolution {
482        Resolution::Skeleton => {
483            let label = if node.kind == "heading" {
484                node.text.clone()
485            } else {
486                node.kind.clone()
487            };
488            m.insert("label".to_owned(), json!(label));
489        }
490        Resolution::Outline => {
491            m.insert("label".to_owned(), json!(first_words(&node.text, 10)));
492        }
493        Resolution::Text => {
494            m.insert("_text".to_owned(), json!(node.text));
495        }
496        Resolution::Raw => {
497            m.insert(
498                "_raw".to_owned(),
499                json!(block_raw(conn, &node.raw_hash_hex)?),
500            );
501            m.insert("_hash".to_owned(), json!(node.raw_hash_hex));
502        }
503        Resolution::Full => {
504            m.insert(
505                "_raw".to_owned(),
506                json!(block_raw(conn, &node.raw_hash_hex)?),
507            );
508            m.insert("_hash".to_owned(), json!(node.raw_hash_hex));
509            m.insert("_text".to_owned(), json!(node.text));
510            m.insert("_attrs".to_owned(), node.attrs.clone());
511            m.insert("_ordinal".to_owned(), json!(node.ordinal));
512            m.insert("_depth".to_owned(), json!(node.depth));
513            m.insert("parent_id".to_owned(), json!(node.parent_block));
514        }
515    }
516    if include_children && !node.children.is_empty() {
517        let kids: Result<Vec<Json>> = node
518            .children
519            .iter()
520            .map(|c| project(conn, c, resolution, true))
521            .collect();
522        m.insert("_children".to_owned(), Json::Array(kids?));
523    }
524    Ok(Json::Object(m))
525}
526
527/// §2 `blocks_read`: one block subtree at a resolution; `None` when the block
528/// is not live in `doc_id`.
529pub fn blocks_read(
530    store: &Store,
531    doc_id: &str,
532    block_id: &str,
533    resolution: Resolution,
534) -> Result<Option<Json>> {
535    let roots = load_doc_blocks(store.conn(), doc_id)?;
536    match find_block(&roots, block_id) {
537        Some(n) => Ok(Some(project(store.conn(), n, resolution, true)?)),
538        None => Ok(None),
539    }
540}
541
542/// §2 `blocks_read_many`: `{ nodes, truncated, unresolved }`.
543pub fn blocks_read_many(
544    store: &Store,
545    doc_id: Option<&str>,
546    block_ids: &[String],
547    resolution: Resolution,
548    budget_tokens: Option<usize>,
549) -> Result<Json> {
550    let conn = store.conn();
551    let capped = &block_ids[..block_ids.len().min(MANY_CAP)];
552    let mut owner: HashMap<String, String> = HashMap::new();
553    if !capped.is_empty() {
554        let placeholders: Vec<String> = (1..=capped.len()).map(|i| format!("?{i}")).collect();
555        let sql = format!(
556            "SELECT block_id, doc_id FROM blocks WHERE deleted_commit IS NULL AND block_id IN ({})",
557            placeholders.join(",")
558        );
559        let mut stmt = conn.prepare(&sql)?;
560        let rows = stmt.query_map(rusqlite::params_from_iter(capped.iter()), |r| {
561            Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
562        })?;
563        for row in rows {
564            let (b, d) = row?;
565            if doc_id.is_none_or(|want| want == d) {
566                owner.insert(b, d);
567            }
568        }
569    }
570    let mut forests: BTreeMap<String, Vec<BlockNode>> = BTreeMap::new();
571    for d in owner.values() {
572        if !forests.contains_key(d) {
573            forests.insert(d.clone(), load_doc_blocks(conn, d)?);
574        }
575    }
576    let mut nodes = Vec::new();
577    let mut unresolved = Vec::new();
578    let mut tokens = 0usize;
579    let mut truncated = block_ids.len() > MANY_CAP;
580    for id in capped {
581        let node = owner
582            .get(id)
583            .and_then(|d| forests.get(d))
584            .and_then(|f| find_block(f, id));
585        let Some(node) = node else {
586            unresolved.push(json!(id));
587            continue;
588        };
589        let projected = project(conn, node, resolution, false)?;
590        // §2 (1.2): at least one resolved node, as `docs_read_many`.
591        let cost = token_cost(&projected);
592        if !nodes.is_empty() && budget_tokens.is_some_and(|b| tokens + cost > b) {
593            truncated = true;
594            break;
595        }
596        tokens += cost;
597        nodes.push(projected);
598    }
599    Ok(json!({ "nodes": nodes, "truncated": truncated, "unresolved": unresolved }))
600}
601
602// ---- docs_outline -----------------------------------------------------------------------
603
604fn type_label(node: &BlockNode) -> String {
605    match node.kind.as_str() {
606        "heading" => format!(
607            "h{}",
608            node.attrs
609                .get("level")
610                .map(|l| match l {
611                    Json::String(s) => s.clone(),
612                    Json::Null => String::new(),
613                    other => other.to_string(),
614                })
615                .unwrap_or_default()
616        ),
617        "paragraph" => "p".to_owned(),
618        "list" => "ul".to_owned(),
619        "list_item" | "task" => "li".to_owned(),
620        "blockquote" => "bq".to_owned(),
621        "code_fence" => "code".to_owned(),
622        "table" => "tbl".to_owned(),
623        "table_row" => "tr".to_owned(),
624        "thematic_break" => "hr".to_owned(),
625        "html_block" => "html".to_owned(),
626        "opaque" => "raw".to_owned(),
627        other => other.to_owned(),
628    }
629}
630
631fn label_for(node: &BlockNode) -> String {
632    match node.kind.as_str() {
633        "list" | "blockquote" | "table" => String::new(),
634        "task" => {
635            let checked = node.attrs.get("checked").is_some_and(|c| {
636                !matches!(c, Json::Null | Json::Bool(false)) && c != &json!(0) && c != &json!("")
637            });
638            let glyph = if checked { "☑" } else { "☐" };
639            format!("{glyph} {}", first_words(&node.text, 10))
640        }
641        _ => first_words(&node.text, 10),
642    }
643}
644
645/// §2 `docs_outline`: `{ text, truncated }`.
646pub fn docs_outline(
647    store: &Store,
648    doc_id: &str,
649    skeleton: bool,
650    depth: Option<i64>,
651    budget_tokens: Option<usize>,
652) -> Result<Json> {
653    let roots = load_doc_blocks(store.conn(), doc_id)?;
654    struct Walk {
655        skeleton: bool,
656        max_depth: Option<i64>,
657        budget: Option<usize>,
658        lines: Vec<String>,
659        tokens: usize,
660        truncated: bool,
661    }
662    impl Walk {
663        fn walk(&mut self, nodes: &[BlockNode], indent: i64) {
664            for node in nodes {
665                if self.truncated {
666                    return;
667                }
668                if self.max_depth.is_some_and(|d| indent > d) {
669                    continue;
670                }
671                let pad = "  ".repeat(usize::try_from(indent).unwrap_or(0));
672                let section_mark = if node.kind == "heading" { "  §" } else { "" };
673                let label = if self.skeleton {
674                    String::new()
675                } else {
676                    label_for(node)
677                };
678                let line = format!(
679                    "{pad}{} {:<4} {label}{section_mark}",
680                    node.block_id,
681                    type_label(node)
682                );
683                let line = line.trim_end().to_owned();
684                let line_tokens = line.chars().count().div_ceil(4);
685                if self.budget.is_some_and(|b| self.tokens + line_tokens > b) {
686                    self.truncated = true;
687                    return;
688                }
689                self.tokens += line_tokens;
690                self.lines.push(line);
691                if !node.children.is_empty() {
692                    self.walk(&node.children, indent + 1);
693                }
694            }
695        }
696    }
697    let mut w = Walk {
698        skeleton,
699        max_depth: depth,
700        budget: budget_tokens,
701        lines: Vec::new(),
702        tokens: 0,
703        truncated: false,
704    };
705    w.walk(&roots, 0);
706    let (lines, truncated) = (w.lines, w.truncated);
707    Ok(json!({ "text": lines.join("\n"), "truncated": truncated }))
708}
709
710// ---- docs_list / docs_tree --------------------------------------------------------------
711
712/// One row of `docs_list` (`path` in the storage form; the wire roots it).
713#[derive(Clone, Debug, PartialEq, Eq)]
714pub struct DocListRow {
715    pub doc_id: String,
716    pub path: String,
717    pub blocks: i64,
718    pub ts: Option<String>,
719}
720
721impl DocListRow {
722    /// The wire row (§2, 5.0): `{ _id, _path, _updated_at, blocks }` — the
723    /// document's row attributes, the path in the reference form (§1 "Paths").
724    fn to_json(&self) -> Json {
725        json!({ "_id": self.doc_id, "_path": reference_path(&self.path), "_updated_at": self.ts, "blocks": self.blocks })
726    }
727}
728
729/// Live docs (path, live block count, last-commit ts) matching `like`, by
730/// path, optionally after `after`, capped at `limit`.
731fn live_doc_rows(
732    conn: &Connection,
733    repo_id: &str,
734    like: &str,
735    after: Option<&str>,
736    limit: Option<usize>,
737) -> Result<Vec<DocListRow>> {
738    let mut sql = String::from(
739        "SELECT d.path, d.doc_id,
740                (SELECT count(*) FROM blocks b WHERE b.doc_id = d.doc_id AND b.deleted_commit IS NULL),
741                (SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = d.current_rev)
742         FROM docs d
743         WHERE d.repo_id = ?1 AND d.deleted_commit IS NULL AND d.path LIKE ?2 ESCAPE '\\'",
744    );
745    let mut p: Vec<rusqlite::types::Value> = vec![
746        rusqlite::types::Value::Text(repo_id.to_owned()),
747        rusqlite::types::Value::Text(like.to_owned()),
748    ];
749    if let Some(a) = after {
750        sql.push_str(" AND d.path > ?3");
751        p.push(rusqlite::types::Value::Text(a.to_owned()));
752    }
753    sql.push_str(" ORDER BY d.path");
754    if let Some(l) = limit {
755        sql.push_str(&format!(" LIMIT ?{}", p.len() + 1));
756        p.push(rusqlite::types::Value::Integer(
757            i64::try_from(l).unwrap_or(i64::MAX),
758        ));
759    }
760    // Four shapes at most (`after` × `limit`): every list/tree call reuses a
761    // compiled statement.
762    let mut stmt = conn.prepare_cached(&sql)?;
763    let rows = stmt.query_map(rusqlite::params_from_iter(p.iter()), |r| {
764        Ok(DocListRow {
765            path: r.get(0)?,
766            doc_id: r.get(1)?,
767            blocks: r.get(2)?,
768            ts: r.get(3)?,
769        })
770    })?;
771    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
772}
773
774/// Page an already path-ordered row set under a limit + token budget (at
775/// least one row), issuing a `[path]` cursor when it stops early; the paths
776/// handed in are the reference form, which the cursor carries.
777fn page_path_ordered(
778    rows: Vec<(String, Json)>,
779    limit: usize,
780    budget_tokens: Option<usize>,
781    more_beyond: bool,
782) -> (Vec<Json>, bool, Option<String>) {
783    let mut items: Vec<Json> = Vec::new();
784    let mut last_path: Option<String> = None;
785    let mut tokens = 0usize;
786    let mut truncated = more_beyond;
787    for (path, row) in rows {
788        if items.len() >= limit {
789            truncated = true;
790            break;
791        }
792        let cost = token_cost(&row);
793        if !items.is_empty() && budget_tokens.is_some_and(|b| tokens + cost > b) {
794            truncated = true;
795            break;
796        }
797        tokens += cost;
798        items.push(row);
799        last_path = Some(path);
800    }
801    let cursor = match (&truncated, last_path) {
802        (true, Some(p)) => Some(encode_cursor(&[&p])),
803        _ => None,
804    };
805    (items, truncated, cursor)
806}
807
808/// §2 `docs_list`: `{ items, truncated, cursor }`.
809pub fn docs_list(
810    store: &Store,
811    repo_id: &str,
812    path_glob: Option<&str>,
813    limit: Option<i64>,
814    cursor: Option<&str>,
815    budget_tokens: Option<usize>,
816) -> Result<Json> {
817    // The glob may be spelled in either form; the column holds the storage form.
818    let like = path_glob.map_or_else(|| "%".to_owned(), |g| glob_to_like(storage_path(g), true));
819    let limit = usize::try_from(limit.unwrap_or(LIST_DEFAULT_LIMIT as i64).max(1)).unwrap_or(1);
820    // The cursor carries the reference form (a 1.x cursor is refused); the
821    // keyset predicate runs on the column.
822    let after = match cursor.filter(|c| !c.is_empty()) {
823        Some(c) => Some(
824            storage_path(&decode_path_cursor(c, "docs_list/docs_tree", 1)?.remove(0)).to_owned(),
825        ),
826        None => None,
827    };
828    let mut rows = live_doc_rows(
829        store.conn(),
830        repo_id,
831        &like,
832        after.as_deref(),
833        Some(limit + 1),
834    )?;
835    let more_beyond = rows.len() > limit;
836    rows.truncate(limit);
837    let (items, truncated, cursor) = page_path_ordered(
838        rows.into_iter()
839            .map(|r| (reference_path(&r.path), r.to_json()))
840            .collect(),
841        limit,
842        budget_tokens,
843        more_beyond,
844    );
845    Ok(json!({ "items": items, "truncated": truncated, "cursor": cursor }))
846}
847
848/// Normalize a tree prefix to its storage form: no leading `/`, and either
849/// empty or ending in `/` (either path form is accepted).
850#[must_use]
851pub fn normalize_tree_prefix(path: Option<&str>) -> String {
852    let trimmed = storage_path(path.unwrap_or("")).trim_end_matches('/');
853    if trimmed.is_empty() {
854        String::new()
855    } else {
856        format!("{trimmed}/")
857    }
858}
859
860/// §2 `docs_tree`: `{ prefix, depth, total, entries, truncated, cursor }`. The
861/// tree is built over the storage form and rooted on the way out, so the
862/// prefix (`/` for the root), the entries and the cursor all speak the
863/// reference form (§1 "Paths").
864pub fn docs_tree(
865    store: &Store,
866    repo_id: &str,
867    path: Option<&str>,
868    depth: Option<i64>,
869    limit: Option<i64>,
870    cursor: Option<&str>,
871    budget_tokens: Option<usize>,
872) -> Result<Json> {
873    let prefix = normalize_tree_prefix(path);
874    let depth = usize::try_from(depth.unwrap_or(1).max(1)).unwrap_or(1);
875    let limit = usize::try_from(limit.unwrap_or(LIST_DEFAULT_LIMIT as i64).max(1)).unwrap_or(1);
876    let like = if prefix.is_empty() {
877        "%".to_owned()
878    } else {
879        format!("{}%", glob_to_like(&prefix, true))
880    };
881    let rows = live_doc_rows(store.conn(), repo_id, &like, None, None)?;
882
883    struct Entry {
884        path: String,
885        /// The document's id for a `doc` entry; a `dir` has none.
886        doc_id: Option<String>,
887        dir: bool,
888        docs: i64,
889        blocks: i64,
890        ts: Option<String>,
891    }
892    let mut by_path: Vec<Entry> = Vec::new();
893    let (mut total_docs, mut total_blocks) = (0i64, 0i64);
894    for row in &rows {
895        total_docs += 1;
896        total_blocks += row.blocks;
897        let rel = &row.path[prefix.len().min(row.path.len())..];
898        let segs: Vec<&str> = rel.split('/').collect();
899        if segs.len() <= depth {
900            by_path.push(Entry {
901                path: row.path.clone(),
902                doc_id: Some(row.doc_id.clone()),
903                dir: false,
904                docs: 1,
905                blocks: row.blocks,
906                ts: row.ts.clone(),
907            });
908            continue;
909        }
910        let dir = format!("{prefix}{}/", segs[..depth].join("/"));
911        match by_path.iter_mut().find(|e| e.path == dir) {
912            Some(cur) => {
913                cur.docs += 1;
914                cur.blocks += row.blocks;
915                if let Some(ts) = &row.ts {
916                    if cur.ts.as_ref().is_none_or(|c| ts > c) {
917                        cur.ts = Some(ts.clone());
918                    }
919                }
920            }
921            None => by_path.push(Entry {
922                path: dir,
923                doc_id: None,
924                dir: true,
925                docs: 1,
926                blocks: row.blocks,
927                ts: row.ts.clone(),
928            }),
929        }
930    }
931    by_path.sort_by(|a, b| a.path.cmp(&b.path));
932    if let Some(c) = cursor.filter(|c| !c.is_empty()) {
933        let after = decode_path_cursor(c, "docs_list/docs_tree", 1)?.remove(0);
934        by_path.retain(|e| reference_path(&e.path) > after);
935    }
936    let entries: Vec<(String, Json)> = by_path
937        .into_iter()
938        .map(|e| {
939            // §2 (5.0): a `doc` entry is `{ _id, _path, kind, docs, blocks,
940            // _updated_at }`, a `dir` the same without the `_id`.
941            let path = reference_path(&e.path);
942            let mut m = Map::new();
943            if let Some(id) = &e.doc_id {
944                m.insert("_id".to_owned(), json!(id));
945            }
946            m.insert("_path".to_owned(), json!(path));
947            m.insert("kind".to_owned(), json!(if e.dir { "dir" } else { "doc" }));
948            m.insert("docs".to_owned(), json!(e.docs));
949            m.insert("blocks".to_owned(), json!(e.blocks));
950            m.insert("_updated_at".to_owned(), json!(e.ts));
951            (path, Json::Object(m))
952        })
953        .collect();
954    let (items, truncated, cursor) = page_path_ordered(entries, limit, budget_tokens, false);
955    Ok(json!({
956        "prefix": reference_path(&prefix),
957        "depth": depth,
958        "total": { "docs": total_docs, "blocks": total_blocks },
959        "entries": items,
960        "truncated": truncated,
961        "cursor": cursor,
962    }))
963}
964
965#[cfg(test)]
966mod tests {
967    use super::*;
968
969    #[test]
970    fn words_and_prefixes() {
971        assert_eq!(first_words("a  b\tc", 10), "a b c");
972        assert_eq!(first_words("a b c", 2), "a b…");
973        assert_eq!(normalize_tree_prefix(None), "");
974        assert_eq!(normalize_tree_prefix(Some("/projects//")), "projects/");
975        assert_eq!(normalize_tree_prefix(Some("a/b")), "a/b/");
976        assert!(is_node_id("n_0123456789ab"));
977        assert!(!is_node_id("n_0123456789AB"));
978        assert!(!is_node_id("b_0123456789ab"));
979    }
980
981    #[test]
982    fn token_cost_is_ceil_quarter_of_json_length() {
983        assert_eq!(token_cost(&json!("ab")), 1); // "ab" → 4 chars
984        assert_eq!(token_cost(&json!("abc")), 2); // 5 chars
985    }
986}