use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::hashing;
use crate::store_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 DATA_HOME_ENV: &str = "BASEMIND_DATA_HOME";
pub const CACHE_DIR: &str = "cache";
pub const WORKSPACES_DIR: &str = "workspaces";
pub const LOCK_META_FILE: &str = ".lock.meta";
pub const WORKSPACE_MARKER_FILE: &str = "workspace.json";
pub const STATUS_SIDECAR_FILE: &str = "status.json";
pub const STATUS_SIDECAR_SCHEMA_VER: u32 = 1;
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}")
}
pub fn cache_root() -> PathBuf {
if let Some(explicit) = std::env::var_os(DATA_HOME_ENV) {
return PathBuf::from(explicit);
}
directories::ProjectDirs::from("", "", "basemind")
.map(|dirs| dirs.data_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
}
pub fn workspace_key(root: &Path) -> String {
let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
hashing::hex(&hashing::hash_bytes(canonical.as_os_str().as_encoded_bytes()))
}
pub fn workspace_cache_dir(root: &Path) -> PathBuf {
cache_root()
.join(CACHE_DIR)
.join(WORKSPACES_DIR)
.join(workspace_key(root))
}
pub fn global_blobs_dir() -> PathBuf {
cache_root().join(CACHE_DIR).join(BLOBS_DIR)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkspaceMarker {
pub root: PathBuf,
pub updated_unix: i64,
}
pub fn ensure_workspace_marker(basemind_dir: &Path, root: &Path) {
let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
if let Some(existing) = read_workspace_marker(basemind_dir)
&& existing.root == canonical
{
return;
}
let updated_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let marker = WorkspaceMarker {
root: canonical,
updated_unix,
};
let Ok(bytes) = serde_json::to_vec(&marker) else {
return;
};
let _ = write_bytes_atomic(basemind_dir.join(WORKSPACE_MARKER_FILE), &bytes);
}
pub fn read_workspace_marker(basemind_dir: &Path) -> Option<WorkspaceMarker> {
let bytes = std::fs::read(basemind_dir.join(WORKSPACE_MARKER_FILE)).ok()?;
serde_json::from_slice(&bytes).ok()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StatusSidecar {
pub schema_ver: u32,
pub file_count: usize,
pub blob_count: usize,
pub scanned_unix: i64,
}
pub fn status_sidecar_path(basemind_dir: &Path) -> PathBuf {
basemind_dir.join(STATUS_SIDECAR_FILE)
}
pub fn count_fm_blobs(blobs_dir: &Path) -> usize {
let Ok(entries) = std::fs::read_dir(blobs_dir) else {
return 0;
};
entries
.filter_map(Result::ok)
.filter(|e| e.file_name().to_str().is_some_and(|n| n.ends_with(".fm.msgpack")))
.count()
}
pub fn write_status_sidecar(basemind_dir: &Path, file_count: usize, blob_count: usize) {
let scanned_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let sidecar = StatusSidecar {
schema_ver: STATUS_SIDECAR_SCHEMA_VER,
file_count,
blob_count,
scanned_unix,
};
let Ok(bytes) = serde_json::to_vec(&sidecar) else {
return;
};
let _ = write_bytes_atomic(status_sidecar_path(basemind_dir), &bytes);
}
pub fn read_status_sidecar(basemind_dir: &Path) -> Option<StatusSidecar> {
let bytes = std::fs::read(status_sidecar_path(basemind_dir)).ok()?;
let sidecar: StatusSidecar = serde_json::from_slice(&bytes).ok()?;
(sidecar.schema_ver == STATUS_SIDECAR_SCHEMA_VER).then_some(sidecar)
}
#[cfg(any(feature = "test-support", test))]
pub fn init_isolated_cache() {
use std::sync::Once;
static INIT: Once = Once::new();
INIT.call_once(|| {
let dir = Box::leak(Box::new(tempfile::tempdir().expect("create isolated cache tempdir")));
let comms_dir = dir.path().join("comms");
unsafe {
std::env::set_var(DATA_HOME_ENV, dir.path());
std::env::set_var("BASEMIND_COMMS_DIR", comms_dir);
}
});
}
#[cfg(test)]
mod status_sidecar_tests {
use super::*;
#[test]
fn write_then_read_round_trips_counts() {
let dir = tempfile::tempdir().expect("tempdir");
write_status_sidecar(dir.path(), 42, 7);
let sidecar = read_status_sidecar(dir.path()).expect("sidecar present after write");
assert_eq!(sidecar.schema_ver, STATUS_SIDECAR_SCHEMA_VER);
assert_eq!(sidecar.file_count, 42);
assert_eq!(sidecar.blob_count, 7);
assert!(
sidecar.scanned_unix > 0,
"scanned_unix should be a real epoch, got {}",
sidecar.scanned_unix
);
}
#[test]
fn read_is_none_when_absent() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(read_status_sidecar(dir.path()), None);
}
#[test]
fn read_rejects_unrecognized_schema_ver() {
let dir = tempfile::tempdir().expect("tempdir");
let bytes = serde_json::to_vec(&StatusSidecar {
schema_ver: STATUS_SIDECAR_SCHEMA_VER + 1,
file_count: 1,
blob_count: 1,
scanned_unix: 1,
})
.expect("serialize");
std::fs::write(status_sidecar_path(dir.path()), bytes).expect("write");
assert_eq!(
read_status_sidecar(dir.path()),
None,
"a future schema_ver must be ignored, not misread"
);
}
#[test]
fn count_fm_blobs_counts_only_fm_msgpack() {
let dir = tempfile::tempdir().expect("tempdir");
for name in [
"a.fm.msgpack",
"b.fm.msgpack",
"c.doc.msgpack",
"d.txt",
"e.fm.msgpack.tmp",
] {
std::fs::write(dir.path().join(name), b"x").expect("write blob");
}
assert_eq!(count_fm_blobs(dir.path()), 2, "only *.fm.msgpack files count");
}
#[test]
fn count_fm_blobs_is_zero_for_missing_dir() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(count_fm_blobs(&dir.path().join("does-not-exist")), 0);
}
}