use rusqlite::{params, Connection, OptionalExtension};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
const SCHEMA_VERSION: i64 = 1;
const FILE_NAME: &str = "index-v1.sqlite";
#[derive(Debug, thiserror::Error)]
pub enum IndexError {
#[error("semantic index database error: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("semantic index file error: {0}")]
Io(#[from] std::io::Error),
#[error("the semantic index location is not a private directory")]
UnsafeLocation,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StoredChunk {
pub start: usize,
pub end: usize,
pub vector: Vec<i8>,
}
pub struct SemanticIndex {
conn: Connection,
}
pub fn index_dir(minutes_dir: &Path) -> PathBuf {
minutes_dir.join("semantic")
}
pub fn quantize(vector: &[f32]) -> Vec<i8> {
vector
.iter()
.map(|v| (v * 127.0).round().clamp(-127.0, 127.0) as i8)
.collect()
}
impl SemanticIndex {
pub fn open(minutes_dir: &Path, model_id: &str, dimensions: usize) -> Result<Self, IndexError> {
let dir = index_dir(minutes_dir);
create_private_dir(&dir)?;
let path = dir.join(FILE_NAME);
if std::fs::symlink_metadata(&path).is_ok_and(|meta| !meta.file_type().is_file()) {
return Err(IndexError::UnsafeLocation);
}
let conn = Connection::open(&path)?;
set_owner_only(&path)?;
conn.execute_batch(
"PRAGMA journal_mode = DELETE;
PRAGMA secure_delete = ON;
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS documents (
path TEXT PRIMARY KEY,
content_sha256 BLOB NOT NULL,
title TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS chunks (
path TEXT NOT NULL,
chunk INTEGER NOT NULL,
start INTEGER NOT NULL,
end INTEGER NOT NULL,
vector BLOB NOT NULL,
PRIMARY KEY (path, chunk)
) WITHOUT ROWID;",
)?;
let index = Self { conn };
let identity = format!("{SCHEMA_VERSION}:{model_id}:{dimensions}");
if index.meta("identity")?.as_deref() != Some(identity.as_str()) {
index
.conn
.execute_batch("DELETE FROM chunks; DELETE FROM documents;")?;
index.conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('identity', ?1)",
params![identity],
)?;
}
Ok(index)
}
fn meta(&self, key: &str) -> Result<Option<String>, IndexError> {
Ok(self
.conn
.query_row(
"SELECT value FROM meta WHERE key = ?1",
params![key],
|row| row.get(0),
)
.optional()?)
}
pub fn indexed(&self) -> Result<HashMap<PathBuf, [u8; 32]>, IndexError> {
let mut statement = self
.conn
.prepare("SELECT path, content_sha256 FROM documents")?;
let rows = statement.query_map([], |row| {
let path: String = row.get(0)?;
let digest: Vec<u8> = row.get(1)?;
Ok((path, digest))
})?;
let mut indexed = HashMap::new();
for row in rows {
let (path, digest) = row?;
if let Ok(digest) = <[u8; 32]>::try_from(digest.as_slice()) {
indexed.insert(PathBuf::from(path), digest);
}
}
Ok(indexed)
}
pub fn counts(&self) -> Result<(usize, usize), IndexError> {
let count = |table: &str| -> Result<usize, IndexError> {
let n: i64 =
self.conn
.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| {
row.get(0)
})?;
Ok(n as usize)
};
Ok((count("documents")?, count("chunks")?))
}
pub fn upsert(
&mut self,
path: &Path,
content_sha256: &[u8; 32],
title: &str,
chunks: &[StoredChunk],
) -> Result<(), IndexError> {
let key = path.to_string_lossy();
let tx = self.conn.transaction()?;
tx.execute("DELETE FROM chunks WHERE path = ?1", params![key])?;
tx.execute(
"INSERT OR REPLACE INTO documents (path, content_sha256, title) VALUES (?1, ?2, ?3)",
params![key, content_sha256.as_slice(), title],
)?;
for (i, chunk) in chunks.iter().enumerate() {
let bytes: Vec<u8> = chunk.vector.iter().map(|v| *v as u8).collect();
tx.execute(
"INSERT INTO chunks (path, chunk, start, end, vector) VALUES (?1, ?2, ?3, ?4, ?5)",
params![key, i as i64, chunk.start as i64, chunk.end as i64, bytes],
)?;
}
tx.commit()?;
Ok(())
}
pub fn remove(&mut self, path: &Path) -> Result<(), IndexError> {
let key = path.to_string_lossy();
let tx = self.conn.transaction()?;
tx.execute("DELETE FROM chunks WHERE path = ?1", params![key])?;
tx.execute("DELETE FROM documents WHERE path = ?1", params![key])?;
tx.commit()?;
Ok(())
}
pub fn current_chunks(
&self,
current: impl Fn(&Path) -> Option<[u8; 32]>,
) -> Result<Vec<(PathBuf, String, StoredChunk)>, IndexError> {
let fresh: HashMap<PathBuf, ()> = self
.indexed()?
.into_iter()
.filter(|(path, digest)| current(path).as_ref() == Some(digest))
.map(|(path, _)| (path, ()))
.collect();
let mut statement = self.conn.prepare(
"SELECT c.path, d.title, c.start, c.end, c.vector
FROM chunks c JOIN documents d ON d.path = c.path
ORDER BY c.path, c.chunk",
)?;
let rows = statement.query_map([], |row| {
let path: String = row.get(0)?;
let title: String = row.get(1)?;
let start: i64 = row.get(2)?;
let end: i64 = row.get(3)?;
let vector: Vec<u8> = row.get(4)?;
Ok((path, title, start, end, vector))
})?;
let mut chunks = Vec::new();
for row in rows {
let (path, title, start, end, vector) = row?;
let path = PathBuf::from(path);
if fresh.contains_key(&path) {
chunks.push((
path,
title,
StoredChunk {
start: start as usize,
end: end as usize,
vector: vector.into_iter().map(|v| v as i8).collect(),
},
));
}
}
Ok(chunks)
}
}
pub fn clear(minutes_dir: &Path) -> Result<(), IndexError> {
let dir = index_dir(minutes_dir);
match std::fs::symlink_metadata(&dir) {
Ok(meta) if meta.file_type().is_symlink() => Err(IndexError::UnsafeLocation),
Ok(_) => Ok(std::fs::remove_dir_all(&dir)?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
fn create_private_dir(dir: &Path) -> Result<(), IndexError> {
match std::fs::symlink_metadata(dir) {
Ok(meta) if !meta.file_type().is_dir() => return Err(IndexError::UnsafeLocation),
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(dir)?;
}
Err(error) => return Err(error.into()),
}
set_owner_only_dir(dir)
}
#[cfg(unix)]
fn set_owner_only(path: &Path) -> Result<(), IndexError> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
Ok(())
}
#[cfg(unix)]
fn set_owner_only_dir(path: &Path) -> Result<(), IndexError> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
Ok(())
}
#[cfg(not(unix))]
fn set_owner_only(_: &Path) -> Result<(), IndexError> {
Ok(())
}
#[cfg(not(unix))]
fn set_owner_only_dir(_: &Path) -> Result<(), IndexError> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn chunk(start: usize, v: &[f32]) -> StoredChunk {
StoredChunk {
start,
end: start + 10,
vector: quantize(v),
}
}
#[test]
fn only_vectors_from_the_current_content_are_returned() {
let home = tempfile::tempdir().unwrap();
let mut index = SemanticIndex::open(home.path(), "m", 2).unwrap();
let (a, b) = (PathBuf::from("/m/a.md"), PathBuf::from("/m/b.md"));
index
.upsert(&a, &[1; 32], "A", &[chunk(0, &[1.0, 0.0])])
.unwrap();
index
.upsert(&b, &[2; 32], "B", &[chunk(0, &[0.0, 1.0])])
.unwrap();
let live = |path: &Path| match path.to_str() {
Some("/m/a.md") => Some([1; 32]),
Some("/m/b.md") => Some([9; 32]),
_ => None,
};
let chunks = index.current_chunks(live).unwrap();
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].0, a);
assert_eq!(chunks[0].2.vector, vec![127, 0]);
index.remove(&a).unwrap();
assert!(index.current_chunks(live).unwrap().is_empty());
}
#[test]
fn upsert_replaces_and_a_model_change_wipes() {
let home = tempfile::tempdir().unwrap();
let path = PathBuf::from("/m/a.md");
{
let mut index = SemanticIndex::open(home.path(), "m1", 2).unwrap();
index
.upsert(
&path,
&[1; 32],
"A",
&[chunk(0, &[1.0, 0.0]), chunk(5, &[0.0, 1.0])],
)
.unwrap();
index
.upsert(&path, &[1; 32], "A", &[chunk(0, &[1.0, 0.0])])
.unwrap();
assert_eq!(index.current_chunks(|_| Some([1; 32])).unwrap().len(), 1);
}
let reopened = SemanticIndex::open(home.path(), "m1", 2).unwrap();
assert_eq!(reopened.indexed().unwrap().len(), 1);
let other_model = SemanticIndex::open(home.path(), "m2", 2).unwrap();
assert!(other_model.indexed().unwrap().is_empty());
}
#[cfg(unix)]
#[test]
fn the_index_is_owner_only_and_clear_removes_it() {
use std::os::unix::fs::PermissionsExt;
let home = tempfile::tempdir().unwrap();
SemanticIndex::open(home.path(), "m", 2).unwrap();
let dir = index_dir(home.path());
assert_eq!(
std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777,
0o700
);
let file = dir.join(FILE_NAME);
assert_eq!(
std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
0o600
);
clear(home.path()).unwrap();
assert!(!dir.exists());
clear(home.path()).unwrap();
}
#[cfg(unix)]
#[test]
fn a_symlinked_index_directory_is_refused() {
let home = tempfile::tempdir().unwrap();
let elsewhere = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(elsewhere.path(), index_dir(home.path())).unwrap();
assert!(matches!(
SemanticIndex::open(home.path(), "m", 2),
Err(IndexError::UnsafeLocation)
));
assert!(matches!(
clear(home.path()),
Err(IndexError::UnsafeLocation)
));
}
#[test]
fn quantize_maps_unit_components_to_i8() {
assert_eq!(quantize(&[1.0, -1.0, 0.5, 2.0]), vec![127, -127, 64, 127]);
}
}