use crate::value::IndexValue;
use std::fmt::Write as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValType {
Vector,
I64,
F64,
Str,
}
impl ValType {
pub fn tag(self) -> &'static str {
match self {
ValType::I64 => "i64",
ValType::F64 => "f64",
ValType::Str => "str",
ValType::Vector => "vector",
}
}
pub fn parse(raw: &[u8]) -> Option<ValType> {
if raw.eq_ignore_ascii_case(b"i64") {
Some(ValType::I64)
} else if raw.eq_ignore_ascii_case(b"f64") {
Some(ValType::F64)
} else if raw.eq_ignore_ascii_case(b"str") {
Some(ValType::Str)
} else if raw.eq_ignore_ascii_case(b"vector") {
Some(ValType::Vector)
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexKind {
Range,
Unique,
Text,
Ann,
Agg,
}
impl IndexKind {
pub fn tag(self) -> &'static str {
match self {
IndexKind::Range => "range",
IndexKind::Unique => "unique",
IndexKind::Text => "text",
IndexKind::Ann => "ann",
IndexKind::Agg => "agg",
}
}
pub fn parse(raw: &[u8]) -> Option<IndexKind> {
if raw.eq_ignore_ascii_case(b"range") {
Some(IndexKind::Range)
} else if raw.eq_ignore_ascii_case(b"unique") {
Some(IndexKind::Unique)
} else if raw.eq_ignore_ascii_case(b"text") {
Some(IndexKind::Text)
} else if raw.eq_ignore_ascii_case(b"ann") {
Some(IndexKind::Ann)
} else if raw.eq_ignore_ascii_case(b"agg") {
Some(IndexKind::Agg)
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexState {
Building,
Ready,
FailedOverBudget,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexSpec {
pub name: Vec<u8>,
pub prefix: Vec<u8>,
pub field: Vec<u8>,
pub ty: ValType,
pub kind: IndexKind,
pub max_bytes: u64,
pub ann: Option<AnnSpec>,
pub group_by: Option<Vec<u8>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AnnSpec {
pub dim: u32,
pub distance: u8,
pub m: u16,
pub ef: u16,
}
pub const MAX_INDEXES: usize = 64;
#[derive(Debug, Clone, Default)]
pub struct Catalog {
specs: Vec<(IndexSpec, IndexState)>,
}
impl Catalog {
pub fn new() -> Self {
Self::default()
}
pub fn create(&mut self, spec: IndexSpec) -> Result<(), &'static str> {
if self.specs.len() >= MAX_INDEXES {
return Err("ERR index limit reached (64)");
}
if self.specs.iter().any(|(s, _)| s.name == spec.name) {
return Err("ERR index already exists");
}
self.specs.push((spec, IndexState::Building));
Ok(())
}
pub fn drop_index(&mut self, name: &[u8]) -> bool {
let before = self.specs.len();
self.specs.retain(|(s, _)| s.name != name);
self.specs.len() != before
}
pub fn set_state(&mut self, name: &[u8], state: IndexState) -> bool {
for (s, st) in &mut self.specs {
if s.name == name {
*st = state;
return true;
}
}
false
}
pub fn get(&self, name: &[u8]) -> Option<(&IndexSpec, IndexState)> {
self.specs.iter().find(|(s, _)| s.name == name).map(|(s, st)| (s, *st))
}
pub fn iter(&self) -> impl Iterator<Item = (&IndexSpec, IndexState)> {
self.specs.iter().map(|(s, st)| (s, *st))
}
pub fn len(&self) -> usize {
self.specs.len()
}
pub fn is_empty(&self) -> bool {
self.specs.is_empty()
}
pub fn matching<'a>(&'a self, key: &'a [u8]) -> impl Iterator<Item = (&'a IndexSpec, IndexState)> {
self.specs
.iter()
.filter(move |(s, _)| key.starts_with(&s.prefix))
.map(|(s, st)| (s, *st))
}
pub fn coerce(spec: &IndexSpec, raw: &[u8]) -> Option<IndexValue> {
IndexValue::coerce(spec.ty, raw)
}
pub fn to_sidecar(&self) -> String {
let mut out = String::from("kevy-index-catalog v1\n");
for (s, _) in &self.specs {
let _ = write!(
out,
"{}\t{}\t{}\t{}\t{}\t{}",
esc(&s.name),
esc(&s.prefix),
esc(&s.field),
s.ty.tag(),
s.kind.tag(),
s.max_bytes
);
if let Some(a) = &s.ann {
let _ = write!(out, "\t{},{},{},{}", a.dim, a.distance, a.m, a.ef);
} else if let Some(g) = &s.group_by {
let _ = write!(out, "\t{}", esc(g));
}
out.push('\n');
}
out
}
pub fn from_sidecar(text: &str) -> Option<Catalog> {
let mut lines = text.lines();
if lines.next()? != "kevy-index-catalog v1" {
return None;
}
let mut c = Catalog::new();
for line in lines {
if line.is_empty() {
continue;
}
c.create(spec_from_line(line)?).ok()?;
}
Some(c)
}
}
fn spec_from_line(line: &str) -> Option<IndexSpec> {
let parts: Vec<&str> = line.split('\t').collect();
if !(parts.len() == 6 || parts.len() == 7) {
return None;
}
let kind = IndexKind::parse(parts[4].as_bytes())?;
let (ann, group_by) = if parts.len() == 7 {
match kind {
IndexKind::Ann => {
let nums: Vec<&str> = parts[6].split(',').collect();
if nums.len() != 4 {
return None;
}
(
Some(AnnSpec {
dim: nums[0].parse().ok()?,
distance: nums[1].parse().ok()?,
m: nums[2].parse().ok()?,
ef: nums[3].parse().ok()?,
}),
None,
)
}
IndexKind::Agg => (None, Some(unesc(parts[6])?)),
_ => return None,
}
} else {
(None, None)
};
Some(IndexSpec {
name: unesc(parts[0])?,
prefix: unesc(parts[1])?,
field: unesc(parts[2])?,
ty: ValType::parse(parts[3].as_bytes())?,
kind,
max_bytes: parts[5].parse().ok()?,
ann,
group_by,
})
}
fn esc(b: &[u8]) -> String {
let mut out = String::with_capacity(b.len());
for &c in b {
if c == b'\t' || c == b'\n' || c == b'%' || !(32..127).contains(&c) {
let _ = write!(out, "%{c:02X}");
} else {
out.push(c as char);
}
}
out
}
fn unesc(s: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hex = s.get(i + 1..i + 3)?;
out.push(u8::from_str_radix(hex, 16).ok()?);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(name: &str, prefix: &str) -> IndexSpec {
IndexSpec {
name: name.into(),
prefix: prefix.into(),
field: b"age".to_vec(),
ty: ValType::I64,
kind: IndexKind::Range,
ann: None,
max_bytes: 0,
group_by: None,
}
}
#[test]
fn create_drop_match_lifecycle() {
let mut c = Catalog::new();
c.create(spec("a", "user:")).unwrap();
c.create(spec("b", "sess:")).unwrap();
assert!(c.create(spec("a", "x:")).is_err(), "dup name");
assert_eq!(c.matching(b"user:42").count(), 1);
assert_eq!(c.matching(b"other:1").count(), 0);
assert_eq!(c.get(b"a").unwrap().1, IndexState::Building);
assert!(c.set_state(b"a", IndexState::Ready));
assert_eq!(c.get(b"a").unwrap().1, IndexState::Ready);
assert!(c.drop_index(b"b"));
assert!(!c.drop_index(b"b"));
assert_eq!(c.len(), 1);
}
#[test]
fn sidecar_roundtrip_with_escapes() {
let mut c = Catalog::new();
let mut s = spec("weird", "pre\tfix:");
s.field = b"f%\n".to_vec();
s.max_bytes = 1024;
c.create(s).unwrap();
let text = c.to_sidecar();
let c2 = Catalog::from_sidecar(&text).unwrap();
let (got, st) = c2.get(b"weird").unwrap();
assert_eq!(got.prefix, b"pre\tfix:".to_vec());
assert_eq!(got.field, b"f%\n".to_vec());
assert_eq!(got.max_bytes, 1024);
assert_eq!(st, IndexState::Building, "boot loads as Building");
assert!(Catalog::from_sidecar("bogus").is_none());
}
#[test]
fn cap_enforced() {
let mut c = Catalog::new();
for i in 0..MAX_INDEXES {
c.create(spec(&format!("i{i}"), "p:")).unwrap();
}
assert!(c.create(spec("over", "p:")).is_err());
}
}