Skip to main content

bones_core/
capabilities.rs

1//! Runtime capability detection for optional bones subsystems.
2//!
3//! This module probes the active `SQLite` database and filesystem to determine
4//! which optional features are available at startup. The result is a
5//! [`Capabilities`] struct that callers use to choose between full-featured
6//! and gracefully-degraded code paths — without panicking on missing deps.
7//!
8//! # Design
9//!
10//! Every probe is infallible from the caller's perspective: it returns a
11//! `bool`, logs the outcome at `debug!` level, and never propagates errors.
12//! This ensures the CLI remains usable even when subsystems are broken or not
13//! yet initialised.
14//!
15//! # Usage
16//!
17//! ```rust,no_run
18//! use bones_core::capabilities::{detect_capabilities, describe_capabilities};
19//! use bones_core::db::open_projection;
20//! use std::path::Path;
21//!
22//! let conn = open_projection(Path::new(".bones/bones-projection.sqlite3")).unwrap();
23//! let caps = detect_capabilities(&conn);
24//! if !caps.db.fts5 {
25//!     eprintln!("FTS5 not available — falling back to LIKE queries");
26//! }
27//! for status in describe_capabilities(&caps) {
28//!     if !status.available {
29//!         eprintln!("[{}] degraded: {}", status.name, status.fallback);
30//!     }
31//! }
32//! ```
33
34use std::io::Read as _;
35use std::path::{Path, PathBuf};
36
37use rusqlite::Connection;
38use tracing::debug;
39
40use crate::cache::CACHE_MAGIC;
41
42// ---------------------------------------------------------------------------
43// Public types
44// ---------------------------------------------------------------------------
45
46/// Runtime capability flags detected at startup.
47///
48/// Each flag indicates whether a specific optional subsystem is functional.
49/// Consumers should check the relevant flag before calling into the subsystem
50/// and fall back gracefully when it is `false`.
51///
52/// Capabilities are grouped into database-derived and filesystem-derived
53/// sub-structs to keep each group small.
54#[derive(Debug, Clone, PartialEq, Eq, Default)]
55pub struct Capabilities {
56    /// Capabilities detected by probing the `SQLite` database.
57    pub db: DbCapabilities,
58    /// Capabilities detected by probing the filesystem.
59    pub fs: FsCapabilities,
60}
61
62/// Database-derived capability flags.
63#[derive(Debug, Clone, PartialEq, Eq, Default)]
64pub struct DbCapabilities {
65    /// `SQLite` FTS5 extension is available and index (`items_fts`) is built.
66    pub fts5: bool,
67    /// sqlite-vec extension is available for vector acceleration.
68    pub vectors: bool,
69    /// Triage engine dependencies (petgraph, items table) are available.
70    pub triage: bool,
71}
72
73/// Filesystem-derived capability flags.
74#[derive(Debug, Clone, PartialEq, Eq, Default)]
75pub struct FsCapabilities {
76    /// Semantic search model assets are available.
77    pub semantic: bool,
78    /// Binary columnar cache (`events.bin`) exists and has a valid header.
79    pub binary_cache: bool,
80}
81
82/// Status of a single capability for user-visible display.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct CapabilityStatus {
85    /// Short machine-readable name of the capability.
86    pub name: &'static str,
87    /// Whether the capability is currently available.
88    pub available: bool,
89    /// Human-readable description of what the system does when this
90    /// capability is missing.
91    pub fallback: &'static str,
92}
93
94// ---------------------------------------------------------------------------
95// Public API
96// ---------------------------------------------------------------------------
97
98/// Detect available capabilities by probing the database and filesystem.
99///
100/// Checks performed:
101/// - **fts5**: `items_fts` virtual table present in `sqlite_master`
102/// - **vectors**: `vec_version()` SQL function is callable (sqlite-vec loaded)
103/// - **semantic**: `MiniLM` model+tokenizer files available on disk
104/// - **`binary_cache`**: `.bones/cache/events.bin` exists with valid `BNCH` magic
105/// - **triage**: `items` table queryable (petgraph is always compiled in)
106///
107/// All probes are infallible — errors are logged at `debug!` and treated as
108/// capability absent.
109///
110/// # Arguments
111///
112/// * `db` — An open `SQLite` projection database connection.
113#[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/// Describe which capabilities are active or missing for user display.
144///
145/// Returns a [`Vec`] of [`CapabilityStatus`] entries in a stable order.
146/// Each entry contains the capability name, availability flag, and the
147/// fallback behaviour description used when the capability is absent.
148#[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
179// ---------------------------------------------------------------------------
180// Internal probes
181// ---------------------------------------------------------------------------
182
183/// Returns `true` if the `items_fts` FTS5 virtual table exists in `sqlite_master`.
184fn 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
203/// Returns `true` if the sqlite-vec extension is loaded.
204///
205/// Probes by calling `vec_version()` — a function exported only by sqlite-vec.
206fn 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
213/// Returns `true` if the MiniLM-L6-v2 ONNX model file exists on disk.
214///
215/// Uses the same cache path convention as `SemanticModel::model_cache_path()`:
216/// `<os-cache-dir>/bones/models/minilm-l6-v2-int8.onnx`.
217fn 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
229/// Returns `true` if `events.bin` exists and begins with the `BNCH` magic bytes.
230fn 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
251/// Returns `true` if the triage engine can operate.
252///
253/// Petgraph is a compile-time dependency and is always available. This probe
254/// verifies that the underlying `items` table is queryable, which is the
255/// runtime precondition for building the dependency graph.
256fn 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
267/// Derive the `.bones` directory path from the database connection.
268///
269/// Uses `PRAGMA database_list` to find the on-disk path of the `main` database,
270/// then returns its parent directory (which is expected to be `.bones`).
271///
272/// Returns `None` for in-memory connections or when the path cannot be
273/// determined.
274fn 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// ---------------------------------------------------------------------------
288// Tests
289// ---------------------------------------------------------------------------
290
291#[cfg(test)]
292mod tests {
293    use tempfile::TempDir;
294
295    use super::*;
296    use crate::db::{migrations, open_projection};
297
298    // -----------------------------------------------------------------------
299    // Helpers
300    // -----------------------------------------------------------------------
301
302    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        // In-memory DB with no schema at all.
311        Connection::open_in_memory().expect("open in-memory db")
312    }
313
314    // -----------------------------------------------------------------------
315    // probe_fts5
316    // -----------------------------------------------------------------------
317
318    #[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        // Migration v2 creates items_fts.
328        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    // -----------------------------------------------------------------------
336    // probe_vectors
337    // -----------------------------------------------------------------------
338
339    #[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    // -----------------------------------------------------------------------
350    // probe_semantic_model
351    // -----------------------------------------------------------------------
352
353    #[test]
354    fn semantic_model_is_false_in_ci() {
355        // The MiniLM model is never present in the CI environment.
356        // This test documents the expected degradation path.
357        let result = probe_semantic_model();
358        // Either true (dev machine with model) or false (CI/fresh checkout).
359        // We just verify the probe doesn't panic.
360        let _ = result;
361    }
362
363    // -----------------------------------------------------------------------
364    // probe_binary_cache
365    // -----------------------------------------------------------------------
366
367    #[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        // Write a file with valid BNCH magic followed by padding.
379        let mut content = Vec::from(CACHE_MAGIC);
380        content.extend_from_slice(&[0u8; 28]); // pad to HEADER_SIZE
381        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        // File too short to read 4 magic bytes.
398        std::fs::write(&path, b"BN").expect("write truncated");
399        assert!(!probe_binary_cache(&path));
400    }
401
402    // -----------------------------------------------------------------------
403    // probe_triage
404    // -----------------------------------------------------------------------
405
406    #[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        // No items inserted — but the query succeeds, so triage = true.
416        assert!(probe_triage(&conn));
417    }
418
419    // -----------------------------------------------------------------------
420    // bones_dir_from_db
421    // -----------------------------------------------------------------------
422
423    #[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    // -----------------------------------------------------------------------
439    // detect_capabilities (integration)
440    // -----------------------------------------------------------------------
441
442    #[test]
443    fn detect_on_migrated_db_has_fts5() {
444        let (_dir, conn) = migrated_db();
445        let caps = detect_capabilities(&conn);
446        // FTS5 index is built by migration v2.
447        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        // vectors may be available when sqlite-vec is auto-registered.
456        // semantic may be true on developer machines where model assets are
457        // present in cache; this test only asserts DB-derived capabilities.
458        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        // DB lives inside dir so bones_dir_from_db returns dir.path().
473        let db_path = dir.path().join("bones-projection.sqlite3");
474        let conn = open_projection(&db_path).expect("open projection");
475
476        // Create cache dir and write a valid events.bin.
477        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    // -----------------------------------------------------------------------
508    // describe_capabilities
509    // -----------------------------------------------------------------------
510
511    #[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}