fakecloud_persistence/
version.rs1use std::io;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7pub const FORMAT_VERSION: u32 = 1;
8pub const VERSION_FILE_NAME: &str = "fakecloud.version.toml";
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct FormatVersion {
12 pub format_version: u32,
13 pub fakecloud_version: String,
14 pub created_at: String,
15}
16
17#[derive(Debug, Error)]
18pub enum VersionError {
19 #[error("io error at {path}: {source}")]
20 Io {
21 path: PathBuf,
22 #[source]
23 source: io::Error,
24 },
25 #[error("failed to parse {path}: {source}")]
26 Parse {
27 path: PathBuf,
28 #[source]
29 source: toml::de::Error,
30 },
31 #[error("failed to serialize version file: {0}")]
32 Serialize(#[from] toml::ser::Error),
33 #[error(
34 "persistence format version mismatch at {path}: on-disk format_version={on_disk}, binary expects {expected}. \
35 Either point --data-path at a matching directory, or delete the directory to start fresh."
36 )]
37 FormatMismatch {
38 path: PathBuf,
39 on_disk: u32,
40 expected: u32,
41 },
42 #[error(
43 "persistence data directory {dir} is not empty but has no {file} file. \
44 Refusing to initialize it: either point --data-path at an empty directory or restore the missing version file."
45 )]
46 NonEmptyDirectoryWithoutVersionFile { dir: PathBuf, file: String },
47}
48
49fn version_file_path(dir: &Path) -> PathBuf {
50 dir.join(VERSION_FILE_NAME)
51}
52
53pub fn write_version_file(dir: &Path, fakecloud_version: &str) -> Result<(), VersionError> {
54 let path = version_file_path(dir);
55 let value = FormatVersion {
56 format_version: FORMAT_VERSION,
57 fakecloud_version: fakecloud_version.to_string(),
58 created_at: chrono::Utc::now().to_rfc3339(),
59 };
60 crate::atomic::write_atomic_toml(&path, &value).map_err(|source| VersionError::Io {
61 path: path.clone(),
62 source,
63 })?;
64 Ok(())
65}
66
67pub fn check_version_file(dir: &Path) -> Result<(), VersionError> {
68 let path = version_file_path(dir);
69 if !path.exists() {
70 return Ok(());
71 }
72 let text = std::fs::read_to_string(&path).map_err(|source| VersionError::Io {
73 path: path.clone(),
74 source,
75 })?;
76 let parsed: FormatVersion = toml::from_str(&text).map_err(|source| VersionError::Parse {
77 path: path.clone(),
78 source,
79 })?;
80 if parsed.format_version != FORMAT_VERSION {
81 return Err(VersionError::FormatMismatch {
82 path,
83 on_disk: parsed.format_version,
84 expected: FORMAT_VERSION,
85 });
86 }
87 Ok(())
88}
89
90pub fn ensure_version_file(dir: &Path, fakecloud_version: &str) -> Result<(), VersionError> {
91 let path = version_file_path(dir);
92 if path.exists() {
93 return check_version_file(dir);
94 }
95 if dir.exists() {
96 let mut entries = std::fs::read_dir(dir).map_err(|source| VersionError::Io {
97 path: dir.to_path_buf(),
98 source,
99 })?;
100 if entries.next().is_some() {
101 return Err(VersionError::NonEmptyDirectoryWithoutVersionFile {
102 dir: dir.to_path_buf(),
103 file: VERSION_FILE_NAME.to_string(),
104 });
105 }
106 }
107 write_version_file(dir, fakecloud_version)
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn ensure_creates_version_file_in_empty_dir() {
116 let tmp = tempfile::tempdir().unwrap();
117 ensure_version_file(tmp.path(), "test").unwrap();
118 assert!(tmp.path().join(VERSION_FILE_NAME).exists());
119 }
120
121 #[test]
122 fn ensure_rejects_non_empty_dir_without_version_file() {
123 let tmp = tempfile::tempdir().unwrap();
124 std::fs::write(tmp.path().join("stray.txt"), b"hello").unwrap();
125 let err = ensure_version_file(tmp.path(), "test").unwrap_err();
126 matches!(
127 err,
128 VersionError::NonEmptyDirectoryWithoutVersionFile { .. }
129 );
130 }
131
132 #[test]
133 fn ensure_ok_when_version_file_already_present() {
134 let tmp = tempfile::tempdir().unwrap();
135 write_version_file(tmp.path(), "test").unwrap();
136 std::fs::write(tmp.path().join("stray.txt"), b"hello").unwrap();
137 ensure_version_file(tmp.path(), "test").unwrap();
138 }
139}