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_path_cursor, encode_cursor};
16use crate::error::Result;
17use crate::paths::{reference_path, storage_path};
18
19pub const MANY_CAP: usize = 100;
21pub const LIST_DEFAULT_LIMIT: usize = 200;
23
24#[must_use]
26pub fn token_cost(v: &Json) -> usize {
27 v.to_string().chars().count().div_ceil(4)
28}
29
30#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct DocInfo {
35 pub doc_id: String,
36 pub repo_id: String,
37 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
60pub 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
69pub 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
78pub 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
87fn id_prefix(r: &str) -> Option<&str> {
91 let (p, _) = r.split_once('_')?;
92 is_id_ref(r, p).then_some(p)
93}
94
95#[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
119pub 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#[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
190pub 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 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
282pub 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
290fn 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
316pub 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
366pub 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 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
408pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
434pub enum Resolution {
435 Skeleton,
436 Outline,
437 Text,
438 Raw,
439 Full,
440}
441
442impl Resolution {
443 #[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#[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 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
527pub 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
542pub 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 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
602fn 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
645pub 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#[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 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
729fn 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 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
774fn 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
808pub 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 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 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#[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
860pub 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 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 && cur.ts.as_ref().is_none_or(|c| ts > c)
917 {
918 cur.ts = Some(ts.clone());
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 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); assert_eq!(token_cost(&json!("abc")), 2); }
986}