1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4use tracing::{info, warn};
5use xxhash_rust::xxh3::xxh3_64;
6
7use crate::db::ModdeDb;
8use crate::error::{CoreError, Result};
9use crate::hash::hash_file_xxhash;
10use crate::resolver::GameId;
11
12const TREE_HASH_FILENAME: &str = ".modde-tree-hash.toml";
13
14pub struct StockGameManager {
16 store_dir: PathBuf,
17 db: Option<ModdeDb>,
18}
19
20#[derive(Debug)]
22pub struct StockSnapshot {
23 pub game_id: GameId,
24 pub path: PathBuf,
25 pub hash: String,
26}
27
28#[derive(Debug, Serialize, Deserialize)]
30struct TreeHashMeta {
31 tree_hash: String,
33 file_count: usize,
35}
36
37impl StockGameManager {
38 #[must_use]
39 pub fn new(store_dir: PathBuf) -> Self {
40 Self {
41 store_dir,
42 db: None,
43 }
44 }
45
46 pub fn with_db(store_dir: PathBuf, db: ModdeDb) -> Self {
48 Self {
49 store_dir,
50 db: Some(db),
51 }
52 }
53
54 #[must_use]
56 pub fn default_dir() -> PathBuf {
57 crate::paths::stock_dir()
58 }
59
60 pub fn detect_steam_install(&self, game_dir_name: &str) -> Option<PathBuf> {
62 let game_path = crate::paths::steam_common().join(game_dir_name);
63 game_path.exists().then_some(game_path)
64 }
65
66 pub async fn snapshot(&self, game_id: &GameId, source_dir: &Path) -> Result<StockSnapshot> {
70 if !source_dir.exists() {
71 return Err(CoreError::GameNotDetected(game_id.to_string()));
72 }
73
74 let snapshot_dir = self.store_dir.join(game_id.as_str());
75 tokio::fs::create_dir_all(&snapshot_dir).await?;
76
77 snapshot_recursive(source_dir, &snapshot_dir).await?;
79
80 let tree_hash = compute_tree_hash(&snapshot_dir).await?;
82 store_tree_hash(&snapshot_dir, &tree_hash).await?;
83
84 if let Some(ref db) = self.db {
85 db.upsert_snapshot(
86 game_id,
87 &snapshot_dir,
88 &tree_hash.tree_hash,
89 tree_hash.file_count,
90 )?;
91 }
92
93 info!(game_id = %game_id, path = %snapshot_dir.display(), hash = %tree_hash.tree_hash, "stock snapshot created");
94
95 Ok(StockSnapshot {
96 game_id: game_id.clone(),
97 path: snapshot_dir,
98 hash: tree_hash.tree_hash,
99 })
100 }
101
102 pub async fn verify(&self, game_id: &GameId) -> Result<bool> {
104 let snapshot_dir = self.store_dir.join(game_id.as_str());
105 if !snapshot_dir.exists() {
106 return Err(CoreError::Other(
107 format!("no snapshot found for game '{game_id}'").into(),
108 ));
109 }
110
111 let stored = load_tree_hash(&snapshot_dir).await?;
112 let current = compute_tree_hash(&snapshot_dir).await?;
113
114 if stored.tree_hash == current.tree_hash {
115 info!(game_id = %game_id, hash = %stored.tree_hash, "snapshot verified OK");
116 Ok(true)
117 } else {
118 warn!(
119 game_id = %game_id,
120 expected = %stored.tree_hash,
121 actual = %current.tree_hash,
122 "snapshot verification failed: tree hash mismatch"
123 );
124 Ok(false)
125 }
126 }
127}
128
129async fn collect_file_hashes(
131 root: &Path,
132 dir: &Path,
133 entries: &mut Vec<(String, u64)>,
134) -> Result<()> {
135 let mut read_dir = tokio::fs::read_dir(dir).await?;
136 while let Some(entry) = read_dir.next_entry().await? {
137 let path = entry.path();
138 let file_type = entry.file_type().await?;
139
140 if path.file_name().and_then(|n| n.to_str()) == Some(TREE_HASH_FILENAME) {
142 continue;
143 }
144
145 if file_type.is_dir() {
146 Box::pin(collect_file_hashes(root, &path, entries)).await?;
147 } else if file_type.is_file() {
148 let rel = path
149 .strip_prefix(root)
150 .unwrap_or(&path)
151 .to_string_lossy()
152 .replace('\\', "/");
153 let hash = hash_file_xxhash(&path).await?;
154 entries.push((rel, hash));
155 }
156 }
157 Ok(())
158}
159
160async fn compute_tree_hash(dir: &Path) -> Result<TreeHashMeta> {
165 let mut entries = Vec::new();
166 collect_file_hashes(dir, dir, &mut entries).await?;
167
168 entries.sort_by(|a, b| a.0.cmp(&b.0));
170
171 let mut combined = Vec::new();
173 for (rel_path, hash) in &entries {
174 combined.extend_from_slice(rel_path.as_bytes());
175 combined.push(0);
176 combined.extend_from_slice(format!("{hash:016x}").as_bytes());
177 combined.push(b'\n');
178 }
179
180 let tree_hash = xxh3_64(&combined);
181
182 Ok(TreeHashMeta {
183 tree_hash: format!("{tree_hash:016x}"),
184 file_count: entries.len(),
185 })
186}
187
188async fn store_tree_hash(snapshot_dir: &Path, meta: &TreeHashMeta) -> Result<()> {
190 let meta_path = snapshot_dir.join(TREE_HASH_FILENAME);
191 let toml_str = toml::to_string_pretty(meta)?;
192 tokio::fs::write(&meta_path, toml_str.as_bytes()).await?;
193 Ok(())
194}
195
196async fn load_tree_hash(snapshot_dir: &Path) -> Result<TreeHashMeta> {
198 let meta_path = snapshot_dir.join(TREE_HASH_FILENAME);
199 let data = tokio::fs::read_to_string(&meta_path).await.map_err(|_| {
200 CoreError::Other(format!("tree hash metadata not found at {}", meta_path.display()).into())
201 })?;
202 let meta: TreeHashMeta = toml::from_str(&data)?;
203 Ok(meta)
204}
205
206async fn snapshot_recursive(src: &Path, dst: &Path) -> Result<()> {
207 let mut entries = tokio::fs::read_dir(src).await?;
208 while let Some(entry) = entries.next_entry().await? {
209 let file_type = entry.file_type().await?;
210 let src_path = entry.path();
211 let dst_path = dst.join(entry.file_name());
212
213 if file_type.is_dir() {
214 tokio::fs::create_dir_all(&dst_path).await?;
215 Box::pin(snapshot_recursive(&src_path, &dst_path)).await?;
216 } else if file_type.is_file() {
217 let kind = crate::link::link_or_copy(&src_path, &dst_path).await?;
218 tracing::debug!(src = %src_path.display(), dst = %dst_path.display(), ?kind, "stock snapshot linked file");
219 }
220 }
221 Ok(())
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use std::io::Write;
228 use tempfile::TempDir;
229
230 fn create_test_tree(dir: &Path) {
231 std::fs::create_dir_all(dir.join("subdir")).unwrap();
233 let mut f1 = std::fs::File::create(dir.join("file_a.txt")).unwrap();
234 f1.write_all(b"hello world").unwrap();
235 let mut f2 = std::fs::File::create(dir.join("subdir/file_b.txt")).unwrap();
236 f2.write_all(b"nested content").unwrap();
237 }
238
239 #[tokio::test]
240 async fn test_tree_hash_deterministic() {
241 let tmp = TempDir::new().unwrap();
242 create_test_tree(tmp.path());
243
244 let h1 = compute_tree_hash(tmp.path()).await.unwrap();
245 let h2 = compute_tree_hash(tmp.path()).await.unwrap();
246 assert_eq!(h1.tree_hash, h2.tree_hash);
247 assert_eq!(h1.file_count, 2);
248 }
249
250 #[tokio::test]
251 async fn test_tree_hash_changes_on_modification() {
252 let tmp = TempDir::new().unwrap();
253 create_test_tree(tmp.path());
254
255 let h1 = compute_tree_hash(tmp.path()).await.unwrap();
256
257 std::fs::write(tmp.path().join("file_a.txt"), b"changed").unwrap();
259
260 let h2 = compute_tree_hash(tmp.path()).await.unwrap();
261 assert_ne!(h1.tree_hash, h2.tree_hash);
262 }
263
264 #[tokio::test]
265 async fn test_store_and_load_tree_hash() {
266 let tmp = TempDir::new().unwrap();
267 create_test_tree(tmp.path());
268
269 let hash = compute_tree_hash(tmp.path()).await.unwrap();
270 store_tree_hash(tmp.path(), &hash).await.unwrap();
271
272 let loaded = load_tree_hash(tmp.path()).await.unwrap();
273 assert_eq!(hash.tree_hash, loaded.tree_hash);
274 assert_eq!(hash.file_count, loaded.file_count);
275 }
276
277 #[tokio::test]
278 async fn test_snapshot_and_verify() {
279 let src = TempDir::new().unwrap();
280 create_test_tree(src.path());
281
282 let store = TempDir::new().unwrap();
283 let mgr = StockGameManager::new(store.path().to_path_buf());
284
285 let snap = mgr
286 .snapshot(&GameId::from("test-game"), src.path())
287 .await
288 .unwrap();
289 assert!(!snap.hash.is_empty());
290
291 let ok = mgr.verify(&GameId::from("test-game")).await.unwrap();
292 assert!(ok);
293 }
294}