use std::io::Read as _;
use std::path::{Path, PathBuf};
use rusqlite::Connection;
use tracing::debug;
use crate::cache::CACHE_MAGIC;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Capabilities {
pub db: DbCapabilities,
pub fs: FsCapabilities,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct DbCapabilities {
pub fts5: bool,
pub vectors: bool,
pub triage: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FsCapabilities {
pub semantic: bool,
pub binary_cache: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapabilityStatus {
pub name: &'static str,
pub available: bool,
pub fallback: &'static str,
}
#[must_use]
pub fn detect_capabilities(db: &Connection) -> Capabilities {
let fts5 = probe_fts5(db);
let vectors = probe_vectors(db);
let semantic = probe_semantic_model();
let bones_dir = bones_dir_from_db(db);
let binary_cache = bones_dir.as_deref().map_or_else(
|| {
debug!("binary_cache probe: cannot determine .bones dir from connection, reporting unavailable");
false
},
|d| probe_binary_cache(&d.join("cache").join("events.bin")),
);
let triage = probe_triage(db);
let caps = Capabilities {
db: DbCapabilities {
fts5,
vectors,
triage,
},
fs: FsCapabilities {
semantic,
binary_cache,
},
};
debug!(?caps, "capability detection complete");
caps
}
#[must_use]
pub fn describe_capabilities(caps: &Capabilities) -> Vec<CapabilityStatus> {
vec![
CapabilityStatus {
name: "fts5",
available: caps.db.fts5,
fallback: "`bn search` uses LIKE queries (slower, no ranking)",
},
CapabilityStatus {
name: "semantic",
available: caps.fs.semantic,
fallback: "`bn search` uses lexical only, warns user",
},
CapabilityStatus {
name: "vectors",
available: caps.db.vectors,
fallback: "semantic search uses Rust KNN (no sqlite-vec acceleration)",
},
CapabilityStatus {
name: "binary_cache",
available: caps.fs.binary_cache,
fallback: "event replay reads .events files directly (slower)",
},
CapabilityStatus {
name: "triage",
available: caps.db.triage,
fallback: "`bn next` uses simple heuristic (urgency + age)",
},
]
}
fn probe_fts5(db: &Connection) -> bool {
let result = db.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'items_fts'",
[],
|row| row.get::<_, i64>(0),
);
match result {
Ok(count) => {
let available = count > 0;
debug!(available, "fts5 probe");
available
}
Err(e) => {
debug!(error = %e, "fts5 probe failed");
false
}
}
}
fn probe_vectors(db: &Connection) -> bool {
let result = db.query_row("SELECT vec_version()", [], |row| row.get::<_, String>(0));
let available = result.is_ok();
debug!(available, "vectors probe");
available
}
fn probe_semantic_model() -> bool {
let available = dirs::cache_dir().is_some_and(|mut p| {
p.push("bones");
p.push("models");
let model = p.join("minilm-l6-v2-int8.onnx");
let tokenizer = p.join("minilm-l6-v2-tokenizer.json");
model.is_file() && tokenizer.is_file()
});
debug!(available, "semantic model probe");
available
}
fn probe_binary_cache(events_bin: &Path) -> bool {
if !events_bin.exists() {
debug!(path = %events_bin.display(), "binary_cache probe: file absent");
return false;
}
let available = match std::fs::File::open(events_bin) {
Ok(mut f) => {
let mut magic = [0u8; 4];
f.read_exact(&mut magic)
.map(|()| magic == CACHE_MAGIC)
.unwrap_or(false)
}
Err(e) => {
debug!(error = %e, "binary_cache probe: cannot open file");
false
}
};
debug!(available, path = %events_bin.display(), "binary_cache probe");
available
}
fn probe_triage(db: &Connection) -> bool {
let result = db.query_row(
"SELECT COUNT(*) FROM items WHERE is_deleted = 0",
[],
|row| row.get::<_, i64>(0),
);
let available = result.is_ok();
debug!(available, "triage probe");
available
}
fn bones_dir_from_db(db: &Connection) -> Option<PathBuf> {
let mut stmt = db.prepare("PRAGMA database_list").ok()?;
let mut rows = stmt.query([]).ok()?;
while let Ok(Some(row)) = rows.next() {
let name: String = row.get(1).unwrap_or_default();
let file: String = row.get(2).unwrap_or_default();
if name == "main" && !file.is_empty() {
return PathBuf::from(file).parent().map(ToOwned::to_owned);
}
}
None
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
use crate::db::{migrations, open_projection};
fn migrated_db() -> (TempDir, Connection) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("bones-projection.sqlite3");
let conn = open_projection(&path).expect("open projection db");
(dir, conn)
}
fn bare_db() -> Connection {
Connection::open_in_memory().expect("open in-memory db")
}
#[test]
fn fts5_is_false_on_bare_db() {
let conn = bare_db();
assert!(!probe_fts5(&conn));
}
#[test]
fn fts5_is_true_after_migration() {
let (_dir, conn) = migrated_db();
assert!(
migrations::current_schema_version(&conn).expect("version") >= 2,
"test assumes migration v2+ is applied"
);
assert!(probe_fts5(&conn));
}
#[test]
fn vectors_probe_matches_direct_query() {
let conn = bare_db();
let probed = probe_vectors(&conn);
let direct = conn
.query_row("SELECT vec_version()", [], |row| row.get::<_, String>(0))
.is_ok();
assert_eq!(probed, direct);
}
#[test]
fn semantic_model_is_false_in_ci() {
let result = probe_semantic_model();
let _ = result;
}
#[test]
fn binary_cache_false_for_missing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("events.bin");
assert!(!probe_binary_cache(&path));
}
#[test]
fn binary_cache_true_for_valid_magic() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("events.bin");
let mut content = Vec::from(CACHE_MAGIC);
content.extend_from_slice(&[0u8; 28]); std::fs::write(&path, &content).expect("write cache");
assert!(probe_binary_cache(&path));
}
#[test]
fn binary_cache_false_for_wrong_magic() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("events.bin");
std::fs::write(&path, b"WXYZ\x00\x00\x00\x00").expect("write bad magic");
assert!(!probe_binary_cache(&path));
}
#[test]
fn binary_cache_false_for_truncated_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("events.bin");
std::fs::write(&path, b"BN").expect("write truncated");
assert!(!probe_binary_cache(&path));
}
#[test]
fn triage_false_on_bare_db_no_items_table() {
let conn = bare_db();
assert!(!probe_triage(&conn));
}
#[test]
fn triage_true_after_migration_zero_items() {
let (_dir, conn) = migrated_db();
assert!(probe_triage(&conn));
}
#[test]
fn bones_dir_none_for_in_memory_db() {
let conn = bare_db();
assert!(bones_dir_from_db(&conn).is_none());
}
#[test]
fn bones_dir_is_parent_of_db_file() {
let dir = tempfile::tempdir().expect("tempdir");
let canonical_dir = dir.path().canonicalize().expect("canonicalize tempdir");
let db_path = canonical_dir.join("bones-projection.sqlite3");
let conn = Connection::open(&db_path).expect("open");
let bones_dir = bones_dir_from_db(&conn);
assert_eq!(bones_dir.as_deref(), Some(canonical_dir.as_path()));
}
#[test]
fn detect_on_migrated_db_has_fts5() {
let (_dir, conn) = migrated_db();
let caps = detect_capabilities(&conn);
assert!(caps.db.fts5, "FTS5 should be available after migration");
}
#[test]
fn detect_on_bare_db_has_no_capabilities() {
let conn = bare_db();
let caps = detect_capabilities(&conn);
assert!(!caps.db.fts5, "no FTS5 on bare db");
assert!(!caps.fs.binary_cache, "no binary_cache (in-memory db)");
assert!(!caps.db.triage, "no triage on bare db (no items table)");
}
#[test]
fn detect_triage_true_on_migrated_db() {
let (_dir, conn) = migrated_db();
let caps = detect_capabilities(&conn);
assert!(caps.db.triage, "triage should be true after migration");
}
#[test]
fn detect_with_valid_binary_cache() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("bones-projection.sqlite3");
let conn = open_projection(&db_path).expect("open projection");
let cache_dir = dir.path().join("cache");
std::fs::create_dir_all(&cache_dir).expect("create cache dir");
let mut content = Vec::from(CACHE_MAGIC);
content.extend_from_slice(&[0u8; 28]);
std::fs::write(cache_dir.join("events.bin"), &content).expect("write cache");
let caps = detect_capabilities(&conn);
assert!(
caps.fs.binary_cache,
"binary_cache should be true with valid events.bin"
);
}
#[test]
fn detect_binary_cache_false_with_bad_magic() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("bones-projection.sqlite3");
let conn = open_projection(&db_path).expect("open projection");
let cache_dir = dir.path().join("cache");
std::fs::create_dir_all(&cache_dir).expect("create cache dir");
std::fs::write(cache_dir.join("events.bin"), b"BADMAGIC").expect("write");
let caps = detect_capabilities(&conn);
assert!(
!caps.fs.binary_cache,
"binary_cache should be false with bad magic"
);
}
#[test]
fn describe_returns_five_entries() {
let caps = Capabilities::default();
let statuses = describe_capabilities(&caps);
assert_eq!(statuses.len(), 5);
}
#[test]
fn describe_names_are_stable() {
let caps = Capabilities::default();
let statuses = describe_capabilities(&caps);
let names: Vec<_> = statuses.iter().map(|s| s.name).collect();
assert_eq!(
names,
&["fts5", "semantic", "vectors", "binary_cache", "triage"]
);
}
#[test]
fn describe_available_flags_match_capabilities() {
let caps = Capabilities {
db: DbCapabilities {
fts5: true,
vectors: true,
triage: true,
},
fs: FsCapabilities {
semantic: false,
binary_cache: false,
},
};
let statuses = describe_capabilities(&caps);
let map: std::collections::HashMap<_, _> =
statuses.iter().map(|s| (s.name, s.available)).collect();
assert!(map["fts5"]);
assert!(!map["semantic"]);
assert!(map["vectors"]);
assert!(!map["binary_cache"]);
assert!(map["triage"]);
}
#[test]
fn describe_fallbacks_are_non_empty() {
let caps = Capabilities::default();
let statuses = describe_capabilities(&caps);
for status in &statuses {
assert!(
!status.fallback.is_empty(),
"fallback for {} is empty",
status.name
);
}
}
#[test]
fn capabilities_default_is_all_false() {
let caps = Capabilities::default();
assert!(!caps.db.fts5);
assert!(!caps.fs.semantic);
assert!(!caps.db.vectors);
assert!(!caps.fs.binary_cache);
assert!(!caps.db.triage);
}
}