Skip to main content

arama_cache/core/
engine.rs

1//! Engine-facing configuration and helpers.
2//!
3//! This module owns the vocabulary that used to come from the in-house
4//! `file-feature-cache` engine ([`DbLocation`], [`CacheConfig`],
5//! [`CacheError`], [`Result`]) and the glue that maps it onto
6//! [`localcache`] (RFC 002).
7//!
8//! Design choices (see `rfcs/done/002-replace-cache-engine-with-localcache.md`):
9//!
10//! - One SQLite database file, two namespaces (`"image"` / `"video"`).
11//! - Change detection: [`ChangeDetectionMode::MetadataThenFullHash`] —
12//!   unchanged files are verified by `mtime` + size alone (no hashing),
13//!   changed files are confirmed with a full BLAKE3 hash.
14//!   `localcache` has stored `mtime` at nanosecond precision since v0.20,
15//!   so there is no same-second blind window.
16//! - Payload versioning: bump the constants below whenever the embedding
17//!   pipeline or the thumbnail format changes in a way that invalidates
18//!   stored payloads. Entries with a stale version are purged when a
19//!   writer opens the cache.
20
21use std::path::{Path, PathBuf};
22
23use localcache::{CacheEngine, CacheOptions, CacheStatus, ChangeDetectionMode};
24use serde::{Serialize, de::DeserializeOwned};
25
26// ---------------------------------------------------------------------------
27// Namespaces and payload versions
28// ---------------------------------------------------------------------------
29
30/// Namespace for image entries inside the shared cache database.
31pub(crate) const NAMESPACE_IMAGE: &str = "image";
32/// Namespace for video entries inside the shared cache database.
33pub(crate) const NAMESPACE_VIDEO: &str = "video";
34
35/// Version of the image payload layout / pipeline. Bump to invalidate.
36pub(crate) const IMAGE_PAYLOAD_VERSION: u32 = 1;
37/// Version of the video payload layout / pipeline. Bump to invalidate.
38pub(crate) const VIDEO_PAYLOAD_VERSION: u32 = 1;
39
40// ---------------------------------------------------------------------------
41// Error / Result
42// ---------------------------------------------------------------------------
43
44/// Errors produced by the `arama-cache` facade.
45#[derive(Debug, thiserror::Error)]
46pub enum CacheError {
47    /// Error bubbled up from the `localcache` engine.
48    #[error("cache engine error: {0}")]
49    Engine(#[from] localcache::LocalFileCacheError),
50
51    /// Thumbnail generation failed (image decode, resize, or ffmpeg).
52    #[error("thumbnail generation failed: {0}")]
53    ThumbnailGenerationFailed(String),
54
55    /// Filesystem error with the offending path attached.
56    #[error("I/O error for '{path}': {source}")]
57    Io {
58        path: String,
59        #[source]
60        source: std::io::Error,
61    },
62
63    /// One-time migration from the v1 cache database failed.
64    #[error("v1 cache migration failed: {0}")]
65    Migration(String),
66}
67
68impl CacheError {
69    pub(crate) fn io(path: &Path, source: std::io::Error) -> Self {
70        CacheError::Io {
71            path: path.to_string_lossy().into_owned(),
72            source,
73        }
74    }
75}
76
77/// Convenience alias used across the crate and by consumers.
78pub type Result<T> = std::result::Result<T, CacheError>;
79
80// ---------------------------------------------------------------------------
81// DbLocation
82// ---------------------------------------------------------------------------
83
84/// Where the cache database file lives.
85#[derive(Debug, Clone)]
86pub enum DbLocation {
87    /// Fully specified path.
88    Custom(PathBuf),
89
90    /// XDG cache directory: `$XDG_CACHE_HOME/<app>/<name>`.
91    ///
92    /// `name` defaults to `cache.db` when `None`. The application name is
93    /// derived from the executable file name.
94    AppCache(Option<String>),
95
96    /// Current working directory: `./<name>`.
97    ///
98    /// `name` defaults to `cache.db` when `None`.
99    WorkDir(Option<String>),
100}
101
102impl Default for DbLocation {
103    fn default() -> Self {
104        Self::WorkDir(None)
105    }
106}
107
108impl DbLocation {
109    /// Resolve to a concrete filesystem path.
110    pub fn resolve(&self) -> PathBuf {
111        match self {
112            Self::Custom(p) => p.clone(),
113
114            Self::AppCache(name) => {
115                let base = std::env::var("XDG_CACHE_HOME")
116                    .map(PathBuf::from)
117                    .unwrap_or_else(|_| {
118                        std::env::var("HOME")
119                            .map(|h| PathBuf::from(h).join(".cache"))
120                            .unwrap_or_else(|_| PathBuf::from(".cache"))
121                    });
122                let app = std::env::current_exe()
123                    .ok()
124                    .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned()))
125                    .unwrap_or_else(|| "app".to_string());
126                base.join(app).join(name.as_deref().unwrap_or("cache.db"))
127            }
128
129            Self::WorkDir(name) => {
130                PathBuf::from(format!("./{}", name.as_deref().unwrap_or("cache.db")))
131            }
132        }
133    }
134}
135
136// ---------------------------------------------------------------------------
137// CacheConfig
138// ---------------------------------------------------------------------------
139
140/// Session-level configuration shared by image and video handles.
141#[derive(Debug, Clone)]
142pub struct CacheConfig {
143    pub db_location: DbLocation,
144    /// Number of read-only connections in the read pool. Align with the
145    /// expected level of read parallelism (e.g. the rayon thread count).
146    pub read_conns: u32,
147    /// Directory where thumbnail files are stored. `None` disables
148    /// thumbnail management entirely.
149    pub thumbnail_dir: Option<PathBuf>,
150}
151
152impl Default for CacheConfig {
153    fn default() -> Self {
154        Self {
155            db_location: DbLocation::default(),
156            read_conns: num_cpus(),
157            thumbnail_dir: None,
158        }
159    }
160}
161
162fn num_cpus() -> u32 {
163    std::thread::available_parallelism()
164        .map(|n| n.get() as u32)
165        .unwrap_or(4)
166}
167
168// ---------------------------------------------------------------------------
169// localcache glue
170// ---------------------------------------------------------------------------
171
172/// Create the parent directory of the database file if it does not yet
173/// exist.
174///
175/// `localcache` — and SQLite underneath it — does not create intermediate
176/// directories. When they are absent, `Connection::open` fails with
177/// `SQLITE_CANTOPEN (14)` ("unable to open database file"). The previous
178/// `file-feature-cache` engine called `validate_dir` before opening;
179/// this helper restores that guarantee.
180pub(crate) fn ensure_db_dir(options: &CacheOptions) -> Result<()> {
181    if let Some(parent) = options.database_path.parent() {
182        std::fs::create_dir_all(parent).map_err(|e| CacheError::io(parent, e))?;
183    }
184    Ok(())
185}
186
187/// Build the [`CacheOptions`] shared by all handles for one namespace.
188pub(crate) fn cache_options(
189    config: &CacheConfig,
190    namespace: &str,
191    payload_version: u32,
192) -> CacheOptions {
193    CacheOptions {
194        database_path: config.db_location.resolve(),
195        change_detection_mode: ChangeDetectionMode::MetadataThenFullHash,
196        namespace: namespace.to_owned(),
197        payload_version,
198        ..CacheOptions::default()
199    }
200}
201
202/// Ensure the database file exists and its schema is initialized.
203///
204/// Read-only connections (the read pool) skip schema creation, so a
205/// standalone reader on a never-written database would fail its first
206/// query. Opening (and immediately dropping) one writable engine first
207/// creates the file and runs migrations — the same pattern `localcache`'s
208/// own `ReadPool` tests use.
209pub(crate) fn ensure_schema<T>(options: &CacheOptions) -> Result<()>
210where
211    T: Serialize + DeserializeOwned,
212{
213    let _engine: CacheEngine<T> = CacheEngine::open(options.clone())?;
214    Ok(())
215}
216
217/// Number of read-pool slots for a config (`read_conns`, at least 1).
218pub(crate) fn read_pool_size(config: &CacheConfig) -> usize {
219    (config.read_conns.max(1)) as usize
220}
221
222// ---------------------------------------------------------------------------
223// Status mapping
224// ---------------------------------------------------------------------------
225
226/// Map a `localcache` freshness status onto the legacy three-state
227/// decision used by [`crate::types::LookupResult`]:
228///
229/// | `CacheStatus` | meaning here |
230/// |---|---|
231/// | `Missing` | no entry (or the file itself is gone) → `Miss` |
232/// | `Stale` | entry exists but the file changed → `Invalidated` |
233/// | `Fresh` | entry is valid → `Hit` (caller loads the payload) |
234pub(crate) fn is_fresh(status: &CacheStatus) -> bool {
235    matches!(status, CacheStatus::Fresh)
236}