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 pub fn new(store_dir: PathBuf) -> Self {
39 Self { store_dir, db: None }
40 }
41
42 pub fn with_db(store_dir: PathBuf, db: ModdeDb) -> Self {
44 Self { store_dir, db: Some(db) }
45 }
46
47 pub fn default_dir() -> PathBuf {
49 crate::paths::stock_dir()
50 }
51
52 pub fn detect_steam_install(&self, game_dir_name: &str) -> Option<PathBuf> {
54 let game_path = crate::paths::steam_common().join(game_dir_name);
55 game_path.exists().then_some(game_path)
56 }
57
58 pub async fn snapshot(&self, game_id: &str, source_dir: &Path) -> Result<StockSnapshot> {
62 if !source_dir.exists() {
63 return Err(CoreError::GameNotDetected(game_id.to_string()));
64 }
65
66 let snapshot_dir = self.store_dir.join(game_id);
67 tokio::fs::create_dir_all(&snapshot_dir).await?;
68
69 snapshot_recursive(source_dir, &snapshot_dir).await?;
71
72 let tree_hash = compute_tree_hash(&snapshot_dir).await?;
74 store_tree_hash(&snapshot_dir, &tree_hash).await?;
75
76 if let Some(ref db) = self.db {
77 db.upsert_snapshot(game_id, &snapshot_dir, &tree_hash.tree_hash, tree_hash.file_count)?;
78 }
79
80 info!(game_id, path = %snapshot_dir.display(), hash = %tree_hash.tree_hash, "stock snapshot created");
81
82 Ok(StockSnapshot {
83 game_id: GameId::from(game_id),
84 path: snapshot_dir,
85 hash: tree_hash.tree_hash,
86 })
87 }
88
89 pub async fn verify(&self, game_id: &str) -> Result<bool> {
91 let snapshot_dir = self.store_dir.join(game_id);
92 if !snapshot_dir.exists() {
93 return Err(CoreError::Other(format!(
94 "no snapshot found for game '{game_id}'"
95 ).into()));
96 }
97
98 let stored = load_tree_hash(&snapshot_dir).await?;
99 let current = compute_tree_hash(&snapshot_dir).await?;
100
101 if stored.tree_hash == current.tree_hash {
102 info!(game_id, hash = %stored.tree_hash, "snapshot verified OK");
103 Ok(true)
104 } else {
105 warn!(
106 game_id,
107 expected = %stored.tree_hash,
108 actual = %current.tree_hash,
109 "snapshot verification failed: tree hash mismatch"
110 );
111 Ok(false)
112 }
113 }
114}
115
116async fn collect_file_hashes(
118 root: &Path,
119 dir: &Path,
120 entries: &mut Vec<(String, u64)>,
121) -> Result<()> {
122 let mut read_dir = tokio::fs::read_dir(dir).await?;
123 while let Some(entry) = read_dir.next_entry().await? {
124 let path = entry.path();
125 let file_type = entry.file_type().await?;
126
127 if path.file_name().and_then(|n| n.to_str()) == Some(TREE_HASH_FILENAME) {
129 continue;
130 }
131
132 if file_type.is_dir() {
133 Box::pin(collect_file_hashes(root, &path, entries)).await?;
134 } else if file_type.is_file() {
135 let rel = path
136 .strip_prefix(root)
137 .unwrap_or(&path)
138 .to_string_lossy()
139 .replace('\\', "/");
140 let hash = hash_file_xxhash(&path).await?;
141 entries.push((rel, hash));
142 }
143 }
144 Ok(())
145}
146
147async fn compute_tree_hash(dir: &Path) -> Result<TreeHashMeta> {
152 let mut entries = Vec::new();
153 collect_file_hashes(dir, dir, &mut entries).await?;
154
155 entries.sort_by(|a, b| a.0.cmp(&b.0));
157
158 let mut combined = Vec::new();
160 for (rel_path, hash) in &entries {
161 combined.extend_from_slice(rel_path.as_bytes());
162 combined.push(0);
163 combined.extend_from_slice(format!("{hash:016x}").as_bytes());
164 combined.push(b'\n');
165 }
166
167 let tree_hash = xxh3_64(&combined);
168
169 Ok(TreeHashMeta {
170 tree_hash: format!("{tree_hash:016x}"),
171 file_count: entries.len(),
172 })
173}
174
175async fn store_tree_hash(snapshot_dir: &Path, meta: &TreeHashMeta) -> Result<()> {
177 let meta_path = snapshot_dir.join(TREE_HASH_FILENAME);
178 let toml_str = toml::to_string_pretty(meta)?;
179 tokio::fs::write(&meta_path, toml_str.as_bytes()).await?;
180 Ok(())
181}
182
183async fn load_tree_hash(snapshot_dir: &Path) -> Result<TreeHashMeta> {
185 let meta_path = snapshot_dir.join(TREE_HASH_FILENAME);
186 let data = tokio::fs::read_to_string(&meta_path).await.map_err(|_| {
187 CoreError::Other(format!(
188 "tree hash metadata not found at {}",
189 meta_path.display()
190 ).into())
191 })?;
192 let meta: TreeHashMeta = toml::from_str(&data)?;
193 Ok(meta)
194}
195
196async fn snapshot_recursive(src: &Path, dst: &Path) -> Result<()> {
197 let mut entries = tokio::fs::read_dir(src).await?;
198 while let Some(entry) = entries.next_entry().await? {
199 let file_type = entry.file_type().await?;
200 let src_path = entry.path();
201 let dst_path = dst.join(entry.file_name());
202
203 if file_type.is_dir() {
204 tokio::fs::create_dir_all(&dst_path).await?;
205 Box::pin(snapshot_recursive(&src_path, &dst_path)).await?;
206 } else if file_type.is_file() {
207 match tokio::fs::hard_link(&src_path, &dst_path).await {
208 Ok(()) => {}
209 Err(e) if crate::fs::is_cross_device_error(&e) => {
210 warn!(
211 src = %src_path.display(),
212 "cross-device hardlink; falling back to copy"
213 );
214 tokio::fs::copy(&src_path, &dst_path).await?;
215 }
216 Err(e) => return Err(e.into()),
217 }
218 }
219 }
220 Ok(())
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use std::io::Write;
227 use tempfile::TempDir;
228
229 fn create_test_tree(dir: &Path) {
230 std::fs::create_dir_all(dir.join("subdir")).unwrap();
232 let mut f1 = std::fs::File::create(dir.join("file_a.txt")).unwrap();
233 f1.write_all(b"hello world").unwrap();
234 let mut f2 = std::fs::File::create(dir.join("subdir/file_b.txt")).unwrap();
235 f2.write_all(b"nested content").unwrap();
236 }
237
238 #[tokio::test]
239 async fn test_tree_hash_deterministic() {
240 let tmp = TempDir::new().unwrap();
241 create_test_tree(tmp.path());
242
243 let h1 = compute_tree_hash(tmp.path()).await.unwrap();
244 let h2 = compute_tree_hash(tmp.path()).await.unwrap();
245 assert_eq!(h1.tree_hash, h2.tree_hash);
246 assert_eq!(h1.file_count, 2);
247 }
248
249 #[tokio::test]
250 async fn test_tree_hash_changes_on_modification() {
251 let tmp = TempDir::new().unwrap();
252 create_test_tree(tmp.path());
253
254 let h1 = compute_tree_hash(tmp.path()).await.unwrap();
255
256 std::fs::write(tmp.path().join("file_a.txt"), b"changed").unwrap();
258
259 let h2 = compute_tree_hash(tmp.path()).await.unwrap();
260 assert_ne!(h1.tree_hash, h2.tree_hash);
261 }
262
263 #[tokio::test]
264 async fn test_store_and_load_tree_hash() {
265 let tmp = TempDir::new().unwrap();
266 create_test_tree(tmp.path());
267
268 let hash = compute_tree_hash(tmp.path()).await.unwrap();
269 store_tree_hash(tmp.path(), &hash).await.unwrap();
270
271 let loaded = load_tree_hash(tmp.path()).await.unwrap();
272 assert_eq!(hash.tree_hash, loaded.tree_hash);
273 assert_eq!(hash.file_count, loaded.file_count);
274 }
275
276 #[tokio::test]
277 async fn test_snapshot_and_verify() {
278 let src = TempDir::new().unwrap();
279 create_test_tree(src.path());
280
281 let store = TempDir::new().unwrap();
282 let mgr = StockGameManager::new(store.path().to_path_buf());
283
284 let snap = mgr.snapshot("test-game", src.path()).await.unwrap();
285 assert!(!snap.hash.is_empty());
286
287 let ok = mgr.verify("test-game").await.unwrap();
288 assert!(ok);
289 }
290}