1use 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
18pub const MANY_CAP: usize = 100;
20pub const LIST_DEFAULT_LIMIT: usize = 200;
22
23#[must_use]
25pub fn token_cost(v: &Json) -> usize {
26 v.to_string().chars().count().div_ceil(4)
27}
28
29#[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
57pub 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
66pub 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
75pub 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
84fn id_prefix(r: &str) -> Option<&str> {
88 let (p, _) = r.split_once('_')?;
89 is_id_ref(r, p).then_some(p)
90}
91
92#[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
116pub 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#[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
187pub 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 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
279pub 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
287pub 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
335pub 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 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
377pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
397pub enum Resolution {
398 Skeleton,
399 Outline,
400 Text,
401 Raw,
402 Full,
403}
404
405impl Resolution {
406 #[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#[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
487pub 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
502pub 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 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
562fn 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
605pub 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#[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
686fn 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 let mut stmt = conn.prepare(&sql)?;
718 let rows = stmt.query_map(rusqlite::params_from_iter(p.iter()), |r| {
719 Ok(DocListRow {
720 path: r.get(0)?,
721 blocks: r.get(1)?,
722 ts: r.get(2)?,
723 })
724 })?;
725 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
726}
727
728fn page_path_ordered(
731 rows: Vec<(String, Json)>,
732 limit: usize,
733 budget_tokens: Option<usize>,
734 more_beyond: bool,
735) -> (Vec<Json>, bool, Option<String>) {
736 let mut items: Vec<Json> = Vec::new();
737 let mut last_path: Option<String> = None;
738 let mut tokens = 0usize;
739 let mut truncated = more_beyond;
740 for (path, row) in rows {
741 if items.len() >= limit {
742 truncated = true;
743 break;
744 }
745 let cost = token_cost(&row);
746 if !items.is_empty() && budget_tokens.is_some_and(|b| tokens + cost > b) {
747 truncated = true;
748 break;
749 }
750 tokens += cost;
751 items.push(row);
752 last_path = Some(path);
753 }
754 let cursor = match (&truncated, last_path) {
755 (true, Some(p)) => Some(encode_cursor(&[&p])),
756 _ => None,
757 };
758 (items, truncated, cursor)
759}
760
761pub fn docs_list(
763 store: &Store,
764 repo_id: &str,
765 path_glob: Option<&str>,
766 limit: Option<i64>,
767 cursor: Option<&str>,
768 budget_tokens: Option<usize>,
769) -> Result<Json> {
770 let like = path_glob.map_or_else(|| "%".to_owned(), |g| glob_to_like(g, true));
771 let limit = usize::try_from(limit.unwrap_or(LIST_DEFAULT_LIMIT as i64).max(1)).unwrap_or(1);
772 let after = match cursor.filter(|c| !c.is_empty()) {
773 Some(c) => Some(decode_cursor(c, "docs_list/docs_tree", 1)?.remove(0)),
774 None => None,
775 };
776 let mut rows = live_doc_rows(
777 store.conn(),
778 repo_id,
779 &like,
780 after.as_deref(),
781 Some(limit + 1),
782 )?;
783 let more_beyond = rows.len() > limit;
784 rows.truncate(limit);
785 let (items, truncated, cursor) = page_path_ordered(
786 rows.into_iter()
787 .map(|r| (r.path.clone(), r.to_json()))
788 .collect(),
789 limit,
790 budget_tokens,
791 more_beyond,
792 );
793 Ok(json!({ "items": items, "truncated": truncated, "cursor": cursor }))
794}
795
796#[must_use]
798pub fn normalize_tree_prefix(path: Option<&str>) -> String {
799 let trimmed = path
800 .unwrap_or("")
801 .trim_start_matches('/')
802 .trim_end_matches('/');
803 if trimmed.is_empty() {
804 String::new()
805 } else {
806 format!("{trimmed}/")
807 }
808}
809
810pub fn docs_tree(
812 store: &Store,
813 repo_id: &str,
814 path: Option<&str>,
815 depth: Option<i64>,
816 limit: Option<i64>,
817 cursor: Option<&str>,
818 budget_tokens: Option<usize>,
819) -> Result<Json> {
820 let prefix = normalize_tree_prefix(path);
821 let depth = usize::try_from(depth.unwrap_or(1).max(1)).unwrap_or(1);
822 let limit = usize::try_from(limit.unwrap_or(LIST_DEFAULT_LIMIT as i64).max(1)).unwrap_or(1);
823 let like = if prefix.is_empty() {
824 "%".to_owned()
825 } else {
826 format!("{}%", glob_to_like(&prefix, true))
827 };
828 let rows = live_doc_rows(store.conn(), repo_id, &like, None, None)?;
829
830 struct Entry {
831 path: String,
832 dir: bool,
833 docs: i64,
834 blocks: i64,
835 ts: Option<String>,
836 }
837 let mut by_path: Vec<Entry> = Vec::new();
838 let (mut total_docs, mut total_blocks) = (0i64, 0i64);
839 for row in &rows {
840 total_docs += 1;
841 total_blocks += row.blocks;
842 let rel = &row.path[prefix.len().min(row.path.len())..];
843 let segs: Vec<&str> = rel.split('/').collect();
844 if segs.len() <= depth {
845 by_path.push(Entry {
846 path: row.path.clone(),
847 dir: false,
848 docs: 1,
849 blocks: row.blocks,
850 ts: row.ts.clone(),
851 });
852 continue;
853 }
854 let dir = format!("{prefix}{}/", segs[..depth].join("/"));
855 match by_path.iter_mut().find(|e| e.path == dir) {
856 Some(cur) => {
857 cur.docs += 1;
858 cur.blocks += row.blocks;
859 if let Some(ts) = &row.ts {
860 if cur.ts.as_ref().is_none_or(|c| ts > c) {
861 cur.ts = Some(ts.clone());
862 }
863 }
864 }
865 None => by_path.push(Entry {
866 path: dir,
867 dir: true,
868 docs: 1,
869 blocks: row.blocks,
870 ts: row.ts.clone(),
871 }),
872 }
873 }
874 by_path.sort_by(|a, b| a.path.cmp(&b.path));
875 if let Some(c) = cursor.filter(|c| !c.is_empty()) {
876 let after = decode_cursor(c, "docs_list/docs_tree", 1)?.remove(0);
877 by_path.retain(|e| e.path > after);
878 }
879 let entries: Vec<(String, Json)> = by_path
880 .into_iter()
881 .map(|e| {
882 (
883 e.path.clone(),
884 json!({
885 "path": e.path,
886 "kind": if e.dir { "dir" } else { "doc" },
887 "docs": e.docs,
888 "blocks": e.blocks,
889 "ts": e.ts,
890 }),
891 )
892 })
893 .collect();
894 let (items, truncated, cursor) = page_path_ordered(entries, limit, budget_tokens, false);
895 Ok(json!({
896 "prefix": prefix,
897 "depth": depth,
898 "total": { "docs": total_docs, "blocks": total_blocks },
899 "entries": items,
900 "truncated": truncated,
901 "cursor": cursor,
902 }))
903}
904
905#[cfg(test)]
906mod tests {
907 use super::*;
908
909 #[test]
910 fn words_and_prefixes() {
911 assert_eq!(first_words("a b\tc", 10), "a b c");
912 assert_eq!(first_words("a b c", 2), "a b…");
913 assert_eq!(normalize_tree_prefix(None), "");
914 assert_eq!(normalize_tree_prefix(Some("/projects//")), "projects/");
915 assert_eq!(normalize_tree_prefix(Some("a/b")), "a/b/");
916 assert!(is_node_id("n_0123456789ab"));
917 assert!(!is_node_id("n_0123456789AB"));
918 assert!(!is_node_id("b_0123456789ab"));
919 }
920
921 #[test]
922 fn token_cost_is_ceil_quarter_of_json_length() {
923 assert_eq!(token_cost(&json!("ab")), 1); assert_eq!(token_cost(&json!("abc")), 2); }
926}