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::{FileMapL1, FileMapL2, SCHEMA_VER};
use crate::hashing::{self, Hash};
use crate::index::{IndexDb, IndexError};
#[cfg(feature = "intelligence")]
use crate::lance::LanceStore;
use crate::path::RelPath;
use crate::store_blob::{
frame_filemap, parse_filemap_l1, parse_filemap_l2, peek_filemap_schema, read_if_exists, write_blob,
write_bytes_atomic,
};
pub const INDEX_FILE: &str = "index.msgpack";
pub const BLOBS_DIR: &str = "blobs";
pub const LOCK_FILE: &str = ".lock";
pub const LOCK_META_FILE: &str = ".lock.meta";
pub const VIEWS_DIR: &str = "views";
#[cfg(feature = "intelligence")]
pub const LANCE_DIR: &str = "lance";
pub const VIEW_WORKING: &str = "working";
pub const VIEW_STAGED: &str = "staged";
pub fn view_name_for_rev(short_sha: &str) -> String {
format!("rev-{short_sha}")
}
#[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 = root.join(crate::config::BASEMIND_DIR);
ensure_dir(&basemind_dir)?;
ensure_gitignore(&basemind_dir)?;
let (blobs_dir, blobs_shared) = resolve_blobs_dir(root, &basemind_dir);
ensure_dir(&blobs_dir)?;
if blobs_shared {
if let Some(shared_basemind) = blobs_dir.parent() {
ensure_gitignore(shared_basemind)?;
}
}
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)?;
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> {
let basemind_dir = root.join(crate::config::BASEMIND_DIR);
if basemind_dir.exists() {
let _ = migrate_legacy_index_into_views(&basemind_dir);
}
let (blobs_dir, blobs_shared) = resolve_blobs_dir(root, &basemind_dir);
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 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 blob_path_fm(&self, hash: &Hash) -> PathBuf {
let buf = hashing::hex_buf(hash);
self.blob_path_fm_hex(hashing::hex_str(&buf))
}
pub fn blob_path_fm_hex(&self, hash_hex: &str) -> PathBuf {
self.blobs_dir.join(format!("{hash_hex}.fm.msgpack"))
}
#[cfg(feature = "documents")]
pub fn blob_path_doc(&self, hash: &Hash) -> PathBuf {
let buf = hashing::hex_buf(hash);
self.blob_path_doc_hex(hashing::hex_str(&buf))
}
#[cfg(feature = "documents")]
pub fn blob_path_doc_hex(&self, hash_hex: &str) -> PathBuf {
self.blobs_dir.join(format!("{hash_hex}.doc.msgpack"))
}
pub fn read_l1_by_hex(&self, hash_hex: &str) -> Result<Option<FileMapL1>, StoreError> {
let path = self.blob_path_fm_hex(hash_hex);
let Some(bytes) = read_if_exists(&path)? else {
return Ok(None);
};
let map = parse_filemap_l1(&path, &bytes)?;
check_schema(map.schema_ver)?;
Ok(Some(map))
}
pub fn read_l2_by_hex(&self, hash_hex: &str) -> Result<Option<FileMapL2>, StoreError> {
let path = self.blob_path_fm_hex(hash_hex);
let Some(bytes) = read_if_exists(&path)? else {
return Ok(None);
};
match parse_filemap_l2(&path, &bytes)? {
Some(map) => {
check_schema(map.schema_ver)?;
Ok(Some(map))
}
None => Ok(None),
}
}
pub fn write_filemap_hex(&self, hash_hex: &str, l1: &FileMapL1, l2: Option<&FileMapL2>) -> Result<(), StoreError> {
let path = self.blob_path_fm_hex(hash_hex);
if path.exists() && peek_filemap_schema(&path) == Some(SCHEMA_VER) {
return Ok(());
}
let bytes = frame_filemap(l1, l2)?;
write_bytes_atomic(path, &bytes)
}
#[cfg(feature = "documents")]
pub fn write_doc(&self, hash: &Hash, map: &crate::extract::doc::FileMapDoc) -> Result<(), StoreError> {
write_blob(self.blob_path_doc(hash), map)
}
#[cfg(feature = "documents")]
pub fn read_doc_by_hex(&self, hash_hex: &str) -> Result<Option<crate::extract::doc::FileMapDoc>, StoreError> {
let path = self.blob_path_doc_hex(hash_hex);
if !path.exists() {
return Ok(None);
}
let bytes = std::fs::read(&path).map_err(|source| StoreError::Io {
path: path.clone(),
source,
})?;
let map: crate::extract::doc::FileMapDoc = rmp_serde::from_slice(&bytes)?;
check_schema(map.schema_ver)?;
Ok(Some(map))
}
pub fn blob_path_rref_hex(&self, hash_hex: &str) -> PathBuf {
self.blobs_dir.join(format!("{hash_hex}.rref.msgpack"))
}
pub fn write_resolved_hex(
&self,
hash_hex: &str,
refs: &crate::intel::model::FileResolvedRefs,
) -> Result<(), StoreError> {
write_blob(self.blob_path_rref_hex(hash_hex), refs)
}
pub fn read_resolved_by_hex(
&self,
hash_hex: &str,
) -> Result<Option<crate::intel::model::FileResolvedRefs>, StoreError> {
let path = self.blob_path_rref_hex(hash_hex);
let Some(bytes) = read_if_exists(&path)? else {
return Ok(None);
};
let refs: crate::intel::model::FileResolvedRefs = rmp_serde::from_slice(&bytes)?;
check_schema(refs.schema_ver)?;
Ok(Some(refs))
}
#[cfg(feature = "code-search")]
pub fn blob_path_chunk_hex(&self, hash_hex: &str) -> PathBuf {
self.blobs_dir.join(format!("{hash_hex}.chunk.msgpack"))
}
#[cfg(feature = "code-search")]
pub fn write_chunks_hex(&self, hash_hex: &str, blob: &crate::chunk::CodeChunkBlob) -> Result<(), StoreError> {
write_blob(self.blob_path_chunk_hex(hash_hex), blob)
}
#[cfg(feature = "code-search")]
pub fn read_chunks_by_hex(&self, hash_hex: &str) -> Result<Option<crate::chunk::CodeChunkBlob>, StoreError> {
let path = self.blob_path_chunk_hex(hash_hex);
let Some(bytes) = read_if_exists(&path)? else {
return Ok(None);
};
let blob: crate::chunk::CodeChunkBlob = rmp_serde::from_slice(&bytes)?;
check_schema(blob.schema_ver)?;
Ok(Some(blob))
}
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 ensure_gitignore(basemind_dir: &Path) -> Result<(), StoreError> {
let gitignore = basemind_dir.join(".gitignore");
if gitignore.exists() {
return Ok(());
}
std::fs::write(
&gitignore,
"# basemind's machine-local index — not version-controlled.\n*\n",
)
.map_err(|source| StoreError::Io {
path: gitignore,
source,
})
}
fn resolve_blobs_dir(root: &Path, basemind_dir: &Path) -> (PathBuf, bool) {
if let Ok(repo) = crate::git::Repo::discover(root) {
let gc_unsafe = repo.has_linked_worktrees();
let dir = if repo.is_linked_worktree() {
repo.main_worktree_root()
.join(crate::config::BASEMIND_DIR)
.join(BLOBS_DIR)
} else {
basemind_dir.join(BLOBS_DIR)
};
return (dir, gc_unsafe);
}
(basemind_dir.join(BLOBS_DIR), false)
}
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(basemind_dir: &Path) -> Result<(), StoreError> {
let blobs_dir = basemind_dir.join(BLOBS_DIR);
if blobs_dir.exists() {
std::fs::remove_dir_all(&blobs_dir).map_err(|source| StoreError::Io {
path: blobs_dir.clone(),
source,
})?;
std::fs::create_dir_all(&blobs_dir).map_err(|source| StoreError::Io {
path: blobs_dir,
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),
}
}
}
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::*;
fn sample_l1() -> FileMapL1 {
FileMapL1 {
schema_ver: SCHEMA_VER,
language: "rust".to_string(),
size_bytes: 42,
had_errors: false,
error_count: 0,
symbols: Vec::new(),
imports: Vec::new(),
implementations: Vec::new(),
}
}
fn sample_l2() -> FileMapL2 {
FileMapL2 {
schema_ver: SCHEMA_VER,
language: "rust".to_string(),
calls: Vec::new(),
docs: Vec::new(),
}
}
#[test]
fn filemap_frame_round_trips_both_tiers() {
let tmp = tempfile::tempdir().unwrap();
let store = Store::open(tmp.path(), VIEW_WORKING).expect("open store");
let hash_hex = "a".repeat(64);
store
.write_filemap_hex(&hash_hex, &sample_l1(), Some(&sample_l2()))
.expect("write combined frame");
let l1 = store.read_l1_by_hex(&hash_hex).expect("read l1");
assert_eq!(l1.map(|m| m.size_bytes), Some(42), "L1 slice round-trips");
let l2 = store.read_l2_by_hex(&hash_hex).expect("read l2");
assert_eq!(l2.map(|m| m.language), Some("rust".to_string()), "L2 present");
}
#[test]
fn filemap_frame_l1_only_reads_back_no_l2() {
let tmp = tempfile::tempdir().unwrap();
let store = Store::open(tmp.path(), VIEW_WORKING).expect("open store");
let hash_hex = "b".repeat(64);
store
.write_filemap_hex(&hash_hex, &sample_l1(), None)
.expect("write L1-only frame");
assert!(
store.read_l1_by_hex(&hash_hex).expect("read l1").is_some(),
"L1 present in an L1-only frame"
);
assert!(
store.read_l2_by_hex(&hash_hex).expect("read l2").is_none(),
"L2 absent in an L1-only frame (escalation will extract on demand)"
);
}
#[test]
fn resolved_blob_round_trips_and_missing_reads_none() {
use crate::intel::model::{ExportEdge, FileResolvedRefs, ImportEdge, ResolvedEdge};
let tmp = tempfile::tempdir().unwrap();
let store = Store::open(tmp.path(), VIEW_WORKING).expect("open store");
let hash_hex = "d".repeat(64);
let mut refs = FileResolvedRefs::new("typescript");
refs.intra.push(ResolvedEdge {
use_start: 40,
use_end: 43,
def_start: 4,
def_end: 7,
});
refs.imports.push(ImportEdge {
local: "foo".to_string(),
specifier: "./bar".to_string(),
imported: Some("baz".to_string()),
is_type: false,
local_start: 9,
});
refs.exports.push(ExportEdge {
name: "alpha".to_string(),
name_start: 20,
});
store.write_resolved_hex(&hash_hex, &refs).expect("write resolved blob");
let read = store.read_resolved_by_hex(&hash_hex).expect("read resolved blob");
assert_eq!(read.as_ref(), Some(&refs), "resolution blob round-trips exactly");
let missing = store.read_resolved_by_hex(&"e".repeat(64)).expect("read missing");
assert_eq!(missing, None, "absent resolution blob reads back as None");
}
#[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() {
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() {
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() {
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());
}
}