use core::fmt::Write as _;
use crate::catalog::{AnnSpec, Catalog, FieldSpec, IndexKind, IndexSpec, ValType, ValueSpec};
use crate::composite::CompositeCol;
impl Catalog {
pub fn to_sidecar(&self) -> String {
let needs_v6 = self.specs.iter().any(|(s, _)| s.composite.is_some());
let needs_v5 = self.specs.iter().any(|(s, _)| {
matches!(s.kind, IndexKind::Range | IndexKind::Unique) && !s.values.is_empty()
});
let header = if needs_v6 {
"kevy-index-catalog v6\n"
} else if needs_v5 {
"kevy-index-catalog v5\n"
} else {
"kevy-index-catalog v4\n"
};
let mut out = String::from(header);
for (s, _) in &self.specs {
let _ = write!(
out,
"{}\t{}\t{}\t{}\t{}\t{}",
esc(&s.name),
esc(&s.prefix),
fields_to_col(&s.fields),
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));
} else if let Some(cols) = &s.composite {
let _ = write!(out, "\t{}", composite_col_text(cols));
} else if let Some(col) = values_col(s) {
let _ = write!(out, "\t{col}");
}
out.push('\n');
}
out
}
pub fn from_sidecar(text: &str) -> Option<Catalog> {
let mut lines = text.lines();
let version: u8 = match lines.next()? {
"kevy-index-catalog v6" => 6,
"kevy-index-catalog v5" => 5,
"kevy-index-catalog v4" => 4,
"kevy-index-catalog v3" => 3,
"kevy-index-catalog v2" => 2,
"kevy-index-catalog v1" => 1,
_ => return None,
};
let mut c = Catalog::new();
for line in lines {
if line.is_empty() {
continue;
}
c.create(spec_from_line(line, version)?).ok()?;
}
Some(c)
}}
fn spec_from_line(line: &str, version: u8) -> 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, with_positions) = if parts.len() == 7 {
match kind {
IndexKind::Ann => (Some(ann_col(parts[6])?), None, false),
IndexKind::Agg => (None, Some(unesc(parts[6])?), false),
IndexKind::Text if version >= 3 => return text_spec(&parts, version),
IndexKind::Range if version >= 6 && parts[6].starts_with("comp,") => {
return composite_spec(&parts, version);
}
IndexKind::Range | IndexKind::Unique if version >= 5 => {
return scalar_spec(&parts, version);
}
_ => return None,
}
} else {
(None, None, false)
};
Some(IndexSpec {
name: unesc(parts[0])?,
prefix: unesc(parts[1])?,
fields: col_to_fields(parts[2], version >= 2)?,
ty: ValType::parse(parts[3].as_bytes())?,
kind,
max_bytes: parts[5].parse().ok()?,
ann,
group_by,
with_positions,
values: Vec::new(),
composite: None,
})
}
fn composite_col_text(cols: &[CompositeCol]) -> String {
let mut out = String::from("comp");
for c in cols {
let _ = write!(
out,
",{}:{}:{}",
esc_field(&c.name),
c.ty.tag(),
if c.desc { 'd' } else { 'a' }
);
}
out
}
fn composite_spec(parts: &[&str], version: u8) -> Option<IndexSpec> {
let cols = parts[6]
.split(',')
.skip(1)
.map(|e| {
let segs: Vec<&str> = e.split(':').collect();
if segs.len() != 3 {
return None;
}
let desc = match segs[2] {
"a" => false,
"d" => true,
_ => return None,
};
Some(CompositeCol { name: unesc(segs[0])?, ty: ValType::parse(segs[1].as_bytes())?, desc })
})
.collect::<Option<Vec<_>>>()?;
if cols.is_empty() {
return None;
}
Some(IndexSpec {
name: unesc(parts[0])?,
prefix: unesc(parts[1])?,
fields: col_to_fields(parts[2], version >= 2)?,
ty: ValType::parse(parts[3].as_bytes())?,
kind: IndexKind::Range,
max_bytes: parts[5].parse().ok()?,
ann: None,
group_by: None,
with_positions: false,
values: Vec::new(),
composite: Some(cols),
})
}
fn ann_col(col: &str) -> Option<AnnSpec> {
let nums: Vec<&str> = col.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()?,
})
}
fn text_spec(parts: &[&str], version: u8) -> Option<IndexSpec> {
let (with_positions, values) = parse_text_col(parts[6])?;
Some(IndexSpec {
name: unesc(parts[0])?,
prefix: unesc(parts[1])?,
fields: col_to_fields(parts[2], version >= 2)?,
ty: ValType::parse(parts[3].as_bytes())?,
kind: IndexKind::Text,
max_bytes: parts[5].parse().ok()?,
ann: None,
group_by: None,
with_positions,
values,
composite: None,
})
}
fn values_col(s: &IndexSpec) -> Option<String> {
if !s.with_positions && s.values.is_empty() {
return None;
}
let head = if s.with_positions { "pos" } else { "-" };
let mut col = String::from(head);
for v in &s.values {
let _ = write!(col, ",{}:{}", esc_field(&v.name), v.ty.tag());
}
Some(col)
}
fn scalar_spec(parts: &[&str], version: u8) -> Option<IndexSpec> {
let (with_positions, values) = parse_text_col(parts[6])?;
if with_positions || values.is_empty() {
return None;
}
Some(IndexSpec {
name: unesc(parts[0])?,
prefix: unesc(parts[1])?,
fields: col_to_fields(parts[2], version >= 2)?,
ty: ValType::parse(parts[3].as_bytes())?,
kind: IndexKind::parse(parts[4].as_bytes())?,
max_bytes: parts[5].parse().ok()?,
ann: None,
group_by: None,
with_positions: false,
values,
composite: None,
})
}
fn parse_text_col(col: &str) -> Option<(bool, Vec<ValueSpec>)> {
let mut parts = col.split(',');
let pos = match parts.next()? {
"pos" => true,
"-" => false,
_ => return None,
};
let values = parts
.map(|p| {
let (name, ty) = p.rsplit_once(':')?;
Some(ValueSpec { name: unesc(name)?, ty: ValType::parse(ty.as_bytes())? })
})
.collect::<Option<Vec<_>>>()?;
Some((pos, values))
}
fn esc_field(b: &[u8]) -> String {
let mut out = String::with_capacity(b.len());
for &c in b {
if c == b',' || c == b':' {
let _ = write!(out, "%{c:02X}");
} else {
out.push_str(&esc(&[c]));
}
}
out
}
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)
}
fn fields_to_col(fields: &[FieldSpec]) -> String {
fields
.iter()
.map(|f| format!("{}:{}", esc_field(&f.name), f.weight))
.collect::<Vec<_>>()
.join(",")
}
fn col_to_fields(col: &str, weighted: bool) -> Option<Vec<FieldSpec>> {
if !weighted {
return Some(vec![FieldSpec::new(unesc(col)?)]);
}
let mut out = Vec::new();
for part in col.split(',') {
let (name, weight) = part.rsplit_once(':')?;
out.push(FieldSpec { name: unesc(name)?, weight: weight.parse().ok()? });
}
if out.is_empty() { None } else { Some(out) }
}
#[cfg(test)]
#[path = "catalog_sidecar_tests.rs"]
mod tests;