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