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}
62
63impl IndexKind {
64 pub fn tag(self) -> &'static str {
66 match self {
67 IndexKind::Range => "range",
68 IndexKind::Unique => "unique",
69 IndexKind::Text => "text",
70 IndexKind::Ann => "ann",
71 }
72 }
73
74 pub fn parse(raw: &[u8]) -> Option<IndexKind> {
76 if raw.eq_ignore_ascii_case(b"range") {
77 Some(IndexKind::Range)
78 } else if raw.eq_ignore_ascii_case(b"unique") {
79 Some(IndexKind::Unique)
80 } else if raw.eq_ignore_ascii_case(b"text") {
81 Some(IndexKind::Text)
82 } else if raw.eq_ignore_ascii_case(b"ann") {
83 Some(IndexKind::Ann)
84 } else {
85 None
86 }
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum IndexState {
93 Building,
95 Ready,
97 FailedOverBudget,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct IndexSpec {
104 pub name: Vec<u8>,
106 pub prefix: Vec<u8>,
108 pub field: Vec<u8>,
110 pub ty: ValType,
112 pub kind: IndexKind,
114 pub max_bytes: u64,
116 pub ann: Option<AnnSpec>,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct AnnSpec {
123 pub dim: u32,
125 pub distance: u8,
127 pub m: u16,
129 pub ef: u16,
131}
132
133pub const MAX_INDEXES: usize = 64;
135
136#[derive(Debug, Clone, Default)]
139pub struct Catalog {
140 specs: Vec<(IndexSpec, IndexState)>,
141}
142
143impl Catalog {
144 pub fn new() -> Self {
146 Self::default()
147 }
148
149 pub fn create(&mut self, spec: IndexSpec) -> Result<(), &'static str> {
151 if self.specs.len() >= MAX_INDEXES {
152 return Err("ERR index limit reached (64)");
153 }
154 if self.specs.iter().any(|(s, _)| s.name == spec.name) {
155 return Err("ERR index already exists");
156 }
157 self.specs.push((spec, IndexState::Building));
158 Ok(())
159 }
160
161 pub fn drop_index(&mut self, name: &[u8]) -> bool {
163 let before = self.specs.len();
164 self.specs.retain(|(s, _)| s.name != name);
165 self.specs.len() != before
166 }
167
168 pub fn set_state(&mut self, name: &[u8], state: IndexState) -> bool {
170 for (s, st) in &mut self.specs {
171 if s.name == name {
172 *st = state;
173 return true;
174 }
175 }
176 false
177 }
178
179 pub fn get(&self, name: &[u8]) -> Option<(&IndexSpec, IndexState)> {
181 self.specs.iter().find(|(s, _)| s.name == name).map(|(s, st)| (s, *st))
182 }
183
184 pub fn iter(&self) -> impl Iterator<Item = (&IndexSpec, IndexState)> {
186 self.specs.iter().map(|(s, st)| (s, *st))
187 }
188
189 pub fn len(&self) -> usize {
191 self.specs.len()
192 }
193
194 pub fn is_empty(&self) -> bool {
196 self.specs.is_empty()
197 }
198
199 pub fn matching<'a>(&'a self, key: &'a [u8]) -> impl Iterator<Item = (&'a IndexSpec, IndexState)> {
204 self.specs
205 .iter()
206 .filter(move |(s, _)| key.starts_with(&s.prefix))
207 .map(|(s, st)| (s, *st))
208 }
209
210 pub fn coerce(spec: &IndexSpec, raw: &[u8]) -> Option<IndexValue> {
212 IndexValue::coerce(spec.ty, raw)
213 }
214
215 pub fn to_sidecar(&self) -> String {
220 let mut out = String::from("kevy-index-catalog v1\n");
221 for (s, _) in &self.specs {
222 out.push_str(&format!(
223 "{}\t{}\t{}\t{}\t{}\t{}",
224 esc(&s.name),
225 esc(&s.prefix),
226 esc(&s.field),
227 s.ty.tag(),
228 s.kind.tag(),
229 s.max_bytes
230 ));
231 if let Some(a) = &s.ann {
232 out.push_str(&format!("\t{},{},{},{}", a.dim, a.distance, a.m, a.ef));
233 }
234 out.push('\n');
235 }
236 out
237 }
238
239 pub fn from_sidecar(text: &str) -> Option<Catalog> {
242 let mut lines = text.lines();
243 if lines.next()? != "kevy-index-catalog v1" {
244 return None;
245 }
246 let mut c = Catalog::new();
247 for line in lines {
248 if line.is_empty() {
249 continue;
250 }
251 let parts: Vec<&str> = line.split('\t').collect();
252 if !(parts.len() == 6 || parts.len() == 7) {
253 return None;
254 }
255 let ann = if parts.len() == 7 {
256 let nums: Vec<&str> = parts[6].split(',').collect();
257 if nums.len() != 4 {
258 return None;
259 }
260 Some(AnnSpec {
261 dim: nums[0].parse().ok()?,
262 distance: nums[1].parse().ok()?,
263 m: nums[2].parse().ok()?,
264 ef: nums[3].parse().ok()?,
265 })
266 } else {
267 None
268 };
269 let spec = IndexSpec {
270 name: unesc(parts[0])?,
271 prefix: unesc(parts[1])?,
272 field: unesc(parts[2])?,
273 ty: ValType::parse(parts[3].as_bytes())?,
274 kind: IndexKind::parse(parts[4].as_bytes())?,
275 max_bytes: parts[5].parse().ok()?,
276 ann,
277 };
278 c.create(spec).ok()?;
279 }
280 Some(c)
281 }
282}
283
284fn esc(b: &[u8]) -> String {
285 let mut out = String::with_capacity(b.len());
286 for &c in b {
287 if c == b'\t' || c == b'\n' || c == b'%' || !(32..127).contains(&c) {
288 out.push_str(&format!("%{c:02X}"));
289 } else {
290 out.push(c as char);
291 }
292 }
293 out
294}
295
296fn unesc(s: &str) -> Option<Vec<u8>> {
297 let mut out = Vec::with_capacity(s.len());
298 let bytes = s.as_bytes();
299 let mut i = 0;
300 while i < bytes.len() {
301 if bytes[i] == b'%' {
302 let hex = s.get(i + 1..i + 3)?;
303 out.push(u8::from_str_radix(hex, 16).ok()?);
304 i += 3;
305 } else {
306 out.push(bytes[i]);
307 i += 1;
308 }
309 }
310 Some(out)
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 fn spec(name: &str, prefix: &str) -> IndexSpec {
318 IndexSpec {
319 name: name.into(),
320 prefix: prefix.into(),
321 field: b"age".to_vec(),
322 ty: ValType::I64,
323 kind: IndexKind::Range,
324 ann: None,
325 max_bytes: 0,
326 }
327 }
328
329 #[test]
330 fn create_drop_match_lifecycle() {
331 let mut c = Catalog::new();
332 c.create(spec("a", "user:")).unwrap();
333 c.create(spec("b", "sess:")).unwrap();
334 assert!(c.create(spec("a", "x:")).is_err(), "dup name");
335 assert_eq!(c.matching(b"user:42").count(), 1);
336 assert_eq!(c.matching(b"other:1").count(), 0);
337 assert_eq!(c.get(b"a").unwrap().1, IndexState::Building);
338 assert!(c.set_state(b"a", IndexState::Ready));
339 assert_eq!(c.get(b"a").unwrap().1, IndexState::Ready);
340 assert!(c.drop_index(b"b"));
341 assert!(!c.drop_index(b"b"));
342 assert_eq!(c.len(), 1);
343 }
344
345 #[test]
346 fn sidecar_roundtrip_with_escapes() {
347 let mut c = Catalog::new();
348 let mut s = spec("weird", "pre\tfix:");
349 s.field = b"f%\n".to_vec();
350 s.max_bytes = 1024;
351 c.create(s).unwrap();
352 let text = c.to_sidecar();
353 let c2 = Catalog::from_sidecar(&text).unwrap();
354 let (got, st) = c2.get(b"weird").unwrap();
355 assert_eq!(got.prefix, b"pre\tfix:".to_vec());
356 assert_eq!(got.field, b"f%\n".to_vec());
357 assert_eq!(got.max_bytes, 1024);
358 assert_eq!(st, IndexState::Building, "boot loads as Building");
359 assert!(Catalog::from_sidecar("bogus").is_none());
360 }
361
362 #[test]
363 fn cap_enforced() {
364 let mut c = Catalog::new();
365 for i in 0..MAX_INDEXES {
366 c.create(spec(&format!("i{i}"), "p:")).unwrap();
367 }
368 assert!(c.create(spec("over", "p:")).is_err());
369 }
370}