Skip to main content

supreme_fs/game_storage/
local_ops.rs

1//! Local-only filesystem helpers: hashing, profile listing, empty-directory
2//! cleanup, and JSON report writing. Hook-aware text/byte read-write lives
3//! in [`super::hooked_ops`].
4
5use super::GameStorage;
6use sha2::{Digest, Sha256};
7use std::fs::{self, File};
8use std::io::{self, Read};
9use std::path::{Path, PathBuf};
10
11impl GameStorage {
12    /// Calculate SHA256 hash of a file, for content-addressed storage.
13    ///
14    /// # Errors
15    ///
16    /// Returns an error if the file cannot be opened or read.
17    pub fn calculate_hash(&self, path: &Path) -> io::Result<String> {
18        let mut file = File::open(path)?;
19        let mut hasher = Sha256::new();
20        let mut buffer = [0; 1024];
21        loop {
22            let count = file.read(&mut buffer)?;
23            if count == 0 {
24                break;
25            }
26            hasher.update(&buffer[..count]);
27        }
28        Ok(hex::encode(hasher.finalize()))
29    }
30
31    /// List all available snapshot profiles: finds and filters .json files.
32    ///
33    /// # Errors
34    ///
35    /// Returns an error if the profiles directory cannot be read.
36    pub fn list_snapshots_profiles(&self) -> io::Result<Vec<String>> {
37        let mut profiles = Vec::new();
38        if let Ok(entries) = fs::read_dir(self.profiles_path()) {
39            for entry in entries.flatten() {
40                let path = entry.path();
41                if path.extension().and_then(|s| s.to_str()) == Some("json")
42                    && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
43                {
44                    profiles.push(stem.to_string());
45                }
46            }
47        }
48        profiles.sort();
49        Ok(profiles)
50    }
51
52    /// Clean up empty directories recursively.
53    pub fn clean_up_empty_dirs(&self, start_dir: &Path, limit_dir: &Path) {
54        let mut current = start_dir;
55        while current.starts_with(limit_dir) && current != limit_dir {
56            if fs::remove_dir(current).is_ok() {
57                if let Some(p) = current.parent() {
58                    current = p;
59                } else {
60                    break;
61                }
62            } else {
63                break;
64            }
65        }
66    }
67
68    /// Write a JSON report to .supreme/{name}.json
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if the report directory cannot be created or the file written.
73    pub fn write_report<T: serde::Serialize>(&self, name: &str, value: &T) -> io::Result<()> {
74        let report_dir = self.root_path.join(".supreme");
75        fs::create_dir_all(&report_dir)?;
76
77        let report_path = report_dir.join(format!("{name}.json"));
78        let file = File::create(report_path)?;
79        serde_json::to_writer_pretty(file, value).map_err(io::Error::other)
80    }
81
82    /// Get the path to a report file.
83    #[must_use]
84    pub fn get_report_path(&self, name: &str) -> PathBuf {
85        self.root_path.join(".supreme").join(format!("{name}.json"))
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use std::fs;
93    use tempfile::tempdir;
94
95    fn make_storage() -> (tempfile::TempDir, GameStorage) {
96        let dir = tempdir().expect("tempdir");
97        let storage = GameStorage::new(dir.path());
98        (dir, storage)
99    }
100
101    #[test]
102    fn calculate_hash_matches_known_sha256() {
103        let (dir, storage) = make_storage();
104        let file_path = dir.path().join("hello.txt");
105        fs::write(&file_path, b"hello world").unwrap();
106
107        let hash = storage.calculate_hash(&file_path).expect("hash");
108        // Known SHA256 of "hello world"
109        assert_eq!(
110            hash,
111            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
112        );
113    }
114
115    #[test]
116    fn calculate_hash_of_empty_file_matches_known_sha256() {
117        let (dir, storage) = make_storage();
118        let file_path = dir.path().join("empty.txt");
119        fs::write(&file_path, b"").unwrap();
120
121        let hash = storage.calculate_hash(&file_path).expect("hash");
122        assert_eq!(
123            hash,
124            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
125        );
126    }
127
128    #[test]
129    fn calculate_hash_errors_on_missing_file() {
130        let (dir, storage) = make_storage();
131        let result = storage.calculate_hash(&dir.path().join("missing.txt"));
132        assert!(result.is_err());
133    }
134
135    #[test]
136    fn list_snapshots_profiles_returns_sorted_json_stems() {
137        let (_dir, storage) = make_storage();
138        fs::create_dir_all(storage.profiles_path()).unwrap();
139        fs::write(storage.profiles_path().join("zeta.json"), b"{}").unwrap();
140        fs::write(storage.profiles_path().join("alpha.json"), b"{}").unwrap();
141        // Non-json files should be ignored.
142        fs::write(storage.profiles_path().join("notes.txt"), b"ignore me").unwrap();
143
144        let profiles = storage.list_snapshots_profiles().expect("list");
145        assert_eq!(profiles, vec!["alpha".to_string(), "zeta".to_string()]);
146    }
147
148    #[test]
149    fn list_snapshots_profiles_on_missing_dir_returns_empty() {
150        let (_dir, storage) = make_storage();
151        let profiles = storage.list_snapshots_profiles().expect("list");
152        assert!(profiles.is_empty());
153    }
154
155    #[test]
156    fn clean_up_empty_dirs_removes_empty_chain_but_stops_at_limit() {
157        let (dir, storage) = make_storage();
158        let nested = dir.path().join("a/b/c");
159        fs::create_dir_all(&nested).unwrap();
160
161        storage.clean_up_empty_dirs(&nested, dir.path());
162
163        assert!(!nested.exists());
164        assert!(!dir.path().join("a/b").exists());
165        assert!(!dir.path().join("a").exists());
166        // The limit dir itself must still exist.
167        assert!(dir.path().exists());
168    }
169
170    #[test]
171    fn clean_up_empty_dirs_stops_when_a_directory_is_not_empty() {
172        let (dir, storage) = make_storage();
173        let nested = dir.path().join("a/b/c");
174        fs::create_dir_all(&nested).unwrap();
175        // Put a file in "a/b" so cleanup can remove "c" but must stop at "b".
176        fs::write(dir.path().join("a/b/keep.txt"), b"keep").unwrap();
177
178        storage.clean_up_empty_dirs(&nested, dir.path());
179
180        assert!(!nested.exists());
181        assert!(dir.path().join("a/b").exists());
182        assert!(dir.path().join("a/b/keep.txt").exists());
183    }
184
185    #[test]
186    fn write_report_creates_json_file_readable_back() {
187        let (_dir, storage) = make_storage();
188        #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
189        struct Report {
190            ok: bool,
191            count: u32,
192        }
193        let report = Report { ok: true, count: 3 };
194
195        storage
196            .write_report("summary", &report)
197            .expect("write_report");
198
199        let path = storage.get_report_path("summary");
200        assert!(path.exists());
201        let contents = fs::read_to_string(path).unwrap();
202        let parsed: Report = serde_json::from_str(&contents).unwrap();
203        assert_eq!(parsed, report);
204    }
205
206    #[test]
207    fn get_report_path_is_under_dot_supreme() {
208        let (dir, storage) = make_storage();
209        let path = storage.get_report_path("mysummary");
210        assert_eq!(path, dir.path().join(".supreme").join("mysummary.json"));
211    }
212}