kmp_adapter_embedded/adapter/
format_version.rs1use std::fs;
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4
5use kmp_domain::PortError;
6
7pub const SUPPORTED_FORMAT_VERSION: u32 = StorageEngine::Redb.format_version();
15
16pub const EVENT_FORMAT_VERSION: u32 = 1;
20
21const FORMAT_VERSION_FILE: &str = "FORMAT_VERSION";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum StorageEngine {
27 Redb,
29 Sqlite,
33}
34
35impl StorageEngine {
36 pub const fn format_version(self) -> u32 {
38 match self {
39 StorageEngine::Redb => 1,
40 StorageEngine::Sqlite => 2,
41 }
42 }
43
44 pub(crate) const NEWEST_KNOWN_FORMAT_VERSION: u32 = StorageEngine::Sqlite.format_version();
47
48 pub(crate) const fn from_format_version(version: u32) -> Option<Self> {
49 match version {
50 1 => Some(StorageEngine::Redb),
51 2 => Some(StorageEngine::Sqlite),
52 _ => None,
53 }
54 }
55
56 pub const fn is_compiled(self) -> bool {
58 match self {
59 StorageEngine::Redb => true,
60 StorageEngine::Sqlite => cfg!(feature = "sqlite"),
61 }
62 }
63
64 pub const fn name(self) -> &'static str {
65 match self {
66 StorageEngine::Redb => "redb",
67 StorageEngine::Sqlite => "sqlite",
68 }
69 }
70
71 const fn store_file_name(self) -> &'static str {
72 match self {
73 StorageEngine::Redb => "kernel.redb",
74 StorageEngine::Sqlite => "kernel.sqlite3",
75 }
76 }
77
78 const ALL: [StorageEngine; 2] = [StorageEngine::Redb, StorageEngine::Sqlite];
79}
80
81impl std::fmt::Display for StorageEngine {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.write_str(self.name())
84 }
85}
86
87pub fn format_version_path(data_dir: &Path) -> PathBuf {
88 data_dir.join(FORMAT_VERSION_FILE)
89}
90
91pub(crate) fn read_stamped_version(data_dir: &Path) -> Result<u32, PortError> {
96 let version_path = format_version_path(data_dir);
97 let raw = fs::read_to_string(&version_path).map_err(|error| {
98 PortError::Unavailable(format!(
99 "could not read FORMAT_VERSION at `{}`: {error}",
100 version_path.display()
101 ))
102 })?;
103 raw.trim().parse().map_err(|_| {
104 PortError::InvalidState(format!(
105 "FORMAT_VERSION at `{}` is corrupt (`{}`)",
106 version_path.display(),
107 raw.trim()
108 ))
109 })
110}
111
112pub(crate) fn store_file_path_for(data_dir: &Path, engine: StorageEngine) -> PathBuf {
114 data_dir.join("store").join(engine.store_file_name())
115}
116
117fn any_store_file_exists(data_dir: &Path) -> bool {
120 StorageEngine::ALL
121 .iter()
122 .any(|engine| store_file_path_for(data_dir, *engine).exists())
123}
124
125pub(crate) fn existing_store_file(data_dir: &Path) -> Option<(StorageEngine, PathBuf)> {
130 let version = read_stamped_version(data_dir).ok()?;
131 let engine = StorageEngine::from_format_version(version)?;
132 let path = store_file_path_for(data_dir, engine);
133 path.exists().then_some((engine, path))
134}
135
136pub(crate) fn check_or_stamp(data_dir: &Path) -> Result<StorageEngine, PortError> {
142 check_or_stamp_as(data_dir, None)
143}
144
145pub(crate) fn check_or_stamp_as(
149 data_dir: &Path,
150 wanted: Option<StorageEngine>,
151) -> Result<StorageEngine, PortError> {
152 let version_path = format_version_path(data_dir);
153 match fs::read_to_string(&version_path) {
154 Ok(raw) => {
155 let version: u32 = raw.trim().parse().map_err(|_| {
156 PortError::InvalidState(format!(
157 "embedded store at `{}` has a corrupt FORMAT_VERSION (`{}`); refusing to open",
158 data_dir.display(),
159 raw.trim()
160 ))
161 })?;
162 let stamped = resolve_stamped(data_dir, version)?;
163 if let Some(wanted) = wanted
164 && wanted != stamped
165 {
166 return Err(PortError::InvalidState(format!(
167 "embedded store at `{}` is a {stamped} store (format version {}), not {wanted}; \
168 a store is never reopened with another engine — to change engines, migrate it: \
169 `kmp-mcp migrate <this-dir> <new-dir> --engine {wanted}`, or unset the engine \
170 to open it as it is",
171 data_dir.display(),
172 stamped.format_version()
173 )));
174 }
175 Ok(stamped)
176 }
177 Err(error) if error.kind() == ErrorKind::NotFound => {
178 if any_store_file_exists(data_dir) {
179 return Err(PortError::InvalidState(format!(
180 "embedded store at `{}` has a store file but no FORMAT_VERSION; the data \
181 directory layout is corrupt, refusing to open",
182 data_dir.display()
183 )));
184 }
185 let engine = wanted.unwrap_or(StorageEngine::Redb);
186 require_compiled(data_dir, engine)?;
187 fs::write(&version_path, format!("{}\n", engine.format_version())).map_err(
188 |error| {
189 PortError::Unavailable(format!(
190 "embedded store could not stamp FORMAT_VERSION at `{}`: {error}",
191 version_path.display()
192 ))
193 },
194 )?;
195 Ok(engine)
196 }
197 Err(error) => Err(PortError::Unavailable(format!(
198 "embedded store could not read FORMAT_VERSION at `{}`: {error}",
199 version_path.display()
200 ))),
201 }
202}
203
204fn resolve_stamped(data_dir: &Path, version: u32) -> Result<StorageEngine, PortError> {
208 if version > StorageEngine::NEWEST_KNOWN_FORMAT_VERSION {
209 return Err(PortError::InvalidState(format!(
210 "embedded store at `{}` uses format version {version}, newer than this \
211 binary supports ({}); upgrade the binary",
212 data_dir.display(),
213 StorageEngine::NEWEST_KNOWN_FORMAT_VERSION
214 )));
215 }
216 let Some(engine) = StorageEngine::from_format_version(version) else {
217 return Err(PortError::InvalidState(format!(
218 "embedded store at `{}` uses format version {version}, older than this \
219 binary supports ({SUPPORTED_FORMAT_VERSION}); migrate it with \
220 `kmp-mcp migrate <this-dir> <new-dir>` — the source is left untouched",
221 data_dir.display()
222 )));
223 };
224 require_compiled(data_dir, engine)?;
225 Ok(engine)
226}
227
228fn require_compiled(data_dir: &Path, engine: StorageEngine) -> Result<(), PortError> {
229 if engine.is_compiled() {
230 return Ok(());
231 }
232 Err(PortError::Unavailable(format!(
233 "embedded store at `{}` uses the {engine} engine (format version {}), which this \
234 binary was built without; rebuild with `--features {engine}`, or open it with a \
235 build that has it",
236 data_dir.display(),
237 engine.format_version()
238 )))
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn fresh_directory_is_stamped_with_supported_version() {
247 let dir = tempfile::tempdir().expect("tempdir");
248
249 let engine = check_or_stamp(dir.path()).expect("fresh directory should stamp");
250
251 assert_eq!(engine, StorageEngine::Redb);
252 let stamped = fs::read_to_string(format_version_path(dir.path())).expect("read stamp");
253 assert_eq!(stamped.trim(), SUPPORTED_FORMAT_VERSION.to_string());
254 check_or_stamp(dir.path()).expect("stamped directory should reopen");
255 }
256
257 #[test]
258 fn newer_format_version_fails_fast() {
259 let dir = tempfile::tempdir().expect("tempdir");
260 fs::write(format_version_path(dir.path()), "999\n").expect("write");
261
262 let error = check_or_stamp(dir.path()).expect_err("newer version must fail");
263 assert!(error.to_string().contains("upgrade the binary"));
264 }
265
266 #[test]
267 fn older_format_version_requires_migration() {
268 let dir = tempfile::tempdir().expect("tempdir");
269 fs::write(format_version_path(dir.path()), "0\n").expect("write");
270
271 let error = check_or_stamp(dir.path()).expect_err("older version must fail");
272 assert!(error.to_string().contains("kmp-mcp migrate"));
273 }
274
275 #[test]
276 fn corrupt_version_content_fails_fast() {
277 let dir = tempfile::tempdir().expect("tempdir");
278 fs::write(format_version_path(dir.path()), "not-a-number\n").expect("write");
279
280 let error = check_or_stamp(dir.path()).expect_err("corrupt version must fail");
281 assert!(error.to_string().contains("corrupt FORMAT_VERSION"));
282 }
283
284 #[test]
285 fn store_without_version_stamp_is_a_corrupt_layout() {
286 let dir = tempfile::tempdir().expect("tempdir");
287 let store = store_file_path_for(dir.path(), StorageEngine::Redb);
288 fs::create_dir_all(store.parent().expect("parent")).expect("mkdir");
289 fs::write(&store, b"stub").expect("write store stub");
290
291 let error = check_or_stamp(dir.path()).expect_err("missing stamp must fail");
292 assert!(error.to_string().contains("corrupt"));
293 }
294
295 #[test]
296 fn a_store_is_never_reopened_as_another_engine() {
297 let dir = tempfile::tempdir().expect("tempdir");
298 check_or_stamp(dir.path()).expect("stamps redb");
299
300 let error = check_or_stamp_as(dir.path(), Some(StorageEngine::Sqlite))
301 .expect_err("redb store must not open as sqlite");
302 assert!(error.to_string().contains("is a redb store"));
303 assert!(error.to_string().contains("not sqlite"));
304 }
305
306 #[test]
307 fn sqlite_layout_is_named_even_when_not_compiled() {
308 let dir = tempfile::tempdir().expect("tempdir");
309 fs::write(format_version_path(dir.path()), "2\n").expect("write");
310
311 let result = check_or_stamp(dir.path());
312 if cfg!(feature = "sqlite") {
313 assert_eq!(result.expect("sqlite compiled in"), StorageEngine::Sqlite);
314 } else {
315 let error = result.expect_err("sqlite not compiled in");
316 assert!(error.to_string().contains("sqlite engine"));
317 assert!(error.to_string().contains("--features sqlite"));
318 }
319 }
320}