1use crate::value::IndexValue;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum ValType {
9 Vector,
12 I64,
14 F64,
16 Str,
18}
19
20impl ValType {
21 pub fn tag(self) -> &'static str {
23 match self {
24 ValType::I64 => "i64",
25 ValType::F64 => "f64",
26 ValType::Str => "str",
27 ValType::Vector => "vector",
28 }
29 }
30
31 pub fn parse(raw: &[u8]) -> Option<ValType> {
33 if raw.eq_ignore_ascii_case(b"i64") {
34 Some(ValType::I64)
35 } else if raw.eq_ignore_ascii_case(b"f64") {
36 Some(ValType::F64)
37 } else if raw.eq_ignore_ascii_case(b"str") {
38 Some(ValType::Str)
39 } else if raw.eq_ignore_ascii_case(b"vector") {
40 Some(ValType::Vector)
41 } else {
42 None
43 }
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum IndexKind {
50 Range,
52 Unique,
55 Text,
58 Ann,
61 Agg,
64}
65
66impl IndexKind {
67 pub fn tag(self) -> &'static str {
69 match self {
70 IndexKind::Range => "range",
71 IndexKind::Unique => "unique",
72 IndexKind::Text => "text",
73 IndexKind::Ann => "ann",
74 IndexKind::Agg => "agg",
75 }
76 }
77
78 pub fn parse(raw: &[u8]) -> Option<IndexKind> {
80 if raw.eq_ignore_ascii_case(b"range") {
81 Some(IndexKind::Range)
82 } else if raw.eq_ignore_ascii_case(b"unique") {
83 Some(IndexKind::Unique)
84 } else if raw.eq_ignore_ascii_case(b"text") {
85 Some(IndexKind::Text)
86 } else if raw.eq_ignore_ascii_case(b"ann") {
87 Some(IndexKind::Ann)
88 } else if raw.eq_ignore_ascii_case(b"agg") {
89 Some(IndexKind::Agg)
90 } else {
91 None
92 }
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum IndexState {
99 Building,
101 Ready,
103 FailedOverBudget,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct IndexSpec {
110 pub name: Vec<u8>,
112 pub prefix: Vec<u8>,
114 pub field: Vec<u8>,
116 pub ty: ValType,
118 pub kind: IndexKind,
120 pub max_bytes: u64,
122 pub ann: Option<AnnSpec>,
124 pub group_by: Option<Vec<u8>>,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub struct AnnSpec {
131 pub dim: u32,
133 pub distance: u8,
135 pub m: u16,
137 pub ef: u16,
139}
140
141pub const MAX_INDEXES: usize = 64;
143
144#[derive(Debug, Clone, Default)]
147pub struct Catalog {
148 specs: Vec<(IndexSpec, IndexState)>,
149}
150
151impl Catalog {
152 pub fn new() -> Self {
154 Self::default()
155 }
156
157 pub fn create(&mut self, spec: IndexSpec) -> Result<(), &'static str> {
159 if self.specs.len() >= MAX_INDEXES {
160 return Err("ERR index limit reached (64)");
161 }
162 if self.specs.iter().any(|(s, _)| s.name == spec.name) {
163 return Err("ERR index already exists");
164 }
165 self.specs.push((spec, IndexState::Building));
166 Ok(())
167 }
168
169 pub fn drop_index(&mut self, name: &[u8]) -> bool {
171 let before = self.specs.len();
172 self.specs.retain(|(s, _)| s.name != name);
173 self.specs.len() != before
174 }
175
176 pub fn set_state(&mut self, name: &[u8], state: IndexState) -> bool {
178 for (s, st) in &mut self.specs {
179 if s.name == name {
180 *st = state;
181 return true;
182 }
183 }
184 false
185 }
186
187 pub fn get(&self, name: &[u8]) -> Option<(&IndexSpec, IndexState)> {
189 self.specs.iter().find(|(s, _)| s.name == name).map(|(s, st)| (s, *st))
190 }
191
192 pub fn iter(&self) -> impl Iterator<Item = (&IndexSpec, IndexState)> {
194 self.specs.iter().map(|(s, st)| (s, *st))
195 }
196
197 pub fn len(&self) -> usize {
199 self.specs.len()
200 }
201
202 pub fn is_empty(&self) -> bool {
204 self.specs.is_empty()
205 }
206
207 pub fn matching<'a>(&'a self, key: &'a [u8]) -> impl Iterator<Item = (&'a IndexSpec, IndexState)> {
212 self.specs
213 .iter()
214 .filter(move |(s, _)| key.starts_with(&s.prefix))
215 .map(|(s, st)| (s, *st))
216 }
217
218 pub fn coerce(spec: &IndexSpec, raw: &[u8]) -> Option<IndexValue> {
220 IndexValue::coerce(spec.ty, raw)
221 }
222
223 pub fn to_sidecar(&self) -> String {
228 let mut out = String::from("kevy-index-catalog v1\n");
229 for (s, _) in &self.specs {
230 out.push_str(&format!(
231 "{}\t{}\t{}\t{}\t{}\t{}",
232 esc(&s.name),
233 esc(&s.prefix),
234 esc(&s.field),
235 s.ty.tag(),
236 s.kind.tag(),
237 s.max_bytes
238 ));
239 if let Some(a) = &s.ann {
242 out.push_str(&format!("\t{},{},{},{}", a.dim, a.distance, a.m, a.ef));
243 } else if let Some(g) = &s.group_by {
244 out.push_str(&format!("\t{}", esc(g)));
245 }
246 out.push('\n');
247 }
248 out
249 }
250
251 pub fn from_sidecar(text: &str) -> Option<Catalog> {
254 let mut lines = text.lines();
255 if lines.next()? != "kevy-index-catalog v1" {
256 return None;
257 }
258 let mut c = Catalog::new();
259 for line in lines {
260 if line.is_empty() {
261 continue;
262 }
263 let parts: Vec<&str> = line.split('\t').collect();
264 if !(parts.len() == 6 || parts.len() == 7) {
265 return None;
266 }
267 let kind = IndexKind::parse(parts[4].as_bytes())?;
268 let (ann, group_by) = if parts.len() == 7 {
269 match kind {
270 IndexKind::Ann => {
271 let nums: Vec<&str> = parts[6].split(',').collect();
272 if nums.len() != 4 {
273 return None;
274 }
275 (
276 Some(AnnSpec {
277 dim: nums[0].parse().ok()?,
278 distance: nums[1].parse().ok()?,
279 m: nums[2].parse().ok()?,
280 ef: nums[3].parse().ok()?,
281 }),
282 None,
283 )
284 }
285 IndexKind::Agg => (None, Some(unesc(parts[6])?)),
286 _ => return None,
287 }
288 } else {
289 (None, None)
290 };
291 let spec = IndexSpec {
292 name: unesc(parts[0])?,
293 prefix: unesc(parts[1])?,
294 field: unesc(parts[2])?,
295 ty: ValType::parse(parts[3].as_bytes())?,
296 kind,
297 max_bytes: parts[5].parse().ok()?,
298 ann,
299 group_by,
300 };
301 c.create(spec).ok()?;
302 }
303 Some(c)
304 }
305}
306
307fn esc(b: &[u8]) -> String {
308 let mut out = String::with_capacity(b.len());
309 for &c in b {
310 if c == b'\t' || c == b'\n' || c == b'%' || !(32..127).contains(&c) {
311 out.push_str(&format!("%{c:02X}"));
312 } else {
313 out.push(c as char);
314 }
315 }
316 out
317}
318
319fn unesc(s: &str) -> Option<Vec<u8>> {
320 let mut out = Vec::with_capacity(s.len());
321 let bytes = s.as_bytes();
322 let mut i = 0;
323 while i < bytes.len() {
324 if bytes[i] == b'%' {
325 let hex = s.get(i + 1..i + 3)?;
326 out.push(u8::from_str_radix(hex, 16).ok()?);
327 i += 3;
328 } else {
329 out.push(bytes[i]);
330 i += 1;
331 }
332 }
333 Some(out)
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 fn spec(name: &str, prefix: &str) -> IndexSpec {
341 IndexSpec {
342 name: name.into(),
343 prefix: prefix.into(),
344 field: b"age".to_vec(),
345 ty: ValType::I64,
346 kind: IndexKind::Range,
347 ann: None,
348 max_bytes: 0,
349 group_by: None,
350 }
351 }
352
353 #[test]
354 fn create_drop_match_lifecycle() {
355 let mut c = Catalog::new();
356 c.create(spec("a", "user:")).unwrap();
357 c.create(spec("b", "sess:")).unwrap();
358 assert!(c.create(spec("a", "x:")).is_err(), "dup name");
359 assert_eq!(c.matching(b"user:42").count(), 1);
360 assert_eq!(c.matching(b"other:1").count(), 0);
361 assert_eq!(c.get(b"a").unwrap().1, IndexState::Building);
362 assert!(c.set_state(b"a", IndexState::Ready));
363 assert_eq!(c.get(b"a").unwrap().1, IndexState::Ready);
364 assert!(c.drop_index(b"b"));
365 assert!(!c.drop_index(b"b"));
366 assert_eq!(c.len(), 1);
367 }
368
369 #[test]
370 fn sidecar_roundtrip_with_escapes() {
371 let mut c = Catalog::new();
372 let mut s = spec("weird", "pre\tfix:");
373 s.field = b"f%\n".to_vec();
374 s.max_bytes = 1024;
375 c.create(s).unwrap();
376 let text = c.to_sidecar();
377 let c2 = Catalog::from_sidecar(&text).unwrap();
378 let (got, st) = c2.get(b"weird").unwrap();
379 assert_eq!(got.prefix, b"pre\tfix:".to_vec());
380 assert_eq!(got.field, b"f%\n".to_vec());
381 assert_eq!(got.max_bytes, 1024);
382 assert_eq!(st, IndexState::Building, "boot loads as Building");
383 assert!(Catalog::from_sidecar("bogus").is_none());
384 }
385
386 #[test]
387 fn cap_enforced() {
388 let mut c = Catalog::new();
389 for i in 0..MAX_INDEXES {
390 c.create(spec(&format!("i{i}"), "p:")).unwrap();
391 }
392 assert!(c.create(spec("over", "p:")).is_err());
393 }
394}