1use super::GameStorage;
17use crate::WINDOWS_1252;
18use std::fs;
19use std::io;
20use std::path::{Path, PathBuf};
21use supreme_hook_config::ResolvedAction;
22
23impl GameStorage {
24 fn resolve_read_path(&self, path: &Path) -> io::Result<PathBuf> {
36 let Some(resolver) = &self.hook_resolver else {
37 return Ok(self.root_path.join(path));
38 };
39 let rel_key = rel_key_for(path);
40
41 match resolver.resolve_path(&rel_key) {
42 ResolvedAction::PassThrough | ResolvedAction::FallbackToOriginal => {
43 Ok(self.root_path.join(path))
44 }
45 ResolvedAction::Redirect { replacement_path } => Ok(replacement_path),
46 ResolvedAction::HardErrorMissingFile => Err(io::Error::new(
47 io::ErrorKind::NotFound,
48 format!(
49 "'{rel_key}' is redirected by a mod in hard-error mode, and its replacement is missing"
50 ),
51 )),
52 }
53 }
54
55 fn resolve_write_path(&self, path: &Path) -> io::Result<PathBuf> {
67 let Some(resolver) = &self.hook_resolver else {
68 return Ok(self.root_path.join(path));
69 };
70 let rel_key = rel_key_for(path);
71
72 match resolver.resolve_path_for_write(&rel_key) {
73 ResolvedAction::PassThrough | ResolvedAction::FallbackToOriginal => {
74 Ok(self.root_path.join(path))
75 }
76 ResolvedAction::Redirect { replacement_path } => Ok(replacement_path),
77 ResolvedAction::HardErrorMissingFile => Err(io::Error::new(
78 io::ErrorKind::NotFound,
79 format!(
80 "'{rel_key}' is redirected by a mod in hard-error mode with no usable target"
81 ),
82 )),
83 }
84 }
85
86 pub fn read_bytes(&self, path: &Path) -> io::Result<Vec<u8>> {
94 let resolved = self.resolve_read_path(path)?;
95 fs::read(resolved)
96 }
97
98 pub fn write_bytes(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
108 let resolved = self.resolve_write_path(path)?;
109 fs::write(resolved, contents)
110 }
111
112 pub fn read_text(&self, path: &Path) -> io::Result<String> {
119 let bytes = self.read_bytes(path)?;
120 let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
121 Ok(decoded.into_owned())
122 }
123
124 pub fn write_text(&self, path: &Path, content: &str) -> io::Result<()> {
133 let (encoded, _, _) = WINDOWS_1252.encode(content);
134 self.write_bytes(path, &encoded)
135 }
136
137 #[must_use]
143 pub fn exists(&self, path: &Path) -> bool {
144 self.resolve_read_path(path)
145 .map(|resolved| resolved.exists())
146 .unwrap_or(false)
147 }
148
149 pub fn remove_file(&self, path: &Path) -> io::Result<()> {
160 let Some(resolver) = &self.hook_resolver else {
161 return fs::remove_file(self.root_path.join(path));
162 };
163 let rel_key = rel_key_for(path);
164
165 match resolver.resolve_path_for_delete(&rel_key) {
166 ResolvedAction::PassThrough | ResolvedAction::FallbackToOriginal => {
167 fs::remove_file(self.root_path.join(path))
168 }
169 ResolvedAction::Redirect { replacement_path } => fs::remove_file(replacement_path),
170 ResolvedAction::HardErrorMissingFile => Err(io::Error::new(
171 io::ErrorKind::NotFound,
172 format!("'{rel_key}' is redirected by a mod in hard-error mode; nothing to delete"),
173 )),
174 }
175 }
176
177 pub fn list_files(&self, rel_dir: &Path) -> io::Result<Vec<String>> {
201 let full_dir = self.root_path.join(rel_dir);
202
203 let mut names: Vec<String> = match fs::read_dir(&full_dir) {
204 Ok(entries) => entries
205 .filter_map(Result::ok)
206 .filter(|entry| entry.file_type().is_ok_and(|ft| ft.is_file()))
207 .filter_map(|entry| entry.file_name().to_str().map(str::to_string))
208 .collect(),
209 Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(),
210 Err(e) => return Err(e),
211 };
212
213 if let Some(resolver) = &self.hook_resolver {
214 let rel_dir_key = rel_key_for(rel_dir).to_lowercase();
215 names.retain(|name| {
216 let rel_key = if rel_dir_key.is_empty() {
217 name.to_lowercase()
218 } else {
219 format!("{rel_dir_key}/{}", name.to_lowercase())
220 };
221 !resolver.should_hide_original_entry(&rel_key)
222 });
223 names.extend(resolver.collect_mod_files_in_dir(&rel_dir_key));
224 }
225
226 names.sort();
227 names.dedup();
228 Ok(names)
229 }
230}
231
232fn rel_key_for(path: &Path) -> String {
240 path.components()
241 .map(|c| c.as_os_str().to_string_lossy())
242 .collect::<Vec<_>>()
243 .join("/")
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use crate::game_storage::GameStorage;
250 use std::fs;
251 use supreme_hook_config::{
252 MOD_FILE_NAME, ModConfig, ModsPreset, ModsState, RedirectionMode, RedirectionRule,
253 RuleTarget, save_mods_state,
254 };
255 use tempfile::tempdir;
256
257 fn make_hooked_storage(
262 root: &Path,
263 rel_glob: &str,
264 target_rel_path: &str,
265 mode: RedirectionMode,
266 ) -> GameStorage {
267 let mods_dir = root.join(".supreme/mods");
268 let mod_dir = mods_dir.join("test_mod");
269 fs::create_dir_all(&mod_dir).unwrap();
270 save_mods_state(
271 &mods_dir,
272 &ModsState {
273 active: ModsPreset {
274 order: vec!["test_mod".to_string()],
275 enabled: vec!["test_mod".to_string()],
276 configuration: std::collections::HashMap::new(),
277 },
278 presets: std::collections::HashMap::new(),
279 },
280 )
281 .unwrap();
282
283 let config = ModConfig {
284 name: "Test mod".to_string(),
285 rules: vec![RedirectionRule {
286 glob_pattern: rel_glob.to_string(),
287 target: Some(RuleTarget::Path(PathBuf::from(target_rel_path))),
288 mode,
289 patches: Vec::new(),
290 enable_if: None,
291 }],
292 ..ModConfig::default()
293 };
294 fs::write(
295 mod_dir.join(MOD_FILE_NAME),
296 serde_json::to_string(&config).unwrap(),
297 )
298 .unwrap();
299
300 let (storage, skipped) =
301 GameStorage::with_hooks(root, &mods_dir, root.join(".supreme/tmp"));
302 assert!(skipped.is_empty(), "test mod should load cleanly");
303 storage
304 }
305
306 #[test]
307 fn without_hooks_read_write_behave_exactly_as_before() {
308 let dir = tempdir().unwrap();
309 let storage = GameStorage::new(dir.path());
310
311 storage
312 .write_text(Path::new("boards.txt"), "original list")
313 .unwrap();
314 assert_eq!(
315 storage.read_text(Path::new("boards.txt")).unwrap(),
316 "original list"
317 );
318 assert_eq!(
319 fs::read_to_string(dir.path().join("boards.txt")).unwrap(),
320 "original list"
321 );
322 }
323
324 #[test]
325 fn read_text_returns_mod_replacement_when_rule_matches() {
326 let dir = tempdir().unwrap();
327 fs::write(dir.path().join("boards.txt"), "vanilla boards").unwrap();
328 fs::write(
329 dir.path().join("modded_boards.txt"),
330 "vanilla boards\nnew snowboard",
331 )
332 .unwrap();
333
334 let storage = make_hooked_storage(
335 dir.path(),
336 "boards.txt",
337 "modded_boards.txt",
338 RedirectionMode::GracefulFallback,
339 );
340
341 assert_eq!(
342 storage.read_text(Path::new("boards.txt")).unwrap(),
343 "vanilla boards\nnew snowboard"
344 );
345 }
346
347 #[test]
348 fn read_text_falls_through_untouched_for_non_matching_paths() {
349 let dir = tempdir().unwrap();
350 fs::write(dir.path().join("other.txt"), "not redirected").unwrap();
351
352 let storage = make_hooked_storage(
353 dir.path(),
354 "boards.txt",
355 "modded_boards.txt",
356 RedirectionMode::GracefulFallback,
357 );
358
359 assert_eq!(
360 storage.read_text(Path::new("other.txt")).unwrap(),
361 "not redirected"
362 );
363 }
364
365 #[test]
366 fn write_text_is_transparently_forwarded_to_the_mod_target_not_the_original() {
367 let dir = tempdir().unwrap();
368 fs::write(dir.path().join("boards.txt"), "vanilla boards").unwrap();
369
370 let storage = make_hooked_storage(
371 dir.path(),
372 "boards.txt",
373 "modded_boards.txt",
374 RedirectionMode::GracefulFallback,
375 );
376
377 storage
378 .write_text(Path::new("boards.txt"), "vanilla boards\nedited board")
379 .unwrap();
380
381 assert_eq!(
383 fs::read_to_string(dir.path().join("modded_boards.txt")).unwrap(),
384 "vanilla boards\nedited board"
385 );
386 assert_eq!(
388 fs::read_to_string(dir.path().join("boards.txt")).unwrap(),
389 "vanilla boards"
390 );
391 assert_eq!(
393 storage.read_text(Path::new("boards.txt")).unwrap(),
394 "vanilla boards\nedited board"
395 );
396 }
397
398 #[test]
399 fn write_text_seeds_target_from_original_on_first_write() {
400 let dir = tempdir().unwrap();
401 fs::write(dir.path().join("boards.txt"), "board a\nboard b").unwrap();
402
403 let storage = make_hooked_storage(
404 dir.path(),
405 "boards.txt",
406 "modded_boards.txt",
407 RedirectionMode::GracefulFallback,
408 );
409
410 assert!(!dir.path().join("modded_boards.txt").exists());
413 storage
414 .write_text(Path::new("boards.txt"), "board a\nboard b\nboard c")
415 .unwrap();
416
417 assert_eq!(
418 fs::read_to_string(dir.path().join("modded_boards.txt")).unwrap(),
419 "board a\nboard b\nboard c"
420 );
421 }
422
423 #[test]
424 fn hard_error_mode_blocks_read_when_replacement_is_missing() {
425 let dir = tempdir().unwrap();
426 fs::write(dir.path().join("boards.txt"), "vanilla boards").unwrap();
427 let storage = make_hooked_storage(
430 dir.path(),
431 "boards.txt",
432 "modded_boards.txt",
433 RedirectionMode::HardError,
434 );
435
436 let result = storage.read_text(Path::new("boards.txt"));
437 assert!(result.is_err());
438 assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
439 }
440
441 #[test]
442 fn exists_reflects_mod_replacement() {
443 let dir = tempdir().unwrap();
444 fs::write(dir.path().join("boards.txt"), "vanilla boards").unwrap();
445
446 let storage = make_hooked_storage(
447 dir.path(),
448 "boards.txt",
449 "modded_boards.txt",
450 RedirectionMode::HardError,
451 );
452
453 assert!(!storage.exists(Path::new("boards.txt")));
455
456 fs::write(dir.path().join("modded_boards.txt"), "modded").unwrap();
457 assert!(storage.exists(Path::new("boards.txt")));
458 }
459
460 #[test]
461 fn remove_file_removes_the_mod_target_not_the_original() {
462 let dir = tempdir().unwrap();
463 fs::write(dir.path().join("boards.txt"), "vanilla boards").unwrap();
464 fs::write(dir.path().join("modded_boards.txt"), "modded").unwrap();
465
466 let storage = make_hooked_storage(
467 dir.path(),
468 "boards.txt",
469 "modded_boards.txt",
470 RedirectionMode::GracefulFallback,
471 );
472
473 storage.remove_file(Path::new("boards.txt")).unwrap();
474
475 assert!(!dir.path().join("modded_boards.txt").exists());
476 assert!(
477 dir.path().join("boards.txt").exists(),
478 "original file must survive a redirected delete"
479 );
480 }
481
482 #[test]
483 fn rel_key_for_joins_components_with_forward_slashes() {
484 assert_eq!(
485 rel_key_for(Path::new("data/levels/level1.dat")),
486 "data/levels/level1.dat"
487 );
488 assert_eq!(rel_key_for(Path::new("boards.txt")), "boards.txt");
489 }
490
491 fn make_hooked_storage_with_dir_rule(
501 root: &Path,
502 rel_glob_dir: &str,
503 target_dir: &str,
504 mode: RedirectionMode,
505 ) -> GameStorage {
506 let mods_dir = root.join(".supreme/mods");
507 let mod_dir = mods_dir.join("test_mod");
508 fs::create_dir_all(&mod_dir).unwrap();
509 save_mods_state(
510 &mods_dir,
511 &ModsState {
512 active: ModsPreset {
513 order: vec!["test_mod".to_string()],
514 enabled: vec!["test_mod".to_string()],
515 configuration: std::collections::HashMap::new(),
516 },
517 presets: std::collections::HashMap::new(),
518 },
519 )
520 .unwrap();
521
522 let config = ModConfig {
523 name: "Test mod".to_string(),
524 rules: vec![RedirectionRule {
525 glob_pattern: format!("{rel_glob_dir}/**"),
526 target: Some(RuleTarget::Path(PathBuf::from(target_dir))),
527 mode,
528 patches: Vec::new(),
529 enable_if: None,
530 }],
531 ..ModConfig::default()
532 };
533 fs::write(
534 mod_dir.join(MOD_FILE_NAME),
535 serde_json::to_string(&config).unwrap(),
536 )
537 .unwrap();
538
539 let (storage, skipped) =
540 GameStorage::with_hooks(root, &mods_dir, root.join(".supreme/tmp"));
541 assert!(skipped.is_empty(), "test mod should load cleanly");
542 storage
543 }
544
545 #[test]
546 fn list_files_without_hooks_lists_real_files_only() {
547 let dir = tempdir().unwrap();
548 fs::create_dir_all(dir.path().join("renderers")).unwrap();
549 fs::write(dir.path().join("renderers/srDD_OpenGL.dll"), b"").unwrap();
550 fs::write(dir.path().join("renderers/srDD_DirectX6.dll"), b"").unwrap();
551
552 let storage = GameStorage::new(dir.path());
553 let mut files = storage.list_files(Path::new("renderers")).unwrap();
554 files.sort();
555 assert_eq!(files, vec!["srDD_DirectX6.dll", "srDD_OpenGL.dll"]);
556 }
557
558 #[test]
559 fn list_files_on_missing_directory_returns_empty_ok() {
560 let dir = tempdir().unwrap();
561 let storage = GameStorage::new(dir.path());
562 assert_eq!(
563 storage.list_files(Path::new("does_not_exist")).unwrap(),
564 Vec::<String>::new()
565 );
566 }
567
568 #[test]
569 fn list_files_hides_original_under_hard_error_with_no_replacement() {
570 let dir = tempdir().unwrap();
579 fs::create_dir_all(dir.path().join("renderers")).unwrap();
580 fs::write(dir.path().join("renderers/srDD_OpenGL.dll"), b"vanilla").unwrap();
581 let storage = make_hooked_storage(
584 dir.path(),
585 "renderers/srDD_OpenGL.dll",
586 "modded_opengl.dll",
587 RedirectionMode::HardError,
588 );
589
590 assert_eq!(
591 storage.list_files(Path::new("renderers")).unwrap(),
592 Vec::<String>::new(),
593 );
594 }
595
596 #[test]
597 fn list_files_shows_a_same_named_replacement_exactly_once() {
598 let dir = tempdir().unwrap();
605 fs::create_dir_all(dir.path().join("renderers")).unwrap();
606 fs::write(dir.path().join("renderers/srDD_OpenGL.dll"), b"vanilla").unwrap();
607 fs::create_dir_all(dir.path().join("mod_target")).unwrap();
608 fs::write(dir.path().join("mod_target/srDD_OpenGL.dll"), b"modded").unwrap();
609
610 let storage = make_hooked_storage(
611 dir.path(),
612 "renderers/srDD_OpenGL.dll",
613 "mod_target/srDD_OpenGL.dll",
614 RedirectionMode::GracefulFallback,
615 );
616
617 assert_eq!(
618 storage.list_files(Path::new("renderers")).unwrap(),
619 vec!["srDD_OpenGL.dll"],
620 );
621 }
622
623 #[test]
624 fn list_files_keeps_original_visible_when_graceful_fallback_replacement_is_missing() {
625 let dir = tempdir().unwrap();
626 fs::create_dir_all(dir.path().join("renderers")).unwrap();
627 fs::write(dir.path().join("renderers/srDD_OpenGL.dll"), b"vanilla").unwrap();
628 let storage = make_hooked_storage(
631 dir.path(),
632 "renderers/srDD_OpenGL.dll",
633 "modded_opengl.dll",
634 RedirectionMode::GracefulFallback,
635 );
636
637 assert_eq!(
638 storage.list_files(Path::new("renderers")).unwrap(),
639 vec!["srDD_OpenGL.dll"]
640 );
641 }
642
643 #[test]
644 fn list_files_includes_files_a_mod_injects_into_the_directory() {
645 let dir = tempdir().unwrap();
646 fs::create_dir_all(dir.path().join("renderers")).unwrap();
647 fs::write(dir.path().join("renderers/srDD_OpenGL.dll"), b"vanilla").unwrap();
648 fs::create_dir_all(dir.path().join("mod_renderers")).unwrap();
649 fs::write(
650 dir.path().join("mod_renderers/srDD_Vulkan.dll"),
651 b"injected by mod",
652 )
653 .unwrap();
654
655 let storage = make_hooked_storage_with_dir_rule(
656 dir.path(),
657 "renderers",
658 "mod_renderers",
659 RedirectionMode::GracefulFallback,
660 );
661
662 assert_eq!(
663 storage.list_files(Path::new("renderers")).unwrap(),
664 vec!["srDD_OpenGL.dll", "srDD_Vulkan.dll"],
665 "a mod-injected file must show up in the listing alongside the \
666 untouched original"
667 );
668 }
669}