Skip to main content

kbolt_types/
document.rs

1use serde::{Deserialize, Serialize};
2
3use crate::{KboltError, Result};
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6pub enum Locator {
7    Path(String),
8    DocId(String),
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub enum ReadLocator {
13    Document(Locator),
14    Chunk(ChunkLocator),
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct ChunkLocator {
19    pub docid: String,
20    pub chunk_ordinal: usize,
21}
22
23impl Locator {
24    pub fn parse(raw: &str) -> Self {
25        let trimmed = raw.trim();
26        if trimmed.contains('/') {
27            return Self::Path(trimmed.to_string());
28        }
29
30        Self::DocId(trimmed.trim_start_matches('#').to_string())
31    }
32}
33
34impl ChunkLocator {
35    pub fn parse(raw: &str) -> Result<Option<Self>> {
36        let trimmed = raw.trim();
37        if trimmed.contains('/') {
38            return Ok(None);
39        }
40
41        let Some((docid, ordinal)) = trimmed.trim_start_matches('#').split_once('@') else {
42            return Ok(None);
43        };
44        let docid = docid.trim();
45        if docid.is_empty() {
46            return Err(KboltError::InvalidInput(
47                "chunk locator docid cannot be empty".to_string(),
48            ));
49        }
50        let chunk_ordinal = ordinal.trim().parse::<usize>().map_err(|_| {
51            KboltError::InvalidInput(
52                "chunk locator must be '#docid@N' with a numeric chunk ordinal".to_string(),
53            )
54        })?;
55        if chunk_ordinal == 0 {
56            return Err(KboltError::InvalidInput(
57                "chunk locator ordinal must be 1 or greater".to_string(),
58            ));
59        }
60
61        Ok(Some(Self {
62            docid: docid.to_string(),
63            chunk_ordinal,
64        }))
65    }
66}
67
68impl ReadLocator {
69    pub fn parse(raw: &str) -> Result<Self> {
70        if let Some(locator) = ChunkLocator::parse(raw)? {
71            return Ok(Self::Chunk(locator));
72        }
73
74        Ok(Self::Document(Locator::parse(raw)))
75    }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79pub struct GetRequest {
80    pub locator: Locator,
81    pub space: Option<String>,
82    pub offset: Option<usize>,
83    pub limit: Option<usize>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
87pub struct GetChunkRequest {
88    pub locator: ChunkLocator,
89    pub space: Option<String>,
90    pub offset: Option<usize>,
91    pub limit: Option<usize>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct DocumentResponse {
96    pub docid: String,
97    pub path: String,
98    pub title: String,
99    pub space: String,
100    pub collection: String,
101    pub content: String,
102    pub stale: bool,
103    pub total_lines: usize,
104    pub returned_lines: usize,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
108pub struct ChunkResponse {
109    pub locator: String,
110    pub docid: String,
111    pub chunk_ordinal: usize,
112    pub path: String,
113    pub title: String,
114    pub space: String,
115    pub collection: String,
116    pub heading: Option<String>,
117    pub content: String,
118    pub stale: bool,
119    pub total_lines: usize,
120    pub returned_lines: usize,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
124pub struct MultiGetRequest {
125    pub locators: Vec<ReadLocator>,
126    pub space: Option<String>,
127    pub max_files: usize,
128    pub max_bytes: usize,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
132#[serde(tag = "kind", rename_all = "snake_case")]
133pub enum MultiGetItem {
134    Document(DocumentResponse),
135    Chunk(ChunkResponse),
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139#[serde(rename_all = "snake_case")]
140pub enum MultiGetItemKind {
141    Document,
142    Chunk,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
146pub struct MultiGetResponse {
147    pub items: Vec<MultiGetItem>,
148    pub omitted: Vec<OmittedItem>,
149    pub resolved_count: usize,
150    pub warnings: Vec<String>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154pub struct OmittedItem {
155    pub kind: MultiGetItemKind,
156    pub locator: String,
157    pub path: String,
158    pub docid: String,
159    pub space: String,
160    pub size_bytes: usize,
161    pub reason: OmitReason,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165pub enum OmitReason {
166    MaxFiles,
167    MaxBytes,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
171pub struct FileEntry {
172    pub path: String,
173    pub title: String,
174    pub docid: String,
175    pub active: bool,
176    pub chunk_count: usize,
177    pub embedded: bool,
178}
179
180#[cfg(test)]
181mod tests {
182    use super::{ChunkLocator, Locator, ReadLocator};
183
184    #[test]
185    fn parse_preserves_relative_paths() {
186        assert_eq!(
187            Locator::parse(" api/src/lib.rs "),
188            Locator::Path("api/src/lib.rs".to_string())
189        );
190    }
191
192    #[test]
193    fn parse_normalizes_docids() {
194        assert_eq!(
195            Locator::parse(" #abc123 "),
196            Locator::DocId("abc123".to_string())
197        );
198    }
199
200    #[test]
201    fn document_parse_keeps_chunk_locator_document_only() {
202        assert_eq!(
203            Locator::parse(" #abc123@4 "),
204            Locator::DocId("abc123@4".to_string())
205        );
206    }
207
208    #[test]
209    fn parse_doc_chunk_locator() {
210        assert_eq!(
211            ChunkLocator::parse(" #abc123@4 ").expect("parse chunk locator"),
212            Some(ChunkLocator {
213                docid: "abc123".to_string(),
214                chunk_ordinal: 4,
215            })
216        );
217    }
218
219    #[test]
220    fn parse_rejects_invalid_chunk_ordinal() {
221        let zero = ChunkLocator::parse("#abc123@0").expect_err("zero ordinal should fail");
222        assert!(zero.to_string().contains("1 or greater"));
223
224        let non_numeric = ChunkLocator::parse("#abc123@x").expect_err("text ordinal should fail");
225        assert!(non_numeric.to_string().contains("numeric chunk ordinal"));
226    }
227
228    #[test]
229    fn read_locator_parse_accepts_documents_and_chunks() {
230        assert_eq!(
231            ReadLocator::parse("api/src/lib.rs").expect("parse path locator"),
232            ReadLocator::Document(Locator::Path("api/src/lib.rs".to_string()))
233        );
234        assert_eq!(
235            ReadLocator::parse("#abc123").expect("parse docid locator"),
236            ReadLocator::Document(Locator::DocId("abc123".to_string()))
237        );
238        assert_eq!(
239            ReadLocator::parse("#abc123@4").expect("parse chunk locator"),
240            ReadLocator::Chunk(ChunkLocator {
241                docid: "abc123".to_string(),
242                chunk_ordinal: 4,
243            })
244        );
245    }
246}