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}