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))]
const ISOLATION_MARKER: &str = ".basemind-isolated-test-cache";
#[cfg(any(feature = "test-support", test))]
fn inherited_isolated_root() -> Option<PathBuf> {
let path = PathBuf::from(std::env::var_os(DATA_HOME_ENV)?);
path.join(ISOLATION_MARKER).is_file().then_some(path)
}
#[cfg(any(feature = "test-support", test))]
pub fn init_isolated_cache() {
use std::sync::Once;
static INIT: Once = Once::new();
INIT.call_once(|| {
let root: &Path = match inherited_isolated_root() {
Some(path) => Box::leak(Box::new(path)).as_path(),
None => {
let dir = Box::leak(Box::new(tempfile::tempdir().expect("create isolated cache tempdir")));
std::fs::write(dir.path().join(ISOLATION_MARKER), b"basemind test cache\n")
.expect("write the isolated-cache marker");
dir.path()
}
};
let comms_dir = root.join("comms");
let shells_endpoint = isolated_shells_endpoint(root);
unsafe {
std::env::set_var(DATA_HOME_ENV, root);
std::env::set_var("BASEMIND_COMMS_DIR", comms_dir);
std::env::set_var("BASEMIND_SHELLS_SOCKET", shells_endpoint);
for (key, value) in [
("BASEMIND_COMMS_IDLE_REAP_SECS", "20"),
("BASEMIND_COMMS_IDLE_CHECK_SECS", "3"),
("BASEMIND_AGENT_BOOTSTRAP_SECS", "20"),
("BASEMIND_AGENT_IDLE_REAP_SECS", "20"),
("BASEMIND_AGENT_IDLE_CHECK_SECS", "3"),
] {
if std::env::var_os(key).is_none() {
std::env::set_var(key, value);
}
}
}
});
}
#[cfg(all(any(feature = "test-support", test), not(windows)))]
fn isolated_shells_endpoint(dir: &Path) -> std::ffi::OsString {
let shells_dir = dir.join("shells");
let _ = std::fs::create_dir_all(&shells_dir);
shells_dir.join("rmux.sock").into_os_string()
}
#[cfg(all(any(feature = "test-support", test), windows))]
fn isolated_shells_endpoint(_dir: &Path) -> std::ffi::OsString {
std::ffi::OsString::from(format!(r"\\.\pipe\basemind-shells-test-{}", std::process::id()))
}
#[cfg(test)]
mod daemon_isolation_tests {
use super::*;
#[test]
fn every_daemon_family_resolves_inside_the_temp_cache() {
init_isolated_cache();
let temp_root = PathBuf::from(std::env::var_os(DATA_HOME_ENV).expect("BASEMIND_DATA_HOME is set"));
assert_eq!(cache_root(), temp_root, "cache_root must follow the isolation seam");
if let Some(dirs) = directories::ProjectDirs::from("", "", "basemind") {
assert!(
!temp_root.starts_with(dirs.data_dir()),
"the isolated cache must not sit inside the real data dir {}",
dirs.data_dir().display()
);
}
let comms_dir = PathBuf::from(std::env::var_os("BASEMIND_COMMS_DIR").expect("BASEMIND_COMMS_DIR is set"));
assert!(
comms_dir.starts_with(&temp_root),
"comms daemon escaped isolation: {}",
comms_dir.display()
);
for key in [
"BASEMIND_AGENT_BOOTSTRAP_SECS",
"BASEMIND_AGENT_IDLE_REAP_SECS",
"BASEMIND_AGENT_IDLE_CHECK_SECS",
] {
let raw = std::env::var(key).unwrap_or_else(|_| panic!("{key} is set"));
let seconds: u64 = raw.parse().unwrap_or_else(|_| panic!("{key} is numeric, got {raw:?}"));
assert!(
(1..=60).contains(&seconds),
"{key} must stay far below the shipped window so a test daemon self-reaps, got {seconds}"
);
}
}
#[cfg(not(windows))]
#[test]
fn shells_socket_override_points_at_a_bindable_path_in_the_temp_cache() {
init_isolated_cache();
let temp_root = PathBuf::from(std::env::var_os(DATA_HOME_ENV).expect("BASEMIND_DATA_HOME is set"));
let socket = PathBuf::from(std::env::var_os("BASEMIND_SHELLS_SOCKET").expect("BASEMIND_SHELLS_SOCKET is set"));
assert!(
socket.starts_with(&temp_root),
"shells/rmux daemon escaped isolation: {}",
socket.display()
);
assert!(
socket.parent().is_some_and(Path::is_dir),
"the socket parent must exist or the bind falls back to the real per-user socket"
);
assert!(
socket.as_os_str().len() < 104,
"isolated socket path must stay bindable: {} ({} bytes)",
socket.display(),
socket.as_os_str().len()
);
}
#[test]
fn the_isolated_cache_is_marked_and_is_adopted_by_a_child() {
init_isolated_cache();
let root = PathBuf::from(std::env::var_os(DATA_HOME_ENV).expect("BASEMIND_DATA_HOME is set"));
assert!(
root.join(ISOLATION_MARKER).is_file(),
"the isolated cache must be marked so a child adopts it instead of minting its own"
);
assert_eq!(
inherited_isolated_root().as_deref(),
Some(root.as_path()),
"a child inheriting this env must resolve the SAME cache, or the daemon ceiling counts \
an empty registry in every generation"
);
}
#[test]
fn an_unmarked_inherited_data_home_is_never_adopted() {
let real = tempfile::tempdir().expect("tempdir");
let previous = std::env::var_os(DATA_HOME_ENV);
unsafe { std::env::set_var(DATA_HOME_ENV, real.path()) };
let resolved = inherited_isolated_root();
match previous {
Some(value) => unsafe { std::env::set_var(DATA_HOME_ENV, value) },
None => unsafe { std::env::remove_var(DATA_HOME_ENV) },
}
assert_eq!(
resolved, None,
"an unmarked data home is the developer's real cache and must not be adopted"
);
}
#[cfg(all(feature = "shells", any(unix, windows)))]
#[test]
fn shell_runtime_resolves_the_isolated_endpoint() {
init_isolated_cache();
let expected =
PathBuf::from(std::env::var_os("BASEMIND_SHELLS_SOCKET").expect("BASEMIND_SHELLS_SOCKET is set"));
assert_eq!(
crate::shells::ShellRuntime::new().socket_path(),
expected.as_path(),
"the shells runtime must bind the isolated endpoint, not the per-user default"
);
}
}
#[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);
}
}