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).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
249/// Returns `true` if the triage engine can operate.
250///
251/// Petgraph is a compile-time dependency and is always available. This probe
252/// verifies that the underlying `items` table is queryable, which is the
253/// runtime precondition for building the dependency graph.
254fn 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
265/// Derive the `.bones` directory path from the database connection.
266///
267/// Uses `PRAGMA database_list` to find the on-disk path of the `main` database,
268/// then returns its parent directory (which is expected to be `.bones`).
269///
270/// Returns `None` for in-memory connections or when the path cannot be
271/// determined.
272fn 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// ---------------------------------------------------------------------------
286// Tests
287// ---------------------------------------------------------------------------
288
289#[cfg(test)]
290mod tests {
291    use tempfile::TempDir;
292
293    use super::*;
294    use crate::db::{migrations, open_projection};
295
296    // -----------------------------------------------------------------------
297    // Helpers
298    // -----------------------------------------------------------------------
299
300    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        // In-memory DB with no schema at all.
309        Connection::open_in_memory().expect("open in-memory db")
310    }
311
312    // -----------------------------------------------------------------------
313    // probe_fts5
314    // -----------------------------------------------------------------------
315
316    #[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        // Migration v2 creates items_fts.
326        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    // -----------------------------------------------------------------------
334    // probe_vectors
335    // -----------------------------------------------------------------------
336
337    #[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    // -----------------------------------------------------------------------
348    // probe_semantic_model
349    // -----------------------------------------------------------------------
350
351    #[test]
352    fn semantic_model_is_false_in_ci() {
353        // The MiniLM model is never present in the CI environment.
354        // This test documents the expected degradation path.
355        let result = probe_semantic_model();
356        // Either true (dev machine with model) or false (CI/fresh checkout).
357        // We just verify the probe doesn't panic.
358        let _ = result;
359    }
360
361    // -----------------------------------------------------------------------
362    // probe_binary_cache
363    // -----------------------------------------------------------------------
364
365    #[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        // Write a file with valid BNCH magic followed by padding.
377        let mut content = Vec::from(CACHE_MAGIC);
378        content.extend_from_slice(&[0u8; 28]); // pad to HEADER_SIZE
379        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        // File too short to read 4 magic bytes.
396        std::fs::write(&path, b"BN").expect("write truncated");
397        assert!(!probe_binary_cache(&path));
398    }
399
400    // -----------------------------------------------------------------------
401    // probe_triage
402    // -----------------------------------------------------------------------
403
404    #[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        // No items inserted — but the query succeeds, so triage = true.
414        assert!(probe_triage(&conn));
415    }
416
417    // -----------------------------------------------------------------------
418    // bones_dir_from_db
419    // -----------------------------------------------------------------------
420
421    #[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        // Canonicalize to resolve macOS /var → /private/var symlinks.
431        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    // -----------------------------------------------------------------------
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}