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}