1use std::io::Read as _;
35use std::path::{Path, PathBuf};
36
37use rusqlite::Connection;
38use tracing::debug;
39
40use crate::cache::CACHE_MAGIC;
41
42#[derive(Debug, Clone, PartialEq, Eq, Default)]
55pub struct Capabilities {
56 pub db: DbCapabilities,
58 pub fs: FsCapabilities,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Default)]
64pub struct DbCapabilities {
65 pub fts5: bool,
67 pub vectors: bool,
69 pub triage: bool,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Default)]
75pub struct FsCapabilities {
76 pub semantic: bool,
78 pub binary_cache: bool,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct CapabilityStatus {
85 pub name: &'static str,
87 pub available: bool,
89 pub fallback: &'static str,
92}
93
94#[must_use]
114pub fn detect_capabilities(db: &Connection) -> Capabilities {
115 let fts5 = probe_fts5(db);
116 let vectors = probe_vectors(db);
117 let semantic = probe_semantic_model();
118 let bones_dir = bones_dir_from_db(db);
119 let binary_cache = bones_dir.as_deref().map_or_else(
120 || {
121 debug!("binary_cache probe: cannot determine .bones dir from connection, reporting unavailable");
122 false
123 },
124 |d| probe_binary_cache(&d.join("cache").join("events.bin")),
125 );
126 let triage = probe_triage(db);
127
128 let caps = Capabilities {
129 db: DbCapabilities {
130 fts5,
131 vectors,
132 triage,
133 },
134 fs: FsCapabilities {
135 semantic,
136 binary_cache,
137 },
138 };
139 debug!(?caps, "capability detection complete");
140 caps
141}
142
143#[must_use]
149pub fn describe_capabilities(caps: &Capabilities) -> Vec<CapabilityStatus> {
150 vec![
151 CapabilityStatus {
152 name: "fts5",
153 available: caps.db.fts5,
154 fallback: "`bn search` uses LIKE queries (slower, no ranking)",
155 },
156 CapabilityStatus {
157 name: "semantic",
158 available: caps.fs.semantic,
159 fallback: "`bn search` uses lexical only, warns user",
160 },
161 CapabilityStatus {
162 name: "vectors",
163 available: caps.db.vectors,
164 fallback: "semantic search uses Rust KNN (no sqlite-vec acceleration)",
165 },
166 CapabilityStatus {
167 name: "binary_cache",
168 available: caps.fs.binary_cache,
169 fallback: "event replay reads .events files directly (slower)",
170 },
171 CapabilityStatus {
172 name: "triage",
173 available: caps.db.triage,
174 fallback: "`bn next` uses simple heuristic (urgency + age)",
175 },
176 ]
177}
178
179fn probe_fts5(db: &Connection) -> bool {
185 let result = db.query_row(
186 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'items_fts'",
187 [],
188 |row| row.get::<_, i64>(0),
189 );
190 match result {
191 Ok(count) => {
192 let available = count > 0;
193 debug!(available, "fts5 probe");
194 available
195 }
196 Err(e) => {
197 debug!(error = %e, "fts5 probe failed");
198 false
199 }
200 }
201}
202
203fn probe_vectors(db: &Connection) -> bool {
207 let result = db.query_row("SELECT vec_version()", [], |row| row.get::<_, String>(0));
208 let available = result.is_ok();
209 debug!(available, "vectors probe");
210 available
211}
212
213fn probe_semantic_model() -> bool {
218 let available = dirs::cache_dir().is_some_and(|mut p| {
219 p.push("bones");
220 p.push("models");
221 let model = p.join("minilm-l6-v2-int8.onnx");
222 let tokenizer = p.join("minilm-l6-v2-tokenizer.json");
223 model.is_file() && tokenizer.is_file()
224 });
225 debug!(available, "semantic model probe");
226 available
227}
228
229fn probe_binary_cache(events_bin: &Path) -> bool {
231 if !events_bin.exists() {
232 debug!(path = %events_bin.display(), "binary_cache probe: file absent");
233 return false;
234 }
235 let available = match std::fs::File::open(events_bin) {
236 Ok(mut f) => {
237 let mut magic = [0u8; 4];
238 f.read_exact(&mut magic)
239 .map(|()| magic == CACHE_MAGIC)
240 .unwrap_or(false)
241 }
242 Err(e) => {
243 debug!(error = %e, "binary_cache probe: cannot open file");
244 false
245 }
246 };
247 debug!(available, path = %events_bin.display(), "binary_cache probe");
248 available
249}
250
251fn probe_triage(db: &Connection) -> bool {
257 let result = db.query_row(
258 "SELECT COUNT(*) FROM items WHERE is_deleted = 0",
259 [],
260 |row| row.get::<_, i64>(0),
261 );
262 let available = result.is_ok();
263 debug!(available, "triage probe");
264 available
265}
266
267fn bones_dir_from_db(db: &Connection) -> Option<PathBuf> {
275 let mut stmt = db.prepare("PRAGMA database_list").ok()?;
276 let mut rows = stmt.query([]).ok()?;
277 while let Ok(Some(row)) = rows.next() {
278 let name: String = row.get(1).unwrap_or_default();
279 let file: String = row.get(2).unwrap_or_default();
280 if name == "main" && !file.is_empty() {
281 return PathBuf::from(file).parent().map(ToOwned::to_owned);
282 }
283 }
284 None
285}
286
287#[cfg(test)]
292mod tests {
293 use tempfile::TempDir;
294
295 use super::*;
296 use crate::db::{migrations, open_projection};
297
298 fn migrated_db() -> (TempDir, Connection) {
303 let dir = tempfile::tempdir().expect("tempdir");
304 let path = dir.path().join("bones-projection.sqlite3");
305 let conn = open_projection(&path).expect("open projection db");
306 (dir, conn)
307 }
308
309 fn bare_db() -> Connection {
310 Connection::open_in_memory().expect("open in-memory db")
312 }
313
314 #[test]
319 fn fts5_is_false_on_bare_db() {
320 let conn = bare_db();
321 assert!(!probe_fts5(&conn));
322 }
323
324 #[test]
325 fn fts5_is_true_after_migration() {
326 let (_dir, conn) = migrated_db();
327 assert!(
329 migrations::current_schema_version(&conn).expect("version") >= 2,
330 "test assumes migration v2+ is applied"
331 );
332 assert!(probe_fts5(&conn));
333 }
334
335 #[test]
340 fn vectors_probe_matches_direct_query() {
341 let conn = bare_db();
342 let probed = probe_vectors(&conn);
343 let direct = conn
344 .query_row("SELECT vec_version()", [], |row| row.get::<_, String>(0))
345 .is_ok();
346 assert_eq!(probed, direct);
347 }
348
349 #[test]
354 fn semantic_model_is_false_in_ci() {
355 let result = probe_semantic_model();
358 let _ = result;
361 }
362
363 #[test]
368 fn binary_cache_false_for_missing_file() {
369 let dir = tempfile::tempdir().expect("tempdir");
370 let path = dir.path().join("events.bin");
371 assert!(!probe_binary_cache(&path));
372 }
373
374 #[test]
375 fn binary_cache_true_for_valid_magic() {
376 let dir = tempfile::tempdir().expect("tempdir");
377 let path = dir.path().join("events.bin");
378 let mut content = Vec::from(CACHE_MAGIC);
380 content.extend_from_slice(&[0u8; 28]); std::fs::write(&path, &content).expect("write cache");
382 assert!(probe_binary_cache(&path));
383 }
384
385 #[test]
386 fn binary_cache_false_for_wrong_magic() {
387 let dir = tempfile::tempdir().expect("tempdir");
388 let path = dir.path().join("events.bin");
389 std::fs::write(&path, b"WXYZ\x00\x00\x00\x00").expect("write bad magic");
390 assert!(!probe_binary_cache(&path));
391 }
392
393 #[test]
394 fn binary_cache_false_for_truncated_file() {
395 let dir = tempfile::tempdir().expect("tempdir");
396 let path = dir.path().join("events.bin");
397 std::fs::write(&path, b"BN").expect("write truncated");
399 assert!(!probe_binary_cache(&path));
400 }
401
402 #[test]
407 fn triage_false_on_bare_db_no_items_table() {
408 let conn = bare_db();
409 assert!(!probe_triage(&conn));
410 }
411
412 #[test]
413 fn triage_true_after_migration_zero_items() {
414 let (_dir, conn) = migrated_db();
415 assert!(probe_triage(&conn));
417 }
418
419 #[test]
424 fn bones_dir_none_for_in_memory_db() {
425 let conn = bare_db();
426 assert!(bones_dir_from_db(&conn).is_none());
427 }
428
429 #[test]
430 fn bones_dir_is_parent_of_db_file() {
431 let dir = tempfile::tempdir().expect("tempdir");
432 let db_path = dir.path().join("bones-projection.sqlite3");
433 let conn = Connection::open(&db_path).expect("open");
434 let bones_dir = bones_dir_from_db(&conn);
435 assert_eq!(bones_dir.as_deref(), Some(dir.path()));
436 }
437
438 #[test]
443 fn detect_on_migrated_db_has_fts5() {
444 let (_dir, conn) = migrated_db();
445 let caps = detect_capabilities(&conn);
446 assert!(caps.db.fts5, "FTS5 should be available after migration");
448 }
449
450 #[test]
451 fn detect_on_bare_db_has_no_capabilities() {
452 let conn = bare_db();
453 let caps = detect_capabilities(&conn);
454 assert!(!caps.db.fts5, "no FTS5 on bare db");
455 assert!(!caps.fs.binary_cache, "no binary_cache (in-memory db)");
459 assert!(!caps.db.triage, "no triage on bare db (no items table)");
460 }
461
462 #[test]
463 fn detect_triage_true_on_migrated_db() {
464 let (_dir, conn) = migrated_db();
465 let caps = detect_capabilities(&conn);
466 assert!(caps.db.triage, "triage should be true after migration");
467 }
468
469 #[test]
470 fn detect_with_valid_binary_cache() {
471 let dir = tempfile::tempdir().expect("tempdir");
472 let db_path = dir.path().join("bones-projection.sqlite3");
474 let conn = open_projection(&db_path).expect("open projection");
475
476 let cache_dir = dir.path().join("cache");
478 std::fs::create_dir_all(&cache_dir).expect("create cache dir");
479 let mut content = Vec::from(CACHE_MAGIC);
480 content.extend_from_slice(&[0u8; 28]);
481 std::fs::write(cache_dir.join("events.bin"), &content).expect("write cache");
482
483 let caps = detect_capabilities(&conn);
484 assert!(
485 caps.fs.binary_cache,
486 "binary_cache should be true with valid events.bin"
487 );
488 }
489
490 #[test]
491 fn detect_binary_cache_false_with_bad_magic() {
492 let dir = tempfile::tempdir().expect("tempdir");
493 let db_path = dir.path().join("bones-projection.sqlite3");
494 let conn = open_projection(&db_path).expect("open projection");
495
496 let cache_dir = dir.path().join("cache");
497 std::fs::create_dir_all(&cache_dir).expect("create cache dir");
498 std::fs::write(cache_dir.join("events.bin"), b"BADMAGIC").expect("write");
499
500 let caps = detect_capabilities(&conn);
501 assert!(
502 !caps.fs.binary_cache,
503 "binary_cache should be false with bad magic"
504 );
505 }
506
507 #[test]
512 fn describe_returns_five_entries() {
513 let caps = Capabilities::default();
514 let statuses = describe_capabilities(&caps);
515 assert_eq!(statuses.len(), 5);
516 }
517
518 #[test]
519 fn describe_names_are_stable() {
520 let caps = Capabilities::default();
521 let statuses = describe_capabilities(&caps);
522 let names: Vec<_> = statuses.iter().map(|s| s.name).collect();
523 assert_eq!(
524 names,
525 &["fts5", "semantic", "vectors", "binary_cache", "triage"]
526 );
527 }
528
529 #[test]
530 fn describe_available_flags_match_capabilities() {
531 let caps = Capabilities {
532 db: DbCapabilities {
533 fts5: true,
534 vectors: true,
535 triage: true,
536 },
537 fs: FsCapabilities {
538 semantic: false,
539 binary_cache: false,
540 },
541 };
542 let statuses = describe_capabilities(&caps);
543 let map: std::collections::HashMap<_, _> =
544 statuses.iter().map(|s| (s.name, s.available)).collect();
545 assert!(map["fts5"]);
546 assert!(!map["semantic"]);
547 assert!(map["vectors"]);
548 assert!(!map["binary_cache"]);
549 assert!(map["triage"]);
550 }
551
552 #[test]
553 fn describe_fallbacks_are_non_empty() {
554 let caps = Capabilities::default();
555 let statuses = describe_capabilities(&caps);
556 for status in &statuses {
557 assert!(
558 !status.fallback.is_empty(),
559 "fallback for {} is empty",
560 status.name
561 );
562 }
563 }
564
565 #[test]
566 fn capabilities_default_is_all_false() {
567 let caps = Capabilities::default();
568 assert!(!caps.db.fts5);
569 assert!(!caps.fs.semantic);
570 assert!(!caps.db.vectors);
571 assert!(!caps.fs.binary_cache);
572 assert!(!caps.db.triage);
573 }
574}