Skip to main content

fakecloud_persistence/
version.rs

1use 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}