1pub(crate) mod path;
2pub(crate) mod sqpk;
3
4use crate::Platform;
5use crate::Result;
6use crate::chunk::Chunk;
7use crate::chunk::adir::AddDirectory;
8use crate::chunk::aply::{ApplyOption, ApplyOptionKind};
9use crate::chunk::ddir::DeleteDirectory;
10use std::collections::HashMap;
11use std::fs::{File, OpenOptions};
12use std::path::{Path, PathBuf};
13use tracing::{debug, trace, warn};
14
15const MAX_CACHED_FDS: usize = 256;
16
17pub struct ApplyContext {
24 pub(crate) game_path: PathBuf,
25 pub(crate) platform: Platform,
28 pub(crate) ignore_missing: bool,
29 pub(crate) ignore_old_mismatch: bool,
30 file_cache: HashMap<PathBuf, File>,
32}
33
34impl ApplyContext {
35 pub fn new(game_path: impl Into<PathBuf>) -> Self {
39 Self {
40 game_path: game_path.into(),
41 platform: Platform::Win32,
42 ignore_missing: false,
43 ignore_old_mismatch: false,
44 file_cache: HashMap::new(),
45 }
46 }
47
48 #[must_use]
50 pub fn game_path(&self) -> &std::path::Path {
51 &self.game_path
52 }
53
54 #[must_use]
56 pub fn platform(&self) -> Platform {
57 self.platform
58 }
59
60 #[must_use]
62 pub fn ignore_missing(&self) -> bool {
63 self.ignore_missing
64 }
65
66 #[must_use]
68 pub fn ignore_old_mismatch(&self) -> bool {
69 self.ignore_old_mismatch
70 }
71
72 #[must_use]
75 pub fn with_platform(mut self, platform: Platform) -> Self {
76 self.platform = platform;
77 self
78 }
79
80 #[must_use]
82 pub fn with_ignore_missing(mut self, v: bool) -> Self {
83 self.ignore_missing = v;
84 self
85 }
86
87 #[must_use]
89 pub fn with_ignore_old_mismatch(mut self, v: bool) -> Self {
90 self.ignore_old_mismatch = v;
91 self
92 }
93
94 pub(crate) fn open_cached(&mut self, path: PathBuf) -> std::io::Result<&mut File> {
95 use std::collections::hash_map::Entry;
96 if self.file_cache.len() >= MAX_CACHED_FDS && !self.file_cache.contains_key(&path) {
98 self.file_cache.clear();
99 }
100 match self.file_cache.entry(path) {
101 Entry::Occupied(e) => Ok(e.into_mut()),
102 Entry::Vacant(e) => {
103 let file = OpenOptions::new()
104 .write(true)
105 .create(true)
106 .truncate(false)
107 .open(e.key())?;
108 Ok(e.insert(file))
109 }
110 }
111 }
112
113 pub(crate) fn evict_cached(&mut self, path: &Path) {
114 self.file_cache.remove(path);
115 }
116
117 pub(crate) fn clear_file_cache(&mut self) {
118 self.file_cache.clear();
119 }
120}
121
122pub trait Apply {
128 fn apply(&self, ctx: &mut ApplyContext) -> Result<()>;
130}
131
132impl Apply for Chunk {
133 fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
134 match self {
135 Chunk::FileHeader(_) | Chunk::ApplyFreeSpace(_) | Chunk::EndOfFile => Ok(()),
136 Chunk::Sqpk(c) => c.apply(ctx),
137 Chunk::ApplyOption(c) => c.apply(ctx),
138 Chunk::AddDirectory(c) => c.apply(ctx),
139 Chunk::DeleteDirectory(c) => c.apply(ctx),
140 }
141 }
142}
143
144impl Apply for ApplyOption {
145 fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
146 debug!(kind = ?self.kind, value = self.value, "apply option");
147 match self.kind {
148 ApplyOptionKind::IgnoreMissing => ctx.ignore_missing = self.value,
149 ApplyOptionKind::IgnoreOldMismatch => ctx.ignore_old_mismatch = self.value,
150 }
151 Ok(())
152 }
153}
154
155impl Apply for AddDirectory {
156 fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
157 trace!(name = %self.name, "create directory");
158 std::fs::create_dir_all(ctx.game_path.join(&self.name))?;
159 Ok(())
160 }
161}
162
163impl Apply for DeleteDirectory {
164 fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
165 match std::fs::remove_dir(ctx.game_path.join(&self.name)) {
166 Ok(()) => {
167 trace!(name = %self.name, "delete directory");
168 Ok(())
169 }
170 Err(e) if e.kind() == std::io::ErrorKind::NotFound && ctx.ignore_missing => {
171 warn!(name = %self.name, "delete directory: not found, ignored");
172 Ok(())
173 }
174 Err(e) => Err(e.into()),
175 }
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn cache_eviction_clears_when_full() {
185 let tmp = tempfile::tempdir().unwrap();
186 let mut ctx = ApplyContext::new(tmp.path());
187
188 for i in 0..MAX_CACHED_FDS {
189 ctx.open_cached(tmp.path().join(format!("{i}.dat")))
190 .unwrap();
191 }
192 assert_eq!(ctx.file_cache.len(), MAX_CACHED_FDS);
193
194 ctx.open_cached(tmp.path().join("new.dat")).unwrap();
195 assert_eq!(ctx.file_cache.len(), 1);
196 }
197
198 #[test]
199 fn game_path_returns_install_root() {
200 let tmp = tempfile::tempdir().unwrap();
201 let ctx = ApplyContext::new(tmp.path());
202 assert_eq!(ctx.game_path(), tmp.path());
203 }
204
205 #[test]
206 fn with_platform_overrides_default() {
207 let ctx = ApplyContext::new("/irrelevant").with_platform(Platform::Ps4);
208 assert_eq!(ctx.platform(), Platform::Ps4);
209 }
210
211 #[test]
212 fn with_ignore_old_mismatch_toggles_flag() {
213 let ctx = ApplyContext::new("/irrelevant").with_ignore_old_mismatch(true);
214 assert!(ctx.ignore_old_mismatch());
215 let ctx = ctx.with_ignore_old_mismatch(false);
216 assert!(!ctx.ignore_old_mismatch());
217 }
218}