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).is_ok() && magic == CACHE_MAGIC
239 }
240 Err(e) => {
241 debug!(error = %e, "binary_cache probe: cannot open file");
242 false
243 }
244 };
245 debug!(available, path = %events_bin.display(), "binary_cache probe");
246 available
247}
248
249fn probe_triage(db: &Connection) -> bool {
255 let result = db.query_row(
256 "SELECT COUNT(*) FROM items WHERE is_deleted = 0",
257 [],
258 |row| row.get::<_, i64>(0),
259 );
260 let available = result.is_ok();
261 debug!(available, "triage probe");
262 available
263}
264
265fn bones_dir_from_db(db: &Connection) -> Option<PathBuf> {
273 let mut stmt = db.prepare("PRAGMA database_list").ok()?;
274 let mut rows = stmt.query([]).ok()?;
275 while let Ok(Some(row)) = rows.next() {
276 let name: String = row.get(1).unwrap_or_default();
277 let file: String = row.get(2).unwrap_or_default();
278 if name == "main" && !file.is_empty() {
279 return PathBuf::from(file).parent().map(ToOwned::to_owned);
280 }
281 }
282 None
283}
284
285#[cfg(test)]
290mod tests {
291 use tempfile::TempDir;
292
293 use super::*;
294 use crate::db::{migrations, open_projection};
295
296 fn migrated_db() -> (TempDir, Connection) {
301 let dir = tempfile::tempdir().expect("tempdir");
302 let path = dir.path().join("bones-projection.sqlite3");
303 let conn = open_projection(&path).expect("open projection db");
304 (dir, conn)
305 }
306
307 fn bare_db() -> Connection {
308 Connection::open_in_memory().expect("open in-memory db")
310 }
311
312 #[test]
317 fn fts5_is_false_on_bare_db() {
318 let conn = bare_db();
319 assert!(!probe_fts5(&conn));
320 }
321
322 #[test]
323 fn fts5_is_true_after_migration() {
324 let (_dir, conn) = migrated_db();
325 assert!(
327 migrations::current_schema_version(&conn).expect("version") >= 2,
328 "test assumes migration v2+ is applied"
329 );
330 assert!(probe_fts5(&conn));
331 }
332
333 #[test]
338 fn vectors_probe_matches_direct_query() {
339 let conn = bare_db();
340 let probed = probe_vectors(&conn);
341 let direct = conn
342 .query_row("SELECT vec_version()", [], |row| row.get::<_, String>(0))
343 .is_ok();
344 assert_eq!(probed, direct);
345 }
346
347 #[test]
352 fn semantic_model_is_false_in_ci() {
353 let result = probe_semantic_model();
356 let _ = result;
359 }
360
361 #[test]
366 fn binary_cache_false_for_missing_file() {
367 let dir = tempfile::tempdir().expect("tempdir");
368 let path = dir.path().join("events.bin");
369 assert!(!probe_binary_cache(&path));
370 }
371
372 #[test]
373 fn binary_cache_true_for_valid_magic() {
374 let dir = tempfile::tempdir().expect("tempdir");
375 let path = dir.path().join("events.bin");
376 let mut content = Vec::from(CACHE_MAGIC);
378 content.extend_from_slice(&[0u8; 28]); std::fs::write(&path, &content).expect("write cache");
380 assert!(probe_binary_cache(&path));
381 }
382
383 #[test]
384 fn binary_cache_false_for_wrong_magic() {
385 let dir = tempfile::tempdir().expect("tempdir");
386 let path = dir.path().join("events.bin");
387 std::fs::write(&path, b"WXYZ\x00\x00\x00\x00").expect("write bad magic");
388 assert!(!probe_binary_cache(&path));
389 }
390
391 #[test]
392 fn binary_cache_false_for_truncated_file() {
393 let dir = tempfile::tempdir().expect("tempdir");
394 let path = dir.path().join("events.bin");
395 std::fs::write(&path, b"BN").expect("write truncated");
397 assert!(!probe_binary_cache(&path));
398 }
399
400 #[test]
405 fn triage_false_on_bare_db_no_items_table() {
406 let conn = bare_db();
407 assert!(!probe_triage(&conn));
408 }
409
410 #[test]
411 fn triage_true_after_migration_zero_items() {
412 let (_dir, conn) = migrated_db();
413 assert!(probe_triage(&conn));
415 }
416
417 #[test]
422 fn bones_dir_none_for_in_memory_db() {
423 let conn = bare_db();
424 assert!(bones_dir_from_db(&conn).is_none());
425 }
426
427 #[test]
428 fn bones_dir_is_parent_of_db_file() {
429 let dir = tempfile::tempdir().expect("tempdir");
430 let canonical_dir = dir.path().canonicalize().expect("canonicalize tempdir");
432 let db_path = canonical_dir.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(canonical_dir.as_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}