use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use ahash::AHashMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::extract::SCHEMA_VER;
use crate::index::{IndexDb, IndexError};
#[cfg(feature = "intelligence")]
use crate::lance::LanceStore;
use crate::path::RelPath;
#[cfg(feature = "intelligence")]
pub use crate::store_layout::LANCE_DIR;
#[cfg(any(feature = "test-support", test))]
pub use crate::store_layout::init_isolated_cache;
pub use crate::store_layout::{
BLOBS_DIR, CACHE_DIR, DATA_HOME_ENV, INDEX_FILE, LOCK_FILE, LOCK_META_FILE, VIEW_STAGED, VIEW_WORKING, VIEWS_DIR,
WORKSPACE_MARKER_FILE, WORKSPACES_DIR, WorkspaceMarker, cache_root, ensure_workspace_marker, global_blobs_dir,
read_workspace_marker, view_name_for_rev, workspace_cache_dir, workspace_key,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LockHolder {
Serve,
Watch,
Scan,
Rescan,
Maintenance,
}
impl LockHolder {
pub fn command(self) -> &'static str {
match self {
LockHolder::Serve => "basemind serve",
LockHolder::Watch => "basemind watch",
LockHolder::Scan => "basemind scan",
LockHolder::Rescan => "basemind rescan",
LockHolder::Maintenance => "a basemind cache/maintenance task",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockMeta {
pub command: String,
pub pid: u32,
pub acquired_unix: i64,
}
#[derive(Debug, Error)]
pub enum StoreError {
#[error("io error on {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("msgpack encode error: {0}")]
Encode(#[from] rmp_serde::encode::Error),
#[error("msgpack decode error: {0}")]
Decode(#[from] rmp_serde::decode::Error),
#[error("schema version mismatch: stored {found}, current {expected}")]
SchemaMismatch { found: u16, expected: u16 },
#[error("corrupt filemap blob at {path}: malformed frame header")]
CorruptBlob { path: PathBuf },
#[error("filemap L1 tier exceeds the 4 GiB frame limit")]
BlobTooLarge,
#[error("{}", lock_contention_message(.path, .holder))]
Locked {
path: PathBuf,
holder: Option<LockMeta>,
},
#[error("inverted index error: {0}")]
Index(#[from] IndexError),
#[error(
"view {view:?} has not been scanned; run `basemind scan --view {view}` \
(or omit --view to use the working view)"
)]
ViewNotScanned { view: String },
}
impl StoreError {
pub fn is_lock_contention(&self) -> bool {
matches!(
self,
StoreError::Locked { .. } | StoreError::Index(IndexError::Fjall(fjall::Error::Locked))
)
}
}
fn lock_contention_message(path: &Path, holder: &Option<LockMeta>) -> String {
match holder {
Some(meta) => format!(
"another basemind process holds the lock on {} (`{}`, pid {})",
path.display(),
meta.command,
meta.pid
),
None => format!(
"another basemind process holds the lock on {} (usually the `basemind serve` MCP \
server from your editor plugin, or `basemind watch`)",
path.display()
),
}
}
pub const LOCK_CONTENTION_HELP: &str = "the basemind index is locked by another process \
(likely the MCP server). If an editor/plugin is serving this repo, use its `rescan` tool \
to refresh the index, or stop that server before running `basemind scan`.";
pub use crate::store_lock::{WriterProbe, probe_writer_lock};
pub(crate) use crate::store_lock::{acquire_lock, acquire_lock_as, writer_lock_is_held};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Index {
pub schema_ver: u16,
pub files: AHashMap<RelPath, FileEntry>,
#[serde(default)]
pub doc_files: AHashMap<RelPath, DocEntry>,
}
impl Index {
pub fn empty() -> Self {
Self {
schema_ver: SCHEMA_VER,
files: AHashMap::new(),
doc_files: AHashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileEntry {
pub hash_hex: String,
pub language: String,
pub size_bytes: u64,
pub mtime: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DocEntry {
pub hash_hex: String,
pub embedding_preset: String,
pub size_bytes: u64,
pub mtime: i64,
}
pub struct Store {
pub root: PathBuf,
pub basemind_dir: PathBuf,
pub blobs_dir: PathBuf,
pub blobs_shared: bool,
pub view_dir: PathBuf,
pub view: String,
pub index: Index,
pub index_db: Option<IndexDb>,
#[cfg(feature = "intelligence")]
pub lance: Option<LanceStore>,
_lock: Option<File>,
}
impl Store {
pub fn open(root: &Path, view: &str) -> Result<Self, StoreError> {
Self::open_with_holder(root, view, LockHolder::Maintenance)
}
pub fn open_with_holder(root: &Path, view: &str, holder: LockHolder) -> Result<Self, StoreError> {
let basemind_dir = workspace_cache_dir(root);
ensure_dir(&basemind_dir)?;
let blobs_dir = global_blobs_dir();
ensure_dir(&blobs_dir)?;
let blobs_shared = true;
ensure_dir(&basemind_dir.join(VIEWS_DIR))?;
migrate_legacy_index_into_views(&basemind_dir)?;
let view_dir = basemind_dir.join(VIEWS_DIR).join(view);
ensure_dir(&view_dir)?;
let lock = acquire_lock_as(&basemind_dir, holder)?;
ensure_workspace_marker(&basemind_dir, root);
let index = match read_index(&view_dir) {
Ok(Some(idx)) => idx,
Ok(None) => Index::empty(),
Err(StoreError::SchemaMismatch { found, expected }) => {
tracing::info!(
found,
expected,
view,
"cache schema bumped; refreshing view in place (re-extract + GC reclaims orphans)"
);
wipe_view(&view_dir)?;
Index::empty()
}
Err(e) => return Err(e),
};
let index_db = Some(open_index_with_retry(&view_dir)?);
Ok(Self {
root: root.to_path_buf(),
basemind_dir,
blobs_dir,
blobs_shared,
view_dir,
view: view.to_string(),
index,
index_db,
#[cfg(feature = "intelligence")]
lance: None,
_lock: Some(lock),
})
}
pub fn open_read_only(root: &Path, view: &str) -> Result<Self, StoreError> {
Self::open_read_only_inner(root, view, true)
}
pub fn open_read_only_no_index(root: &Path, view: &str) -> Result<Self, StoreError> {
Self::open_read_only_inner(root, view, false)
}
fn open_read_only_inner(root: &Path, view: &str, allow_index_db: bool) -> Result<Self, StoreError> {
let basemind_dir = workspace_cache_dir(root);
if basemind_dir.exists() {
let _ = migrate_legacy_index_into_views(&basemind_dir);
ensure_workspace_marker(&basemind_dir, root);
}
let blobs_dir = global_blobs_dir();
let blobs_shared = true;
let view_dir = basemind_dir.join(VIEWS_DIR).join(view);
if view != VIEW_WORKING && !view_dir.join(INDEX_FILE).exists() {
return Err(StoreError::ViewNotScanned { view: view.to_string() });
}
let (index, schema_ok) = match read_index(&view_dir) {
Ok(Some(idx)) => (idx, true),
Ok(None) => (Index::empty(), true),
Err(StoreError::SchemaMismatch { found, expected }) => {
tracing::warn!(
found,
expected,
"cache schema mismatch; index reads empty until `basemind scan` refreshes it"
);
(Index::empty(), false)
}
Err(e) => return Err(e),
};
let index_db = if allow_index_db && schema_ok && view_dir.exists() && !writer_lock_is_held(&basemind_dir) {
match IndexDb::open(&view_dir) {
Ok(db) => Some(db),
Err(IndexError::Fjall(fjall::Error::Locked)) => None,
Err(error) => {
tracing::warn!(%error, "read-only index open failed; degrading to blob-only reads");
None
}
}
} else {
None
};
Ok(Self {
root: root.to_path_buf(),
basemind_dir,
blobs_dir,
blobs_shared,
view_dir,
view: view.to_string(),
index,
index_db,
#[cfg(feature = "intelligence")]
lance: None,
_lock: None,
})
}
#[cfg(feature = "intelligence")]
pub fn lance_or_open(&mut self, dim: u16, embedding_model: &str) -> Result<&LanceStore, anyhow::Error> {
if self.lance.is_none() {
let dir = self.basemind_dir.join(LANCE_DIR);
let store = LanceStore::open(&dir, dim, embedding_model)?;
self.lance = Some(store);
}
Ok(self.lance.as_ref().expect("lance store just populated"))
}
#[cfg(feature = "intelligence")]
pub fn lance_dir_exists(&self) -> bool {
self.basemind_dir.join(LANCE_DIR).exists()
}
pub fn upsert(&mut self, rel: impl Into<RelPath>, entry: FileEntry) {
self.index.files.insert(rel.into(), entry);
}
pub fn remove(&mut self, rel: impl AsRef<[u8]>) {
self.index.files.remove(bstr::BStr::new(rel.as_ref()));
}
pub fn lookup(&self, rel: impl AsRef<[u8]>) -> Option<&FileEntry> {
self.index.files.get(bstr::BStr::new(rel.as_ref()))
}
pub fn upsert_doc(&mut self, rel: impl Into<RelPath>, entry: DocEntry) {
self.index.doc_files.insert(rel.into(), entry);
}
pub fn remove_doc(&mut self, rel: impl AsRef<[u8]>) {
self.index.doc_files.remove(bstr::BStr::new(rel.as_ref()));
}
pub fn lookup_doc(&self, rel: impl AsRef<[u8]>) -> Option<&DocEntry> {
self.index.doc_files.get(bstr::BStr::new(rel.as_ref()))
}
pub fn flush(&self) -> Result<(), StoreError> {
let final_path = self.view_dir.join(INDEX_FILE);
let tmp_path = self.view_dir.join(format!("{INDEX_FILE}.tmp"));
let bytes = rmp_serde::to_vec_named(&self.index)?;
{
let mut f = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&tmp_path)
.map_err(|source| StoreError::Io {
path: tmp_path.clone(),
source,
})?;
f.write_all(&bytes).map_err(|source| StoreError::Io {
path: tmp_path.clone(),
source,
})?;
f.sync_all().map_err(|source| StoreError::Io {
path: tmp_path.clone(),
source,
})?;
}
std::fs::rename(&tmp_path, &final_path).map_err(|source| StoreError::Io {
path: final_path,
source,
})?;
Ok(())
}
}
fn ensure_dir(p: &Path) -> Result<(), StoreError> {
std::fs::create_dir_all(p).map_err(|source| StoreError::Io {
path: p.to_path_buf(),
source,
})
}
fn wipe_view(view_dir: &Path) -> Result<(), StoreError> {
let index_path = view_dir.join(INDEX_FILE);
if index_path.exists() {
std::fs::remove_file(&index_path).map_err(|source| StoreError::Io {
path: index_path,
source,
})?;
}
Ok(())
}
pub(crate) fn wipe_blobs_in(blobs_dir: &Path) -> Result<(), StoreError> {
if blobs_dir.exists() {
std::fs::remove_dir_all(blobs_dir).map_err(|source| StoreError::Io {
path: blobs_dir.to_path_buf(),
source,
})?;
std::fs::create_dir_all(blobs_dir).map_err(|source| StoreError::Io {
path: blobs_dir.to_path_buf(),
source,
})?;
}
Ok(())
}
fn migrate_legacy_index_into_views(basemind_dir: &Path) -> Result<(), StoreError> {
let legacy = basemind_dir.join(INDEX_FILE);
if !legacy.exists() {
return Ok(());
}
let working_dir = basemind_dir.join(VIEWS_DIR).join(VIEW_WORKING);
let working_index = working_dir.join(INDEX_FILE);
if working_index.exists() {
let _ = std::fs::remove_file(&legacy);
return Ok(());
}
ensure_dir(&working_dir)?;
std::fs::rename(&legacy, &working_index).map_err(|source| StoreError::Io {
path: working_index,
source,
})?;
tracing::info!("migrated .basemind/index.msgpack → .basemind/views/{VIEW_WORKING}/index.msgpack");
Ok(())
}
pub(crate) fn read_index(view_dir: &Path) -> Result<Option<Index>, StoreError> {
let path = view_dir.join(INDEX_FILE);
if !path.exists() {
return Ok(None);
}
let bytes = std::fs::read(&path).map_err(|source| StoreError::Io {
path: path.clone(),
source,
})?;
let index: Index = rmp_serde::from_slice(&bytes)?;
check_schema(index.schema_ver)?;
Ok(Some(index))
}
const INDEX_OPEN_RETRIES: u32 = 10;
const INDEX_OPEN_BACKOFF: std::time::Duration = std::time::Duration::from_millis(50);
pub(crate) fn open_index_with_retry(view_dir: &Path) -> Result<IndexDb, IndexError> {
let mut attempt = 0;
loop {
match IndexDb::open(view_dir) {
Ok(db) => return Ok(db),
Err(IndexError::Fjall(fjall::Error::Locked)) if attempt < INDEX_OPEN_RETRIES => {
attempt += 1;
std::thread::sleep(INDEX_OPEN_BACKOFF);
}
Err(other) => return Err(other),
}
}
}
pub(crate) fn check_schema(found: u16) -> Result<(), StoreError> {
if found == SCHEMA_VER {
Ok(())
} else {
Err(StoreError::SchemaMismatch {
found,
expected: SCHEMA_VER,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn locked_display_names_the_serve_holder() {
let err = StoreError::Locked {
path: PathBuf::from("/repo/.basemind/.lock"),
holder: None,
};
let msg = err.to_string();
assert!(
msg.contains("serve"),
"Locked message should name the `serve` holder, got: {msg}"
);
assert!(
msg.contains("watch"),
"Locked message should still mention `watch`, got: {msg}"
);
}
#[test]
fn locked_message_names_actual_holder_from_sidecar() {
let err = StoreError::Locked {
path: PathBuf::from("/repo/.basemind/.lock"),
holder: Some(LockMeta {
command: "basemind scan".to_string(),
pid: 4321,
acquired_unix: 1_700_000_000,
}),
};
let msg = err.to_string();
assert!(
msg.contains("basemind scan"),
"message should name the actual holder command, got: {msg}"
);
assert!(msg.contains("4321"), "message should name the holder pid, got: {msg}");
}
#[test]
fn second_acquisition_names_first_holders_command() {
let tmp = tempfile::tempdir().expect("tempdir");
let basemind_dir = tmp.path().join(".basemind");
std::fs::create_dir_all(&basemind_dir).expect("mkdir");
let _held = acquire_lock_as(&basemind_dir, LockHolder::Scan).expect("first lock");
let err = acquire_lock_as(&basemind_dir, LockHolder::Serve)
.expect_err("second acquisition must fail while the first holds the lock");
assert!(err.is_lock_contention(), "must be a contention error");
let msg = err.to_string();
assert!(
msg.contains("basemind scan"),
"second error should name the FIRST holder (scan), got: {msg}"
);
}
#[test]
fn open_read_only_errors_on_never_scanned_named_view() {
init_isolated_cache();
let tmp = tempfile::tempdir().expect("tempdir");
let err = match Store::open_read_only(tmp.path(), "rev-deadbee") {
Ok(_) => panic!("named unscanned view must error, not silently open empty"),
Err(e) => e,
};
assert!(
matches!(&err, StoreError::ViewNotScanned { view } if view == "rev-deadbee"),
"expected ViewNotScanned, got: {err:?}"
);
assert!(
err.to_string().contains("rev-deadbee"),
"error names the view, got: {err}"
);
}
#[test]
fn open_read_only_allows_unscanned_working_view() {
init_isolated_cache();
let tmp = tempfile::tempdir().expect("tempdir");
let store =
Store::open_read_only(tmp.path(), VIEW_WORKING).expect("working view opens even when never scanned");
assert!(store.index.files.is_empty(), "empty working index");
}
#[test]
fn open_writer_creates_named_view_for_first_scan() {
init_isolated_cache();
let tmp = tempfile::tempdir().expect("tempdir");
let store = Store::open(tmp.path(), "rev-cafe000").expect("writer creates a named view on first scan");
assert!(store.view_dir.exists(), "named view dir created by writer");
}
#[test]
fn fs2_advisory_lock_is_lock_contention() {
let err = StoreError::Locked {
path: PathBuf::from("/repo/.basemind/.lock"),
holder: None,
};
assert!(err.is_lock_contention());
}
#[test]
fn fjall_internal_lock_is_lock_contention() {
let err = StoreError::Index(IndexError::Fjall(fjall::Error::Locked));
assert!(err.is_lock_contention());
}
#[test]
fn schema_mismatch_is_not_lock_contention() {
let err = StoreError::SchemaMismatch { found: 1, expected: 2 };
assert!(!err.is_lock_contention());
}
}