Skip to main content

kmp_embedded/
engine.rs

1//! Which storage engine a fresh data directory gets
2//! ([ADR-018](../../../docs/adr/ADR-018-multi-process-embedded-store.md)).
3//!
4//! The choice is made once, when a directory is created, and recorded in its
5//! `FORMAT_VERSION`. `KMP_MCP_ENGINE` says what a *fresh* directory should
6//! be; an existing directory opens with the engine it was created with, and
7//! asking for a different one is refused rather than quietly ignored — a
8//! store that opens as the wrong engine behind a user's back is exactly the
9//! silent divergence that ends in "why can't my second host open it".
10
11use std::path::Path;
12
13use kmp_adapter_embedded::StorageEngine;
14use kmp_domain::PortError;
15
16/// Explicit engine override for a fresh data directory. Without it, the
17/// user-facing binary chooses SQLite when compiled and redb otherwise;
18/// existing directories always open from their stamp.
19pub const ENGINE_ENV: &str = "KMP_MCP_ENGINE";
20
21/// Parses an engine name the way the environment variable and the CLI spell
22/// it. Pure, for testing; [`resolve_engine_from_env`] feeds it.
23pub fn parse_engine(value: &str) -> Result<StorageEngine, PortError> {
24    match value.trim().to_ascii_lowercase().as_str() {
25        "redb" => Ok(StorageEngine::Redb),
26        "sqlite" => Ok(StorageEngine::Sqlite),
27        other => Err(PortError::InvalidState(format!(
28            "unknown storage engine `{other}`; {ENGINE_ENV} accepts `redb` or `sqlite`"
29        ))),
30    }
31}
32
33/// The engine `KMP_MCP_ENGINE` asks for, if it is set. Unset or empty means
34/// "no preference": a fresh directory gets the default, an existing one
35/// opens as it is.
36pub fn resolve_engine_from_env() -> Result<Option<StorageEngine>, PortError> {
37    match std::env::var(ENGINE_ENV) {
38        Ok(value) if !value.trim().is_empty() => parse_engine(&value).map(Some),
39        _ => Ok(None),
40    }
41}
42
43/// Resolves the engine for a particular data directory.
44///
45/// An explicit environment choice is always authoritative. With no choice,
46/// an existing store is opened as stamped; a fresh store prefers SQLite when
47/// this build ships it, otherwise it retains the pure-Rust redb fallback.
48/// This is intentionally data-dir-aware: returning SQLite blindly would make
49/// an upgraded binary refuse every existing redb store.
50pub fn resolve_engine_for_data_dir_from_env(
51    data_dir: &Path,
52) -> Result<Option<StorageEngine>, PortError> {
53    if let Some(engine) = resolve_engine_from_env()? {
54        return Ok(Some(engine));
55    }
56    Ok(default_engine_for_data_dir(data_dir))
57}
58
59/// Implicit engine choice for a data directory when no operator override is
60/// present. Existing stores defer to their stamp; fresh stores prefer the
61/// shareable engine when this build carries it.
62pub fn default_engine_for_data_dir(data_dir: &Path) -> Option<StorageEngine> {
63    if data_dir.join("FORMAT_VERSION").exists() {
64        return None;
65    }
66    StorageEngine::Sqlite
67        .is_compiled()
68        .then_some(StorageEngine::Sqlite)
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn engine_names_are_case_insensitive_and_trimmed() {
77        assert_eq!(
78            parse_engine("redb").expect("redb parses"),
79            StorageEngine::Redb
80        );
81        assert_eq!(
82            parse_engine(" SQLite ").expect("sqlite parses"),
83            StorageEngine::Sqlite
84        );
85    }
86
87    #[test]
88    fn unknown_engine_names_the_variable_and_the_choices() {
89        let error = parse_engine("postgres").expect_err("not an embedded engine");
90        let message = error.to_string();
91        assert!(message.contains("postgres"), "{message}");
92        assert!(message.contains(ENGINE_ENV), "{message}");
93        assert!(message.contains("`redb` or `sqlite`"), "{message}");
94    }
95
96    #[test]
97    fn compiled_sqlite_is_only_the_implicit_choice_for_a_fresh_directory() {
98        let temp = tempfile::tempdir().expect("tempdir");
99        let fresh = temp.path().join("fresh");
100        let expected = StorageEngine::Sqlite
101            .is_compiled()
102            .then_some(StorageEngine::Sqlite);
103        assert_eq!(default_engine_for_data_dir(&fresh), expected);
104
105        std::fs::create_dir_all(&fresh).expect("data dir");
106        std::fs::write(fresh.join("FORMAT_VERSION"), "1\n").expect("stamp");
107        assert_eq!(
108            default_engine_for_data_dir(&fresh),
109            None,
110            "an existing store must be opened from its stamp"
111        );
112    }
113}