Skip to main content

zipatch_rs/apply/
mod.rs

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
17/// Apply-time state: install root, target platform, flag toggles, and the
18/// internal file-handle cache used by SQPK writers.
19///
20/// Construct with [`ApplyContext::new`] and customise with the `with_*`
21/// builder methods. `SqpkTargetInfo` chunks may overwrite the platform field
22/// at apply time; `ApplyOption` chunks may overwrite the ignore-flags.
23pub struct ApplyContext {
24    pub(crate) game_path: PathBuf,
25    /// The target platform. Defaults to `Win32`. Note: `SqpkTargetInfo` chunks
26    /// in the patch stream will override this value when applied.
27    pub(crate) platform: Platform,
28    pub(crate) ignore_missing: bool,
29    pub(crate) ignore_old_mismatch: bool,
30    // Capped at MAX_CACHED_FDS entries; cleared wholesale when full to bound open FD count.
31    file_cache: HashMap<PathBuf, File>,
32}
33
34impl ApplyContext {
35    /// Create a context targeting the given game install directory.
36    ///
37    /// Defaults: platform is [`Platform::Win32`], both ignore-flags are off.
38    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    /// Returns the game installation directory.
49    #[must_use]
50    pub fn game_path(&self) -> &std::path::Path {
51        &self.game_path
52    }
53
54    /// Returns the current target platform.
55    #[must_use]
56    pub fn platform(&self) -> Platform {
57        self.platform
58    }
59
60    /// Returns whether missing files are silently ignored during apply.
61    #[must_use]
62    pub fn ignore_missing(&self) -> bool {
63        self.ignore_missing
64    }
65
66    /// Returns whether old-data mismatches are silently ignored during apply.
67    #[must_use]
68    pub fn ignore_old_mismatch(&self) -> bool {
69        self.ignore_old_mismatch
70    }
71
72    /// Sets the target platform. Defaults to [`Platform::Win32`].
73    /// Note: `SqpkTargetInfo` chunks in the patch stream will override this at apply time.
74    #[must_use]
75    pub fn with_platform(mut self, platform: Platform) -> Self {
76        self.platform = platform;
77        self
78    }
79
80    /// Silently ignore missing files instead of returning an error during apply.
81    #[must_use]
82    pub fn with_ignore_missing(mut self, v: bool) -> Self {
83        self.ignore_missing = v;
84        self
85    }
86
87    /// Silently ignore old-data mismatches instead of returning an error during apply.
88    #[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        // Crude eviction: clear all when full to bound open FD count.
97        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
122/// Applies a parsed chunk to the filesystem via an [`ApplyContext`].
123///
124/// Implementors should be idempotent against transient errors only when the
125/// chunk semantics permit it; in general callers must consume chunks in stream
126/// order.
127pub trait Apply {
128    /// Apply this chunk to `ctx`.
129    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}