Skip to main content

supreme_hook_config/
resolver.rs

1//! Disk-aware resolution: given a loaded [`HookConfig`] plus a game
2//! directory and a scratch/temp directory, decide what should happen to a
3//! given `rel_key` for reads, writes, and deletes, including staging
4//! patched replacement files and seeding write targets.
5//!
6//! This is deliberately an explicit, constructible type rather than a set
7//! of free functions over hidden global state. Two very different
8//! consumers need it:
9//! - `supreme_hook_proxy` (the DLL): one `HookResolver`, loaded lazily the
10//!   first time the game probes a magic trigger path, then reused for the
11//!   rest of the process's life.
12//! - anything else that wants to know "what would happen to this path"
13//!   without injecting into the game at all, e.g. a launcher page that
14//!   reads/writes a game file and wants mod redirection to apply exactly
15//!   as it would in-game.
16//!
17//! Each consumer owns its own lifecycle/caching policy on top of this; the
18//! type itself has no global state and no opinion on when it should be
19//! constructed.
20
21use crate::hook_config::{MATCH_OPTIONS, matched_suffix};
22use crate::patch::{MemoryPatch, apply_all};
23use crate::{
24    DEFAULT_MODS_DIR_NAME, HookConfig, HookConfigError, RedirectionMode, RedirectionRule,
25    ResolvedAction, RuleTarget,
26};
27use glob::Pattern;
28use log::{debug, error, info, warn};
29use std::collections::HashSet;
30use std::path::{Path, PathBuf};
31
32/// Cap on the number of *redirect* hops a single resolution chain may
33/// follow (see [`HookResolver::resolve_chain`] /
34/// [`HookResolver::resolve_chain_for_target`]) before it's forced to
35/// stop. Identity hops (a matching rule with no real redirect) aren't
36/// counted here, they can't loop, since scanning a stage always moves
37/// strictly forward through a finite rule list. This bound exists for
38/// the redirect-following part specifically, which *can* cycle
39/// (`A -> B -> A`) if a mod config is misconfigured. `panic = "abort"`
40/// on this workspace means a runaway chain would hang the game's file
41/// I/O rather than unwind, so this has to be a hard, provable bound, not
42/// a best-effort one.
43const MAX_REDIRECT_CHAIN_DEPTH: usize = 8;
44
45/// Owns a merged [`HookConfig`] plus the two directories every resolution
46/// needs: the game's root directory (to reconstruct original paths) and a
47/// scratch directory (to stage patched replacement files).
48#[derive(Debug)]
49pub struct HookResolver {
50    config: HookConfig,
51    game_dir: PathBuf,
52    temp_dir: PathBuf,
53}
54
55/// The outcome of walking [`HookResolver::resolve_chain`].
56struct ChainOutcome {
57    /// `None` means the chain was blocked outright by a `hard_error`
58    /// hop with no replacement available, nothing should be served,
59    /// regardless of what patches or earlier redirects were found
60    /// along the way.
61    terminal_path: Option<PathBuf>,
62    /// Patches from every stage visited, in visit order (and, within a
63    /// stage, in rule-list order). Empty if the chain never produced
64    /// anything to patch.
65    patches: Vec<MemoryPatch>,
66}
67
68impl HookResolver {
69    /// Builds a resolver directly from an already-loaded [`HookConfig`].
70    /// Prefer [`HookResolver::load`] unless you've already got a
71    /// `HookConfig` from somewhere else (e.g. a launcher page previewing a
72    /// config that hasn't been written to `mods_dir` yet).
73    #[must_use]
74    pub fn new(config: HookConfig, game_dir: PathBuf, temp_dir: PathBuf) -> Self {
75        Self {
76            config,
77            game_dir,
78            temp_dir,
79        }
80    }
81
82    /// Loads and merges every enabled mod's rules from `mods_dir` (see
83    /// [`HookConfig::load_from_mods_dir`]), rewriting every rule's
84    /// relative `target` to be absolute against `game_dir`, and wraps the
85    /// result in a resolver ready to use.
86    ///
87    /// Skipped mods (present in the active preset's enabled list but
88    /// whose `mod.json` couldn't be read/parsed) are returned alongside
89    /// the resolver rather than logged here, this crate takes no logging
90    /// dependency beyond the `log` facade, so it's the caller's call how
91    /// (or whether) to surface them.
92    ///
93    /// A missing `mods.json` is treated as "no mods enabled" rather
94    /// than a fatal error, the resolver falls back to an empty rule set
95    /// and every resolution becomes a no-op `PassThrough`.
96    #[must_use]
97    pub fn load(
98        mods_dir: &Path,
99        game_dir: PathBuf,
100        temp_dir: PathBuf,
101    ) -> (Self, Vec<(String, HookConfigError)>) {
102        info!("Loading mods from: {}", mods_dir.display());
103
104        let (mut config, skipped) = HookConfig::load_from_mods_dir(mods_dir).unwrap_or_else(|e| {
105            debug!(
106                "Could not load {} ({e}); proceeding with no mods enabled",
107                mods_dir.display()
108            );
109            (HookConfig::default(), Vec::new())
110        });
111
112        for rule in &mut config.rules {
113            match &mut rule.target {
114                Some(RuleTarget::Path(target)) if target.is_relative() => {
115                    *target = game_dir.join(&*target);
116                }
117                Some(RuleTarget::Managed { local, .. }) if local.is_relative() => {
118                    *local = game_dir.join(&*local);
119                }
120                _ => {}
121            }
122        }
123
124        debug!("Loaded config: {config:#?}");
125
126        (Self::new(config, game_dir, temp_dir), skipped)
127    }
128
129    /// Convenience wrapper around [`HookResolver::load`] using the
130    /// conventional `<game_dir>/.supreme/mods` mods directory
131    /// ([`DEFAULT_MODS_DIR_NAME`]).
132    #[must_use]
133    pub fn load_default(
134        game_dir: PathBuf,
135        temp_dir: PathBuf,
136    ) -> (Self, Vec<(String, HookConfigError)>) {
137        let mods_dir = game_dir.join(DEFAULT_MODS_DIR_NAME);
138        Self::load(&mods_dir, game_dir, temp_dir)
139    }
140
141    /// The merged config this resolver was built from.
142    #[must_use]
143    pub fn config(&self) -> &HookConfig {
144        &self.config
145    }
146
147    /// The game root directory this resolver reconstructs original paths
148    /// against.
149    #[must_use]
150    pub fn game_dir(&self) -> &Path {
151        &self.game_dir
152    }
153
154    fn original_path(&self, rel_key: &str) -> PathBuf {
155        self.game_dir
156            .join(rel_key.replace('/', std::path::MAIN_SEPARATOR_STR))
157    }
158
159    /// Returns the persistent temp path that mirrors the original relative
160    /// structure. E.g. rel_key `data/levels/menu_strings.txt` maps to
161    /// `<temp_dir>/data/levels/menu_strings.txt`.
162    ///
163    /// This ensures debuggable file names in the temp directory, idempotent
164    /// staging (the same rel_key always maps to the same temp path), and
165    /// consistency across repeated reads within a session.
166    fn structured_temp_path(&self, rel_key: &str) -> PathBuf {
167        self.temp_dir
168            .join(rel_key.replace('/', std::path::MAIN_SEPARATOR_STR))
169    }
170
171    /// Finds the first rule in `self.config.rules[start_index..]` whose
172    /// glob matches `rel_key`, returning its absolute index into
173    /// `self.config.rules` (so a caller can resume scanning just past it,
174    /// letting a subsequent rule for the same `rel_key` still apply,
175    /// see [`Self::resolve_chain`]).
176    fn next_matching_rule(
177        &self,
178        rel_key: &str,
179        start_index: usize,
180    ) -> Option<(usize, &RedirectionRule)> {
181        let normalized = rel_key.replace('\\', "/");
182        self.config
183            .rules
184            .get(start_index..)?
185            .iter()
186            .enumerate()
187            .find_map(|(i, rule)| {
188                Pattern::new(&rule.glob_pattern)
189                    .is_ok_and(|pattern| pattern.matches_with(&normalized, MATCH_OPTIONS))
190                    .then_some((start_index + i, rule))
191            })
192    }
193
194    /// Re-expresses an absolute path as a `rel_key` (matchable against
195    /// rule globs the same way any other `rel_key` is), if it falls
196    /// under `game_dir` at all. Case-insensitive, matching
197    /// [`crate::hook_config::matched_suffix`]'s handling of Windows
198    /// paths, and deliberately doesn't distinguish a target that lands
199    /// back inside `game_dir` from one that doesn't happen to right now
200    /// a mod's own target folder living under `game_dir` chains
201    /// exactly the same way a vanilla game asset path would.
202    ///
203    /// Returns `None` when `path` isn't expressible relative to
204    /// `game_dir` at all (a different drive, or simply outside it
205    /// entirely, the common case for a target pointing into
206    /// `.supreme/mods/...`). That's not a special case to handle further
207    /// up: no rule's glob is matched against anything but a `game_dir`-
208    /// relative path, so a chain naturally ends the moment it steps
209    /// outside it.
210    fn rel_key_of(&self, path: &Path) -> Option<String> {
211        let game_dir_lower = self
212            .game_dir
213            .to_string_lossy()
214            .replace('\\', "/")
215            .trim_end_matches('/')
216            .to_lowercase();
217        let path_lower = path.to_string_lossy().replace('\\', "/").to_lowercase();
218
219        let rest = path_lower.strip_prefix(&game_dir_lower)?;
220        let rest = rest.strip_prefix('/').unwrap_or(rest);
221        (!rest.is_empty()).then(|| rest.to_string())
222    }
223
224    /// Walks the chain of rules matching `rel_key` for a READ, and then
225    /// whatever `rel_key` each real redirect lands on in turn, the
226    /// "first matching rule wins" replacement.
227    ///
228    /// A `rel_key` is resolved in **stages**. Within a stage, rules are
229    /// scanned in list order, resuming wherever the previous match in
230    /// that same stage left off:
231    ///
232    /// - A rule whose `target` is configured *and* whose computed target
233    ///   file currently exists is a **redirect hop**: it ends the
234    ///   current stage. Its own `patches` are recorded for this chain
235    ///   (even though, if the chain continues, they won't be applied to
236    ///   *this* stage's file, see the note below), and resolution moves
237    ///   to the *next* stage: the redirect target, re-expressed as a
238    ///   `rel_key` via [`Self::rel_key_of`] and scanned again from the
239    ///   very top of the rule list (a fresh path can be matched by any
240    ///   rule, not just ones after the redirecting rule's position).
241    /// - A rule with no `target`, or whose target file doesn't currently
242    ///   exist, is an **identity hop**: it does not end the stage. In
243    ///   `graceful_fallback` mode, its `patches` are recorded and
244    ///   scanning continues *at the same `rel_key`*, from the next rule
245    ///   in the list, this is what lets a "patch the original file in
246    ///   place" rule (`target: None` with `patches`) sit alongside, and
247    ///   stack with, other rules for the same path instead of the first
248    ///   one shadowing the rest. In `hard_error` mode it blocks
249    ///   resolution immediately: nothing accumulated so far (from this
250    ///   or any earlier stage) is served.
251    /// - A stage with no further matching rule is **terminal**: its
252    ///   physical file is `original_path(current_rel_key)`, which for
253    ///   every stage after the first is exactly the previous stage's
254    ///   redirect target (by construction of [`Self::rel_key_of`] /
255    ///   [`Self::original_path`], they're inverses of each other for
256    ///   any path that round-trips through both).
257    ///
258    /// **Patches accumulate across every stage visited, in visit order,
259    /// and are applied once, at the very end, to the terminal file**,
260    /// including a redirect hop's own `patches`, deferred rather than
261    /// applied immediately to its own target, if the chain continues
262    /// past it. A patch authored assuming it runs against one particular
263    /// stage's byte layout could end up running against a *different*
264    /// terminal file if a later rule redirects further; that's an
265    /// authoring concern for chained mods, not something validated here.
266    ///
267    /// A visited-`rel_key` set (cycle guard) plus
268    /// [`MAX_REDIRECT_CHAIN_DEPTH`] stop a misconfigured or cyclic chain
269    /// deterministically.
270    fn resolve_chain(&self, rel_key: &str) -> ChainOutcome {
271        let mut current = rel_key.to_string();
272        let mut visited: HashSet<String> = HashSet::from([current.clone()]);
273        let mut patches: Vec<MemoryPatch> = Vec::new();
274        let mut redirect_hops = 0usize;
275
276        loop {
277            let mut resume_index = 0;
278            loop {
279                let Some((idx, rule)) = self.next_matching_rule(&current, resume_index) else {
280                    // Terminal: nothing further matches this stage.
281                    return ChainOutcome {
282                        terminal_path: Some(self.original_path(&current)),
283                        patches,
284                    };
285                };
286
287                let structured_target = compute_target_path(rule, &current);
288                let is_redirect = structured_target.as_ref().is_some_and(|p| p.exists());
289
290                if is_redirect {
291                    patches.extend(rule.patches.iter().cloned());
292                    let target = structured_target.expect("is_redirect implies Some");
293
294                    redirect_hops += 1;
295                    if redirect_hops > MAX_REDIRECT_CHAIN_DEPTH {
296                        warn!(
297                            "Redirect chain resolving {rel_key} exceeded depth cap of \
298                             {MAX_REDIRECT_CHAIN_DEPTH}, stopping at {}",
299                            target.display()
300                        );
301                        return ChainOutcome {
302                            terminal_path: Some(target),
303                            patches,
304                        };
305                    }
306
307                    match self.rel_key_of(&target) {
308                        Some(next) if visited.insert(next.clone()) => {
309                            current = next;
310                            break; // proceed to the next stage
311                        }
312                        Some(_) => {
313                            warn!(
314                                "Redirect cycle detected resolving {rel_key}, stopping at {}",
315                                target.display()
316                            );
317                            return ChainOutcome {
318                                terminal_path: Some(target),
319                                patches,
320                            };
321                        }
322                        None => {
323                            // Not expressible relative to game_dir --
324                            // nothing further can match it, so this is
325                            // the terminal file.
326                            return ChainOutcome {
327                                terminal_path: Some(target),
328                                patches,
329                            };
330                        }
331                    }
332                } else if rule.mode == RedirectionMode::HardError {
333                    return ChainOutcome {
334                        terminal_path: None,
335                        patches: Vec::new(),
336                    };
337                } else {
338                    patches.extend(rule.patches.iter().cloned());
339                    resume_index = idx + 1;
340                    // keep scanning the same stage
341                }
342            }
343        }
344    }
345
346    /// Write/delete equivalent of [`Self::resolve_chain`]: walks the
347    /// same stage-by-stage structure, a rule with no `target` is an
348    /// identity hop that lets scanning continue past it within the same
349    /// stage, and a rule with a `target` is a redirect hop that moves to
350    /// the next stage, but existence-independent (a `target` alone is
351    /// enough to redirect, matching today's write behavior: a write
352    /// always seeds/creates its target rather than waiting for it to
353    /// already exist) and collects no patches (a read-only concept,
354    /// unchanged).
355    ///
356    /// A stage with no further matching rule is terminal, exactly as in
357    /// [`Self::resolve_chain`], and exactly as there, if we arrived at
358    /// this stage via at least one real redirect, that redirect's
359    /// computed target *is* the resolved destination (kept byte-exact,
360    /// casing included, see the `last_target` note in the body; the old
361    /// `original_path(rel_key_of(target))` re-expression lowercased it).
362    /// Only when no redirect ever happened, the rel_key
363    /// either matched nothing, or matched only identity hops throughout
364    /// does this report `None`, preserving the original single-hop
365    /// behavior for a `target: None` rule (straight `PassThrough`, no
366    /// seed-and-redirect machinery kicking in for a file that was never
367    /// meant to be redirected at all).
368    ///
369    /// Returns the **last** rule reached (for its `mode`, which
370    /// [`Self::resolve_path_for_delete`]'s missing-file fallback uses,
371    /// "everything happens at the end of the redirection path," not just
372    /// the first rule matched) together with the terminal stage's
373    /// structured target path, or `None` if no rule ever matched at all.
374    fn resolve_chain_for_target(
375        &self,
376        rel_key: &str,
377    ) -> Option<(&RedirectionRule, Option<PathBuf>)> {
378        let mut current = rel_key.to_string();
379        let mut visited: HashSet<String> = HashSet::from([current.clone()]);
380        let mut redirect_hops = 0usize;
381        let mut last_rule: Option<&RedirectionRule> = None;
382        // The last REAL redirect target, kept verbatim: re-expressing the
383        // target through `rel_key_of` would fold case to match rule globs
384        // and silently rewrite a declared target's casing whenever it
385        // lived under game_dir (`.supreme/mods/...` always does). On NTFS
386        // that is the same file, but the engine's byte-exact bookkeeping
387        // (the managed-file close hook compares the write target with the
388        // registered local) never saw them as the same path, a MixedCase
389        // managed `local` then never captured its game writes. Carrying
390        // the computed target forward keeps the declared path byte-exact;
391        // same file either way, the declared spelling is simply preserved.
392        let mut last_target: Option<PathBuf> = None;
393
394        loop {
395            let mut resume_index = 0;
396            loop {
397                let Some((idx, rule)) = self.next_matching_rule(&current, resume_index) else {
398                    // Terminal: nothing further matches this stage. When at
399                    // least one real redirect happened, its LAST computed
400                    // target IS the resolved destination (byte-exact, casing
401                    // included); `redirect_hops == 0` means only identity
402                    // hops matched, so there is no target at all.
403                    return last_rule.map(|r| (r, last_target));
404                };
405                last_rule = Some(rule);
406
407                let Some(target) = compute_target_path(rule, &current) else {
408                    // identity hop: no target at all, keep scanning this stage
409                    resume_index = idx + 1;
410                    continue;
411                };
412
413                redirect_hops += 1;
414                last_target = Some(target.clone());
415                if redirect_hops > MAX_REDIRECT_CHAIN_DEPTH {
416                    warn!(
417                        "Write/delete redirect chain resolving {rel_key} exceeded depth \
418                         cap of {MAX_REDIRECT_CHAIN_DEPTH}, stopping at {}",
419                        target.display()
420                    );
421                    return Some((rule, Some(target)));
422                }
423
424                match self.rel_key_of(&target) {
425                    Some(next) if visited.insert(next.clone()) => {
426                        current = next;
427                        break; // proceed to the next stage
428                    }
429                    Some(_) => {
430                        warn!(
431                            "Write/delete redirect cycle resolving {rel_key}, stopping at {}",
432                            target.display()
433                        );
434                        return Some((rule, Some(target)));
435                    }
436                    None => return Some((rule, Some(target))),
437                }
438            }
439        }
440    }
441
442    /// Resolves a game-relative path for READ operations.
443    ///
444    /// Flow:
445    /// 1. Walk the redirect/patch chain ([`Self::resolve_chain`]) to find
446    ///    the terminal physical file and the full accumulated patch list.
447    /// 2. If patches exist, stage to a structured, persistent temp path.
448    ///    - If any accumulated patch is a [`MemoryPatch::Script`], always
449    ///      re-read the base file and re-run every patch as script can
450    ///      derive its output from the current time variable, so it must
451    ///      run fresh on every read rather than being served from a stale
452    ///      cached stage.
453    ///    - Otherwise, if the temp file already exists and is newer than
454    ///      the base file, reuse it; otherwise read base + apply patches +
455    ///      write to the structured temp path.
456    /// 3. If no patches, return the resolved file directly.
457    ///
458    /// Not cached beyond the temp-file reuse above: external writers may
459    /// change the underlying files at any time, so every call re-evaluates
460    /// filesystem state.
461    #[must_use]
462    pub fn resolve_path(&self, rel_key: &str) -> ResolvedAction {
463        let chain = self.resolve_chain(rel_key);
464
465        let Some(base_path) = chain.terminal_path else {
466            debug!("RESOLVE {rel_key} => HardErrorMissingFile (blocked)");
467            return ResolvedAction::HardErrorMissingFile;
468        };
469
470        let patches = chain.patches.as_slice();
471        let is_original = base_path == self.original_path(rel_key);
472
473        // What to serve if there's nothing to patch -- also the fallback
474        // if patch staging itself fails below.
475        let no_patch_action = || {
476            if is_original {
477                ResolvedAction::PassThrough
478            } else {
479                ResolvedAction::Redirect {
480                    replacement_path: base_path.clone(),
481                }
482            }
483        };
484
485        let final_action = if patches.is_empty() {
486            no_patch_action()
487        } else {
488            let temp_path = self.structured_temp_path(rel_key);
489
490            // A `Script` patch can derive its output from `timestamp` so
491            // it can behave differently on every read (randomization).
492            // Plain find/replace patches (deterministic in their output)
493            // keep the existing "only restage when the base file changed"
494            // behavior.
495            let has_script_patch = patches
496                .iter()
497                .any(|p| matches!(p, MemoryPatch::Script { .. }));
498
499            let needs_restage = if has_script_patch {
500                true
501            } else if temp_path.exists() {
502                match (std::fs::metadata(&base_path), std::fs::metadata(&temp_path)) {
503                    (Ok(bm), Ok(tm)) => match (bm.modified(), tm.modified()) {
504                        (Ok(bt), Ok(tt)) => bt > tt,
505                        _ => true,
506                    },
507                    _ => true,
508                }
509            } else {
510                true
511            };
512
513            if needs_restage {
514                match (|| -> std::io::Result<()> {
515                    if let Some(parent) = temp_path.parent() {
516                        std::fs::create_dir_all(parent)?;
517                    }
518                    let data = std::fs::read(&base_path)?;
519
520                    // Handle fallible patch application
521                    let patched = match apply_all(patches, &data, rel_key) {
522                        Ok(p) => p,
523                        Err(e) => {
524                            error!(
525                                "Patch application failed for {}: {}",
526                                base_path.display(),
527                                e
528                            );
529                            return Err(std::io::Error::other(e.to_string()));
530                        }
531                    };
532
533                    std::fs::write(&temp_path, &patched)?;
534                    Ok(())
535                })() {
536                    Ok(()) => ResolvedAction::Redirect {
537                        replacement_path: temp_path,
538                    },
539                    Err(e) => {
540                        error!("IO error staging {}: {e}", base_path.display());
541                        no_patch_action()
542                    }
543                }
544            } else {
545                ResolvedAction::Redirect {
546                    replacement_path: temp_path,
547                }
548            }
549        };
550
551        debug!("RESOLVE {rel_key} => {final_action:?}");
552        final_action
553    }
554
555    /// Whether a directory listing should hide the *original* game file for
556    /// `rel_key`, so that listings agree with what a direct open of the same
557    /// rel_key would do (`resolve_path`): a `hard_error` rule hides the
558    /// original unconditionally, the moment it matches, exactly as it would
559    /// on a read, it never falls back to showing stale original content
560    /// just because nothing's been staged in its place yet. A
561    /// `graceful_fallback` rule only hides the original once a real
562    /// replacement exists on disk to show instead; until then the original
563    /// stays visible, same as a read would fall back to serving it.
564    ///
565    /// Deliberately uses only the pure decision layer
566    /// ([`Self::resolve_chain`], existence checks only, no I/O writes)
567    /// rather than the full `resolve_path`, a directory listing walks
568    /// every entry in the directory, and `resolve_path` stages patched
569    /// files to disk on demand, which would mean every entry in a patched
570    /// directory gets eagerly (re-)staged just to decide whether to show
571    /// it. Patch staging stays lazy, deferred to the first time a file is
572    /// actually opened.
573    #[must_use]
574    pub fn should_hide_original_entry(&self, rel_key: &str) -> bool {
575        let chain = self.resolve_chain(rel_key);
576        match chain.terminal_path {
577            None => true, // blocked by a hard_error hop -- hidden either way
578            Some(base_path) => {
579                base_path != self.original_path(rel_key) || !chain.patches.is_empty()
580            }
581        }
582    }
583
584    /// Resolves the physical target path for a rel_key destined for a
585    /// write-type operation, ensuring its parent directory exists. Returns
586    /// `None` if no rule matches (at any stage of the chain), the
587    /// directory couldn't be created, or the target is the original file.
588    /// Call [`Self::has_write_redirect`] to distinguish an unredirected
589    /// path from a configured target that could not be prepared.
590    #[must_use]
591    pub fn resolve_write_target(&self, rel_key: &str) -> Option<PathBuf> {
592        let (_rule, target_path) = self.resolve_chain_for_target(rel_key)?;
593        let target_path = target_path?;
594
595        let normalized_key = rel_key.to_lowercase();
596        if self.rel_key_of(&target_path).as_deref() == Some(normalized_key.as_str()) {
597            error!("Write target for {rel_key} resolves back to the original file");
598            return None;
599        }
600
601        if let Some(parent) = target_path.parent()
602            && let Err(e) = std::fs::create_dir_all(parent)
603        {
604            error!(
605                "Failed to create target directory {}: {e}",
606                parent.display()
607            );
608            return None;
609        }
610
611        Some(target_path)
612    }
613
614    /// Whether a write rule has a target, even when its directory cannot be
615    /// prepared. A rename must fail instead of falling through to the
616    /// unmodified game path in that case.
617    #[must_use]
618    pub fn has_write_redirect(&self, rel_key: &str) -> bool {
619        self.resolve_chain_for_target(rel_key)
620            .is_some_and(|(_, target)| target.is_some())
621    }
622
623    /// Resolves a game-relative path for WRITE/CREATE/OVERWRITE operations.
624    ///
625    /// Never opens the original game file in write mode: if the target
626    /// doesn't exist yet, it's seeded by copying the original first (see
627    /// [`seed_target_file`]). Patches are not applied for writes, they're
628    /// a read-time transform.
629    #[must_use]
630    pub fn resolve_path_for_write(&self, rel_key: &str) -> ResolvedAction {
631        let Some((_rule, Some(target_path))) = self.resolve_chain_for_target(rel_key) else {
632            return ResolvedAction::PassThrough;
633        };
634
635        // A self-redirect (including one reached after several hops) cannot
636        // protect the original file: opening that target would write through
637        // to the game installation. Refuse the write instead.
638        let normalized_key = rel_key.to_lowercase();
639        if self.rel_key_of(&target_path).as_deref() == Some(normalized_key.as_str()) {
640            error!("Write target for {rel_key} resolves back to the original file");
641            return ResolvedAction::HardErrorMissingFile;
642        }
643
644        if let Some(parent) = target_path.parent()
645            && let Err(e) = std::fs::create_dir_all(parent)
646        {
647            error!("Cannot prepare write target {}: {e}", target_path.display());
648            return ResolvedAction::HardErrorMissingFile;
649        }
650
651        let original_path = self.original_path(rel_key);
652        if original_path.exists() {
653            match seed_target_file(&original_path, &target_path) {
654                Ok(true) => debug!(
655                    "WRITE SEED: copied original {} -> {}",
656                    original_path.display(),
657                    target_path.display()
658                ),
659                Ok(false) => debug!(
660                    "WRITE SEED: {} already present, skipping",
661                    target_path.display()
662                ),
663                Err(e) => {
664                    error!("Failed to seed target file {}: {e}", target_path.display());
665                    return ResolvedAction::HardErrorMissingFile;
666                }
667            }
668        } else if !target_path.exists() {
669            debug!(
670                "WRITE CREATE: original missing, caller will create new file at {}",
671                target_path.display()
672            );
673        }
674
675        debug!("WRITE RESOLVE {rel_key} -> {}", target_path.display());
676
677        ResolvedAction::Redirect {
678            replacement_path: target_path,
679        }
680    }
681
682    /// Resolves a game-relative path for DELETE operations.
683    ///
684    /// - If a hooked file exists at the end of the redirect chain (static
685    ///   config replacement, or a file previously written/created into a
686    ///   `target`), the delete is redirected there.
687    /// - If no hooked file exists: the **last** rule reached while
688    ///   walking the chain decides the fallback, `hard_error` mode
689    ///   blocks the delete (consistent with hard_error's "hide missing
690    ///   files" read contract); `graceful_fallback` mode falls back to
691    ///   the original. This is deliberately the last rule reached, not
692    ///   the first one matched, a delete on a path that was redirected
693    ///   one or more times follows the chain to its end, same as a read
694    ///   or a write would, and it's the rule governing that terminal
695    ///   stage whose `mode` actually applies.
696    ///
697    /// Always re-checks the filesystem fresh, same as [`resolve_path`].
698    /// Patches are irrelevant here.
699    ///
700    /// Deliberately does *not* consult [`HookConfig::resolve`] the way
701    /// [`resolve_path`] does: a delete must land on exactly the file the
702    /// create wrote, so both share one suffix implementation
703    /// ([`compute_target_path`], via [`crate::matched_suffix`]) as the
704    /// single source of truth for "where does this rel_key's file live".
705    ///
706    /// [`resolve_path`]: HookResolver::resolve_path
707    #[must_use]
708    pub fn resolve_path_for_delete(&self, rel_key: &str) -> ResolvedAction {
709        let Some((rule, target_path)) = self.resolve_chain_for_target(rel_key) else {
710            return ResolvedAction::PassThrough;
711        };
712
713        let final_action = match target_path {
714            Some(path) if path.exists() => ResolvedAction::Redirect {
715                replacement_path: path,
716            },
717            _ => match rule.mode {
718                RedirectionMode::HardError => ResolvedAction::HardErrorMissingFile,
719                RedirectionMode::GracefulFallback => ResolvedAction::PassThrough,
720            },
721        };
722
723        debug!("DELETE RESOLVE {rel_key} => {final_action:?}");
724        final_action
725    }
726
727    /// Collects mod filenames directly under a given relative directory, by
728    /// scanning each matching wildcard rule's `target` directory on disk,
729    /// plus injecting the single file named by any matching literal-glob
730    /// rule (whose `target` is a file, not a directory to scan, see
731    /// [`RedirectionRule::is_literal_glob`]).
732    #[must_use]
733    pub fn collect_mod_files_in_dir(&self, rel_dir: &str) -> Vec<String> {
734        let mut files: Vec<String> = Vec::new();
735        let rel_dir_lower = rel_dir.to_lowercase();
736
737        for rule in &self.config.rules {
738            let Some(target) = rule.target.as_ref() else {
739                continue;
740            };
741            let glob_lower = rule.glob_pattern.to_lowercase();
742
743            if rule.is_literal_glob() {
744                let rule_dir = Path::new(&glob_lower)
745                    .parent()
746                    .map(|p| p.to_string_lossy().replace('\\', "/"))
747                    .unwrap_or_default();
748                // managed (remote-backed) targets are listed even
749                // when the local file does not exist yet. The game's
750                // discovery is listing-driven: without the name it never
751                // probes the slot, never triggers the fetch-on-access, and
752                // a remote-backed file would stay invisible forever (the
753                // tournament provider had the same fix as manual
754                // "view-sync" injection). A probe that arrives before the
755                // first fetch resolves gracefully, that is exactly what
756                // the rule's mode is for.
757                let listed = target.path().exists() || target.managed().is_some();
758                if rule_dir == rel_dir_lower
759                    && listed
760                    && let Some(name) = target.path().file_name().and_then(|n| n.to_str())
761                {
762                    files.push(name.to_string());
763                }
764                continue;
765            }
766
767            let prefix_matches = if let Some(star_pos) = glob_lower.find("**") {
768                let prefix = &glob_lower[..star_pos];
769                rel_dir_lower.starts_with(prefix.trim_end_matches('/').trim_end_matches('\\'))
770            } else {
771                let rule_dir = Path::new(&glob_lower)
772                    .parent()
773                    .map(|p| p.to_string_lossy().replace('\\', "/"))
774                    .unwrap_or_default();
775                rule_dir == rel_dir_lower
776            };
777
778            if !prefix_matches {
779                continue;
780            }
781
782            let scan_dir = if let Some(star_pos) = glob_lower.find("**") {
783                let prefix = &glob_lower[..star_pos];
784                let suffix = rel_dir_lower
785                    .strip_prefix(prefix.trim_end_matches('/').trim_end_matches('\\'))
786                    .unwrap_or("")
787                    .trim_start_matches('/')
788                    .trim_start_matches('\\');
789                if suffix.is_empty() {
790                    target.path().to_path_buf()
791                } else {
792                    target.path().join(suffix)
793                }
794            } else {
795                target.path().to_path_buf()
796            };
797
798            if let Ok(entries) = std::fs::read_dir(&scan_dir) {
799                for entry in entries.flatten() {
800                    if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false)
801                        && let Some(name) = entry.file_name().to_str()
802                        // Repo bookkeeping artifacts (`.gitkeep` & friends)
803                        // live in instance dirs too; they must never be
804                        // advertised to the game's directory listings.
805                        && !name.starts_with('.')
806                    {
807                        files.push(name.to_string());
808                    }
809                }
810            }
811        }
812
813        files.sort_unstable();
814        files.dedup();
815        files
816    }
817}
818
819/// Determines whether a file open operation intends to write or create.
820///
821/// Strictly follows Windows NT API definitions:
822/// - Dispositions 0 (SUPERSEDE), 2 (CREATE), 4 (OVERWRITE), 5
823///   (OVERWRITE_IF) unconditionally imply write intent.
824/// - Dispositions 1 (OPEN) and 3 (OPEN_IF) are only writes if
825///   `desired_access` includes GENERIC_WRITE, FILE_WRITE_DATA, or
826///   FILE_APPEND_DATA.
827///
828/// The bitmasks here are plain NT ABI constants, not Windows-crate types,
829/// so this has no platform dependency of its own, the proxy DLL calls it
830/// with values decoded from a real `NtCreateFile` call; any other caller
831/// (including tests) can call it with the same raw `u32`s.
832#[must_use]
833pub fn is_write_operation(desired_access: u32, create_disposition: u32) -> bool {
834    const WRITE_ACCESS_MASK: u32 = 0x4000_0000 | 0x0000_0002 | 0x0000_0004;
835    const DESTRUCTIVE_DISPOSITIONS: [u32; 4] = [0, 2, 4, 5];
836
837    (desired_access & WRITE_ACCESS_MASK) != 0
838        || DESTRUCTIVE_DISPOSITIONS.contains(&create_disposition)
839}
840
841/// Computes the full target path for a redirected file. Returns `None` if
842/// the rule has no `target` configured, there's nothing to compute a path
843/// against.
844///
845/// - **Literal glob**: `target` *is* the replacement/write file, used
846///   as-is.
847/// - **Wildcard glob**: preserves the relative sub-directory structure
848///   below the rule's fixed prefix, via [`crate::matched_suffix`], the
849///   same computation [`HookConfig::resolve`] uses. Exactly one
850///   implementation, so a file written via
851///   [`HookResolver::resolve_path_for_write`] and one probed via
852///   [`HookResolver::resolve_path`] or
853///   [`HookResolver::resolve_path_for_delete`] land at the same path
854///   for the same rel_key.
855#[must_use]
856pub fn compute_target_path(rule: &RedirectionRule, rel_key: &str) -> Option<PathBuf> {
857    let target = rule.target.as_ref()?;
858    let path = target.path();
859
860    if rule.is_literal_glob() {
861        return Some(path.to_path_buf());
862    }
863
864    Some(path.join(matched_suffix(&rule.glob_pattern, rel_key)))
865}
866
867/// One remote-backed managed file declared by a [`RuleTarget::Managed`]
868/// rule. A mod's `mod.json` names the file (literal glob),
869/// its local path, the URL whose body is the file content, an optional
870/// push URL for game writes, and the auto-refresh cadence, the
871/// proxy DLL's generic managed-file engine consumes exactly this, with no
872/// per-mod knowledge inside the DLL.
873#[derive(Debug, Clone, PartialEq)]
874pub struct ManagedFileSpec {
875    /// The rule's rel_key (its literal glob, separators normalized to `/`);
876    /// the hooks compare case-insensitively.
877    pub rel_key: String,
878    /// Absolute local path the redirect serves and the refresh engine
879    /// writes (relative rule targets already resolved against `game_dir`).
880    pub local: PathBuf,
881    /// URL whose HTTP-GET body IS the file content, already interpolated.
882    pub remote: String,
883    /// Auto-refresh cadence in seconds once the file has been accessed;
884    /// `None` = no polling (refresh on access only).
885    pub poll_interval_secs: Option<u64>,
886    /// Where every game write of the file is POSTed to, already
887    /// interpolated; `None` = fetch-only (writes never uploaded).
888    pub push_url: Option<String>,
889}
890
891/// Collects every [`RuleTarget::Managed`] rule from the loaded config, in
892/// config order. Relative `local` paths are resolved against `game_dir`
893/// exactly like [`HookResolver::load`] does, so this accepts either a
894/// loaded resolver's config (absolute) or a raw authored one (relative).
895/// Rules whose glob is not literal are skipped here, a wildcard glob
896/// cannot name the single file a remote URL provides, and
897/// [`crate::mod_config::ModConfig::validate`] already rejects them with a
898/// proper error at load time; this scan just stays defensive.
899#[must_use]
900pub fn managed_file_specs(game_dir: &Path, config: &HookConfig) -> Vec<ManagedFileSpec> {
901    let mut out = Vec::new();
902    for rule in &config.rules {
903        let Some(target) = &rule.target else {
904            continue;
905        };
906        let Some((remote, poll_interval_secs, push_url)) = target.managed() else {
907            continue;
908        };
909        if !rule.is_literal_glob() {
910            continue;
911        }
912        let local = target.path();
913        let local_abs = if local.is_absolute() {
914            local.to_path_buf()
915        } else {
916            game_dir.join(local)
917        };
918        out.push(ManagedFileSpec {
919            rel_key: rule.glob_pattern.replace('\\', "/"),
920            local: local_abs,
921            remote: remote.to_string(),
922            poll_interval_secs,
923            push_url: push_url.map(str::to_string),
924        });
925    }
926    out
927}
928
929/// Seeds a sibling temporary file first, then atomically creates a hard link
930/// at `target_path`. Other writers only see a complete copy, and linking does
931/// not overwrite a target another writer created. The temporary file is
932/// removed after the link is created (or after any error). Both paths are in
933/// the same directory, so they are on the same volume. A filesystem that
934/// cannot create a hard link fails the seed rather than exposing a partial
935/// target or modifying the original.
936///
937/// Returns `Ok(true)` if this call performed the seed, `Ok(false)` if
938/// another call already had (or was concurrently doing so), or `Err` for
939/// any other I/O failure.
940///
941/// # Errors
942///
943/// Returns an error if `original_path` cannot be read or the copy fails
944/// for a reason other than `target_path` already existing.
945pub fn seed_target_file(original_path: &Path, target_path: &Path) -> std::io::Result<bool> {
946    if target_path.exists() {
947        return Ok(false);
948    }
949    let parent = target_path.parent().ok_or_else(|| {
950        std::io::Error::new(
951            std::io::ErrorKind::InvalidInput,
952            "write target has no parent",
953        )
954    })?;
955    let mut staged = tempfile::NamedTempFile::new_in(parent)?;
956    std::io::copy(
957        &mut std::fs::File::open(original_path)?,
958        staged.as_file_mut(),
959    )?;
960    staged.as_file_mut().sync_all()?;
961    match std::fs::hard_link(staged.path(), target_path) {
962        Ok(()) => Ok(true),
963        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
964        Err(e) => Err(e),
965    }
966}
967
968#[cfg(test)]
969mod chain_resolution_tests {
970    //! Read-side chain resolution: `resolve_path`'s replacement for
971    //! "first matching rule wins" -- see `HookResolver::resolve_chain`.
972    //!
973    //! `identity_hop_with_patches_and_no_target_still_patches_original`
974    //! is the regression guard for the exact bug this crate shipped
975    //! once already: a `target: None` rule with `patches` is not an
976    //! inert placeholder, it's "patch the original file in place," and
977    //! must keep working exactly as before no matter how the rest of
978    //! rule matching changes around it.
979
980    use super::HookResolver;
981    use crate::{
982        HookConfig, MemoryPatch, RedirectionMode, RedirectionRule, RuleTarget, StringReplacement,
983    };
984    use std::fs;
985    use std::path::PathBuf;
986
987    struct ScratchDir(PathBuf);
988
989    impl ScratchDir {
990        fn new(name: &str) -> Self {
991            let dir = std::env::temp_dir().join(format!(
992                "hook_resolver_chain_test_{name}_{}",
993                std::process::id()
994            ));
995            let _ = fs::remove_dir_all(&dir);
996            fs::create_dir_all(dir.join("game")).unwrap();
997            Self(dir)
998        }
999
1000        fn path(&self, name: &str) -> PathBuf {
1001            self.0.join(name)
1002        }
1003
1004        fn write_file(&self, name: &str, contents: &[u8]) {
1005            let path = self.path(name);
1006            fs::create_dir_all(path.parent().unwrap()).unwrap();
1007            fs::write(path, contents).unwrap();
1008        }
1009    }
1010
1011    impl Drop for ScratchDir {
1012        fn drop(&mut self) {
1013            let _ = fs::remove_dir_all(&self.0);
1014        }
1015    }
1016
1017    fn resolver_for(dir: &ScratchDir, rules: Vec<RedirectionRule>) -> HookResolver {
1018        HookResolver::new(HookConfig { rules }, dir.path("game"), dir.path("temp"))
1019    }
1020
1021    fn replace_patch(find: &str, replace: &str) -> MemoryPatch {
1022        MemoryPatch::ReplaceString {
1023            replacements: vec![StringReplacement {
1024                find: find.to_string(),
1025                replace: replace.to_string(),
1026                enable_if: None,
1027            }],
1028        }
1029    }
1030
1031    fn identity_rule(
1032        glob_pattern: &str,
1033        mode: RedirectionMode,
1034        patches: Vec<MemoryPatch>,
1035    ) -> RedirectionRule {
1036        RedirectionRule {
1037            glob_pattern: glob_pattern.into(),
1038            target: None,
1039            mode,
1040            patches,
1041            enable_if: None,
1042        }
1043    }
1044
1045    fn redirect_rule(
1046        glob_pattern: &str,
1047        target: PathBuf,
1048        mode: RedirectionMode,
1049        patches: Vec<MemoryPatch>,
1050    ) -> RedirectionRule {
1051        RedirectionRule {
1052            glob_pattern: glob_pattern.into(),
1053            target: Some(RuleTarget::Path(target)),
1054            mode,
1055            patches,
1056            enable_if: None,
1057        }
1058    }
1059
1060    #[test]
1061    fn identity_hop_with_patches_and_no_target_still_patches_original() {
1062        // Exactly the shape of the reported regression: a single rule,
1063        // target: None, mode: graceful_fallback, one ReplaceString patch.
1064        let dir = ScratchDir::new("regression_detail_config");
1065        dir.write_file("game/Data/Detail_Config.txt", b"amount_of_snowflakes\t= 1;");
1066
1067        let resolver = resolver_for(
1068            &dir,
1069            vec![identity_rule(
1070                "Data/Detail_Config.txt",
1071                RedirectionMode::GracefulFallback,
1072                vec![replace_patch(
1073                    "amount_of_snowflakes\t= 1;",
1074                    "amount_of_snowflakes = 10.0;",
1075                )],
1076            )],
1077        );
1078
1079        let action = resolver.resolve_path("Data/Detail_Config.txt");
1080        let crate::ResolvedAction::Redirect { replacement_path } = action else {
1081            panic!("expected a staged patch redirect, got {action:?}");
1082        };
1083        assert_eq!(
1084            fs::read(&replacement_path).unwrap(),
1085            b"amount_of_snowflakes = 10.0;"
1086        );
1087    }
1088
1089    #[test]
1090    fn two_identity_hops_stack_patches_in_rule_order() {
1091        let dir = ScratchDir::new("two_identity_hops");
1092        dir.write_file("game/data/levels/Object_Data.txt", b"base");
1093
1094        let resolver = resolver_for(
1095            &dir,
1096            vec![
1097                identity_rule(
1098                    "**/Object_Data.txt",
1099                    RedirectionMode::GracefulFallback,
1100                    vec![replace_patch("base", "base+ring")],
1101                ),
1102                identity_rule(
1103                    "**/Object_Data.txt",
1104                    RedirectionMode::GracefulFallback,
1105                    vec![replace_patch("ring", "ring+lighting")],
1106                ),
1107            ],
1108        );
1109
1110        let action = resolver.resolve_path("data/levels/Object_Data.txt");
1111        let crate::ResolvedAction::Redirect { replacement_path } = action else {
1112            panic!("expected a staged patch redirect, got {action:?}");
1113        };
1114        assert_eq!(fs::read(&replacement_path).unwrap(), b"base+ring+lighting");
1115    }
1116
1117    #[test]
1118    fn redirect_hop_then_identity_hops_stack_on_top() {
1119        // The full worked example: rule 1 redirects a campaign level to
1120        // the training level (both under game_dir), rules 2 and 3 then
1121        // patch Object_Data.txt at the *redirected* location.
1122        let dir = ScratchDir::new("redirect_then_identity");
1123        dir.write_file("game/campaign/level1.dat", b"unused");
1124        dir.write_file("game/campaign/training.dat", b"training level");
1125        dir.write_file("game/campaign/Object_Data.txt", b"base");
1126
1127        let resolver = resolver_for(
1128            &dir,
1129            vec![
1130                redirect_rule(
1131                    "campaign/level1.dat",
1132                    dir.path("game/campaign/training.dat"),
1133                    RedirectionMode::GracefulFallback,
1134                    vec![],
1135                ),
1136                identity_rule(
1137                    "**/Object_Data.txt",
1138                    RedirectionMode::GracefulFallback,
1139                    vec![replace_patch("base", "base+ring")],
1140                ),
1141                identity_rule(
1142                    "**/Object_Data.txt",
1143                    RedirectionMode::GracefulFallback,
1144                    vec![replace_patch("ring", "ring+lighting")],
1145                ),
1146            ],
1147        );
1148
1149        // Rule 1 doesn't touch Object_Data.txt directly -- this test is
1150        // really about rules 2/3 firing on campaign/Object_Data.txt
1151        // regardless of whether level1.dat itself was redirected; the
1152        // key assertion is the same as the two-identity-hops test, kept
1153        // separate to document the worked example explicitly.
1154        let action = resolver.resolve_path("campaign/Object_Data.txt");
1155        let crate::ResolvedAction::Redirect { replacement_path } = action else {
1156            panic!("expected a staged patch redirect, got {action:?}");
1157        };
1158        assert_eq!(fs::read(&replacement_path).unwrap(), b"base+ring+lighting");
1159
1160        // And the level swap itself still works as a plain redirect.
1161        assert_eq!(
1162            resolver.resolve_path("campaign/level1.dat"),
1163            crate::ResolvedAction::Redirect {
1164                replacement_path: dir.path("game/campaign/training.dat")
1165            }
1166        );
1167    }
1168
1169    #[test]
1170    fn redirecting_rules_own_patches_apply_at_the_end_not_immediately() {
1171        // A -> B via a rule that ALSO patches, then B -> C via a second
1172        // redirect. A's patch must land on C's content, not B's -- B is
1173        // never actually served, it's superseded by C.
1174        let dir = ScratchDir::new("deferred_patch");
1175        fs::create_dir_all(dir.path("game/mid")).unwrap();
1176        fs::create_dir_all(dir.path("game/final")).unwrap();
1177        fs::write(dir.path("game/mid/file.txt"), b"unused").unwrap();
1178        fs::write(dir.path("game/final/file.txt"), b"final content").unwrap();
1179
1180        let resolver = resolver_for(
1181            &dir,
1182            vec![
1183                redirect_rule(
1184                    "start/file.txt",
1185                    dir.path("game/mid/file.txt"),
1186                    RedirectionMode::GracefulFallback,
1187                    vec![replace_patch("final", "FINAL")],
1188                ),
1189                redirect_rule(
1190                    "mid/file.txt",
1191                    dir.path("game/final/file.txt"),
1192                    RedirectionMode::GracefulFallback,
1193                    vec![],
1194                ),
1195            ],
1196        );
1197
1198        let action = resolver.resolve_path("start/file.txt");
1199        let crate::ResolvedAction::Redirect { replacement_path } = action else {
1200            panic!("expected a staged patch redirect, got {action:?}");
1201        };
1202        assert_eq!(fs::read(&replacement_path).unwrap(), b"FINAL content");
1203    }
1204
1205    #[test]
1206    fn hard_error_identity_hop_blocks_regardless_of_prior_patches() {
1207        let dir = ScratchDir::new("hard_error_blocks");
1208        dir.write_file("game/data/thing.txt", b"base");
1209
1210        let resolver = resolver_for(
1211            &dir,
1212            vec![
1213                identity_rule(
1214                    "data/thing.txt",
1215                    RedirectionMode::GracefulFallback,
1216                    vec![replace_patch("base", "patched")],
1217                ),
1218                identity_rule("data/thing.txt", RedirectionMode::HardError, vec![]),
1219            ],
1220        );
1221
1222        assert_eq!(
1223            resolver.resolve_path("data/thing.txt"),
1224            crate::ResolvedAction::HardErrorMissingFile
1225        );
1226    }
1227
1228    #[test]
1229    fn chain_cycle_is_broken() {
1230        let dir = ScratchDir::new("cycle");
1231        fs::write(dir.path("game/a.txt"), b"a").unwrap();
1232        fs::write(dir.path("game/b.txt"), b"b").unwrap();
1233
1234        let resolver = resolver_for(
1235            &dir,
1236            vec![
1237                redirect_rule(
1238                    "a.txt",
1239                    dir.path("game/b.txt"),
1240                    RedirectionMode::GracefulFallback,
1241                    vec![],
1242                ),
1243                redirect_rule(
1244                    "b.txt",
1245                    dir.path("game/a.txt"),
1246                    RedirectionMode::GracefulFallback,
1247                    vec![],
1248                ),
1249            ],
1250        );
1251
1252        // The cycle returns to the requested original, so the correct
1253        // outcome is pass-through. It must terminate without looping.
1254        let action = resolver.resolve_path("a.txt");
1255        assert_eq!(action, crate::ResolvedAction::PassThrough);
1256    }
1257
1258    #[test]
1259    fn chain_depth_cap_is_enforced() {
1260        // A -> B -> C -> ... a straight-line, non-cyclic chain longer
1261        // than MAX_REDIRECT_CHAIN_DEPTH. Must terminate rather than
1262        // following it forever.
1263        let dir = ScratchDir::new("depth_cap");
1264        let chain_len = 20;
1265        let mut rules = Vec::new();
1266        for i in 0..chain_len {
1267            let from = format!("f{i}.txt");
1268            let to_path = dir.path(&format!("game/f{}.txt", i + 1));
1269            fs::write(&to_path, format!("content {}", i + 1)).unwrap();
1270            rules.push(redirect_rule(
1271                &from,
1272                to_path,
1273                RedirectionMode::GracefulFallback,
1274                vec![],
1275            ));
1276        }
1277        fs::write(dir.path("game/f0.txt"), b"content 0").unwrap();
1278
1279        let resolver = resolver_for(&dir, rules);
1280
1281        // Should not hang; should stop well before the end of the chain.
1282        let action = resolver.resolve_path("f0.txt");
1283        let crate::ResolvedAction::Redirect { replacement_path } = action else {
1284            panic!("expected a redirect, got {action:?}");
1285        };
1286        assert_ne!(
1287            replacement_path,
1288            dir.path(&format!("game/f{chain_len}.txt")),
1289            "depth cap should have stopped the chain before reaching the end"
1290        );
1291    }
1292
1293    #[test]
1294    fn chain_does_not_special_case_mod_space_targets() {
1295        // First hop's target lives under a mod folder (outside
1296        // game_dir); a second rule matches it there anyway, once
1297        // re-expressed relative to game_dir is impossible -- so in THIS
1298        // case the chain correctly stops (nothing can match outside
1299        // game_dir), but the point is it doesn't error or behave
1300        // differently just because the target isn't a vanilla game path.
1301        let dir = ScratchDir::new("mod_space");
1302        fs::create_dir_all(dir.path("mods/some_mod")).unwrap();
1303        fs::write(dir.path("mods/some_mod/file.txt"), b"mod content").unwrap();
1304
1305        let resolver = resolver_for(
1306            &dir,
1307            vec![redirect_rule(
1308                "data/file.txt",
1309                dir.path("mods/some_mod/file.txt"),
1310                RedirectionMode::GracefulFallback,
1311                vec![],
1312            )],
1313        );
1314
1315        assert_eq!(
1316            resolver.resolve_path("data/file.txt"),
1317            crate::ResolvedAction::Redirect {
1318                replacement_path: dir.path("mods/some_mod/file.txt")
1319            }
1320        );
1321    }
1322
1323    #[test]
1324    fn chain_does_chain_when_mod_space_target_lands_back_under_game_dir() {
1325        // The positive case for the same principle: a mod's target
1326        // happens to be *inside* game_dir (unusual but not special-
1327        // cased), and a second rule matches it there -- chaining fires
1328        // exactly as it would for two purely-vanilla paths.
1329        let dir = ScratchDir::new("mod_space_chains_too");
1330        fs::create_dir_all(dir.path("game/.supreme/mods/some_mod")).unwrap();
1331        fs::write(
1332            dir.path("game/.supreme/mods/some_mod/file.txt"),
1333            b"mod content",
1334        )
1335        .unwrap();
1336        fs::write(dir.path("game/final.txt"), b"final content").unwrap();
1337
1338        let resolver = resolver_for(
1339            &dir,
1340            vec![
1341                redirect_rule(
1342                    "data/file.txt",
1343                    dir.path("game/.supreme/mods/some_mod/file.txt"),
1344                    RedirectionMode::GracefulFallback,
1345                    vec![],
1346                ),
1347                redirect_rule(
1348                    ".supreme/mods/some_mod/file.txt",
1349                    dir.path("game/final.txt"),
1350                    RedirectionMode::GracefulFallback,
1351                    vec![],
1352                ),
1353            ],
1354        );
1355
1356        assert_eq!(
1357            resolver.resolve_path("data/file.txt"),
1358            crate::ResolvedAction::Redirect {
1359                replacement_path: dir.path("game/final.txt")
1360            }
1361        );
1362    }
1363
1364    #[test]
1365    fn non_script_patches_reuse_the_cached_stage_when_base_is_unchanged() {
1366        // Baseline for the script-patch test below: a plain find/replace
1367        // patch's output only depends on the base file's bytes, so once
1368        // staged it should be reused (not rewritten) as long as the base
1369        // file's mtime hasn't moved forward.
1370        let dir = ScratchDir::new("non_script_cache_reuse");
1371        dir.write_file("game/data/levels/Object_Data.txt", b"base");
1372
1373        let resolver = resolver_for(
1374            &dir,
1375            vec![identity_rule(
1376                "**/Object_Data.txt",
1377                RedirectionMode::GracefulFallback,
1378                vec![replace_patch("base", "patched")],
1379            )],
1380        );
1381
1382        let action = resolver.resolve_path("data/levels/Object_Data.txt");
1383        let crate::ResolvedAction::Redirect { replacement_path } = action else {
1384            panic!("expected a staged patch redirect, got {action:?}");
1385        };
1386        assert_eq!(fs::read(&replacement_path).unwrap(), b"patched");
1387        let mtime_after_first_call = fs::metadata(&replacement_path).unwrap().modified().unwrap();
1388
1389        std::thread::sleep(std::time::Duration::from_millis(5));
1390        let action2 = resolver.resolve_path("data/levels/Object_Data.txt");
1391        assert_eq!(
1392            action2,
1393            crate::ResolvedAction::Redirect {
1394                replacement_path: replacement_path.clone()
1395            }
1396        );
1397        let mtime_after_second_call = fs::metadata(&replacement_path).unwrap().modified().unwrap();
1398        assert_eq!(
1399            mtime_after_first_call, mtime_after_second_call,
1400            "a non-script patch's stage should not be rewritten when the base file hasn't changed"
1401        );
1402    }
1403
1404    #[test]
1405    fn script_patches_are_rerun_on_every_read_even_when_the_base_file_is_unchanged() {
1406        // Regression guard for the actual feature: a level's Object_Data.txt
1407        // can be patched by a script that derives randomness from
1408        // `timestamp` (e.g. randomizing camera placements on every load).
1409        // If script patches reused the mtime-based cache the same way
1410        // plain find/replace patches do (see the test above), the script
1411        // would only ever run once per base-file version, and every
1412        // subsequent load of the same level would see the exact same
1413        // "random" result.
1414        let dir = ScratchDir::new("script_rerun_every_read");
1415        dir.write_file("game/data/levels/Object_Data.txt", b"base");
1416
1417        let script_patch = MemoryPatch::Script {
1418            source: crate::ScriptSource::Inline {
1419                inline: "`${input}${timestamp}`".to_string(),
1420            },
1421            parameters: vec![],
1422            configuration: std::collections::HashMap::new(),
1423        };
1424
1425        let resolver = resolver_for(
1426            &dir,
1427            vec![identity_rule(
1428                "**/Object_Data.txt",
1429                RedirectionMode::GracefulFallback,
1430                vec![script_patch],
1431            )],
1432        );
1433
1434        let action1 = resolver.resolve_path("data/levels/Object_Data.txt");
1435        let crate::ResolvedAction::Redirect {
1436            replacement_path: path1,
1437        } = action1
1438        else {
1439            panic!("expected a staged patch redirect, got {action1:?}");
1440        };
1441        let first = fs::read_to_string(&path1).unwrap();
1442        assert!(first.starts_with("base"));
1443
1444        std::thread::sleep(std::time::Duration::from_millis(5));
1445
1446        let action2 = resolver.resolve_path("data/levels/Object_Data.txt");
1447        let crate::ResolvedAction::Redirect {
1448            replacement_path: path2,
1449        } = action2
1450        else {
1451            panic!("expected a staged patch redirect, got {action2:?}");
1452        };
1453        let second = fs::read_to_string(&path2).unwrap();
1454
1455        assert_eq!(
1456            path1, path2,
1457            "the same rel_key should always stage to the same temp path"
1458        );
1459        assert_ne!(
1460            first, second,
1461            "a script patch must re-run (fresh timestamp) on every read, not reuse a cached stage"
1462        );
1463    }
1464
1465    #[test]
1466    fn script_patches_receive_the_rel_key_as_file_path() {
1467        let dir = ScratchDir::new("script_file_path_var");
1468        dir.write_file("game/data/levels/Object_Data.txt", b"ignored");
1469
1470        let script_patch = MemoryPatch::Script {
1471            source: crate::ScriptSource::Inline {
1472                inline: "file_path".to_string(),
1473            },
1474            parameters: vec![],
1475            configuration: std::collections::HashMap::new(),
1476        };
1477
1478        let resolver = resolver_for(
1479            &dir,
1480            vec![identity_rule(
1481                "**/Object_Data.txt",
1482                RedirectionMode::GracefulFallback,
1483                vec![script_patch],
1484            )],
1485        );
1486
1487        let action = resolver.resolve_path("data/levels/Object_Data.txt");
1488        let crate::ResolvedAction::Redirect { replacement_path } = action else {
1489            panic!("expected a staged patch redirect, got {action:?}");
1490        };
1491        assert_eq!(
1492            fs::read_to_string(&replacement_path).unwrap(),
1493            "data/levels/Object_Data.txt"
1494        );
1495    }
1496}
1497
1498#[cfg(test)]
1499mod is_write_operation_tests {
1500    use super::is_write_operation;
1501
1502    const GENERIC_READ: u32 = 0x8000_0000;
1503    const GENERIC_WRITE: u32 = 0x4000_0000;
1504    const FILE_WRITE_DATA: u32 = 0x0000_0002;
1505    const FILE_APPEND_DATA: u32 = 0x0000_0004;
1506    const FILE_READ_DATA: u32 = 0x0000_0001;
1507
1508    const SUPERSEDE: u32 = 0;
1509    const OPEN: u32 = 1;
1510    const CREATE: u32 = 2;
1511    const OPEN_IF: u32 = 3;
1512    const OVERWRITE: u32 = 4;
1513    const OVERWRITE_IF: u32 = 5;
1514
1515    #[test]
1516    fn destructive_dispositions_are_always_writes_even_with_read_only_access() {
1517        for disposition in [SUPERSEDE, CREATE, OVERWRITE, OVERWRITE_IF] {
1518            assert!(
1519                is_write_operation(GENERIC_READ, disposition),
1520                "disposition {disposition} should be a write regardless of access mask"
1521            );
1522        }
1523    }
1524
1525    #[test]
1526    fn open_with_read_only_access_is_not_a_write() {
1527        assert!(!is_write_operation(GENERIC_READ, OPEN));
1528        assert!(!is_write_operation(FILE_READ_DATA, OPEN));
1529    }
1530
1531    #[test]
1532    fn open_if_with_read_only_access_is_not_a_write() {
1533        assert!(!is_write_operation(GENERIC_READ, OPEN_IF));
1534    }
1535
1536    #[test]
1537    fn open_with_generic_write_access_is_a_write() {
1538        assert!(is_write_operation(GENERIC_WRITE, OPEN));
1539    }
1540
1541    #[test]
1542    fn open_with_file_write_data_access_is_a_write() {
1543        assert!(is_write_operation(FILE_WRITE_DATA, OPEN));
1544    }
1545
1546    #[test]
1547    fn open_if_with_file_append_data_access_is_a_write() {
1548        assert!(is_write_operation(FILE_APPEND_DATA, OPEN_IF));
1549    }
1550
1551    #[test]
1552    fn open_with_combined_read_and_write_access_is_a_write() {
1553        assert!(is_write_operation(GENERIC_READ | GENERIC_WRITE, OPEN));
1554    }
1555
1556    #[test]
1557    fn no_access_bits_and_non_destructive_disposition_is_not_a_write() {
1558        assert!(!is_write_operation(0, OPEN));
1559    }
1560}
1561
1562#[cfg(test)]
1563mod seed_target_file_tests {
1564    use super::seed_target_file;
1565    use std::fs;
1566
1567    struct ScratchDir(std::path::PathBuf);
1568
1569    impl ScratchDir {
1570        fn new(name: &str) -> Self {
1571            let dir = std::env::temp_dir()
1572                .join(format!("hook_resolver_test_{name}_{}", std::process::id()));
1573            let _ = fs::remove_dir_all(&dir);
1574            fs::create_dir_all(&dir).unwrap();
1575            Self(dir)
1576        }
1577
1578        fn path(&self, name: &str) -> std::path::PathBuf {
1579            self.0.join(name)
1580        }
1581    }
1582
1583    impl Drop for ScratchDir {
1584        fn drop(&mut self) {
1585            let _ = fs::remove_dir_all(&self.0);
1586        }
1587    }
1588
1589    #[test]
1590    fn seeds_target_with_original_contents_when_target_is_absent() {
1591        let dir = ScratchDir::new("seeds_when_absent");
1592        let original = dir.path("original.dat");
1593        let target = dir.path("target.dat");
1594        fs::write(&original, b"original bytes").unwrap();
1595
1596        let seeded = seed_target_file(&original, &target).unwrap();
1597
1598        assert!(seeded, "first caller should perform the seed");
1599        assert_eq!(fs::read(&target).unwrap(), b"original bytes");
1600    }
1601
1602    #[test]
1603    fn does_not_touch_target_when_it_already_exists() {
1604        let dir = ScratchDir::new("skips_when_present");
1605        let original = dir.path("original.dat");
1606        let target = dir.path("target.dat");
1607        fs::write(&original, b"original bytes").unwrap();
1608        fs::write(&target, b"already-written save data").unwrap();
1609
1610        let seeded = seed_target_file(&original, &target).unwrap();
1611
1612        assert!(!seeded);
1613        assert_eq!(fs::read(&target).unwrap(), b"already-written save data");
1614    }
1615
1616    #[test]
1617    fn second_call_after_a_successful_seed_is_a_no_op() {
1618        let dir = ScratchDir::new("second_call_is_noop");
1619        let original = dir.path("original.dat");
1620        let target = dir.path("target.dat");
1621        fs::write(&original, b"original bytes").unwrap();
1622
1623        assert!(seed_target_file(&original, &target).unwrap());
1624        fs::write(&target, b"game already wrote new save data here").unwrap();
1625
1626        let seeded_again = seed_target_file(&original, &target).unwrap();
1627
1628        assert!(!seeded_again);
1629        assert_eq!(
1630            fs::read(&target).unwrap(),
1631            b"game already wrote new save data here"
1632        );
1633    }
1634
1635    #[test]
1636    fn propagates_an_error_when_the_original_cannot_be_read() {
1637        let dir = ScratchDir::new("missing_original_errors");
1638        let original = dir.path("does_not_exist.dat");
1639        let target = dir.path("target.dat");
1640
1641        let result = seed_target_file(&original, &target);
1642
1643        assert!(result.is_err());
1644        assert!(!target.exists());
1645    }
1646
1647    #[test]
1648    fn concurrent_seeds_publish_only_complete_original_bytes() {
1649        let dir = ScratchDir::new("concurrent_complete_seeds");
1650        let original = dir.path("original.dat");
1651        let target = dir.path("target.dat");
1652        let expected = vec![0x5au8; 2 * 1024 * 1024];
1653        fs::write(&original, &expected).unwrap();
1654        let barrier = std::sync::Arc::new(std::sync::Barrier::new(5));
1655        let results = std::thread::scope(|scope| {
1656            let threads: Vec<_> = (0..4)
1657                .map(|_| {
1658                    let barrier = barrier.clone();
1659                    let original = original.clone();
1660                    let target = target.clone();
1661                    scope.spawn(move || {
1662                        barrier.wait();
1663                        let seeded = seed_target_file(&original, &target).unwrap();
1664                        assert_eq!(fs::metadata(&target).unwrap().len(), 2 * 1024 * 1024);
1665                        seeded
1666                    })
1667                })
1668                .collect();
1669            barrier.wait();
1670            threads
1671                .into_iter()
1672                .map(|thread| thread.join().unwrap())
1673                .collect::<Vec<_>>()
1674        });
1675        assert_eq!(results.into_iter().filter(|seeded| *seeded).count(), 1);
1676        assert_eq!(fs::read(target).unwrap(), expected);
1677    }
1678}
1679
1680#[cfg(test)]
1681mod write_seed_failure_tests {
1682    use super::*;
1683    use crate::{HookConfig, RedirectionMode, RedirectionRule, ResolvedAction};
1684
1685    #[test]
1686    fn partial_write_starts_with_original_and_leaves_original_untouched() {
1687        let dir = tempfile::tempdir().unwrap();
1688        let game = dir.path().join("game");
1689        std::fs::create_dir_all(game.join("data")).unwrap();
1690        std::fs::write(game.join("data/save.dat"), b"original bytes").unwrap();
1691        let target = game.join("mod/save.dat");
1692        let resolver = HookResolver::new(
1693            HookConfig {
1694                rules: vec![RedirectionRule {
1695                    glob_pattern: "data/save.dat".into(),
1696                    target: Some(RuleTarget::Path(target.clone())),
1697                    mode: RedirectionMode::GracefulFallback,
1698                    patches: vec![],
1699                    enable_if: None,
1700                }],
1701            },
1702            game.clone(),
1703            dir.path().join("temp"),
1704        );
1705
1706        assert_eq!(
1707            resolver.resolve_path_for_write("data/save.dat"),
1708            ResolvedAction::Redirect {
1709                replacement_path: target.clone()
1710            }
1711        );
1712        let mut bytes = std::fs::read(&target).unwrap();
1713        bytes[0..7].copy_from_slice(b"updated");
1714        std::fs::write(&target, bytes).unwrap();
1715        assert_eq!(std::fs::read(&target).unwrap(), b"updatedl bytes");
1716        assert_eq!(
1717            std::fs::read(game.join("data/save.dat")).unwrap(),
1718            b"original bytes"
1719        );
1720    }
1721
1722    #[test]
1723    fn target_preparation_failure_blocks_write_even_in_graceful_mode() {
1724        let dir = tempfile::tempdir().unwrap();
1725        let game = dir.path().join("game");
1726        std::fs::create_dir_all(game.join("data")).unwrap();
1727        std::fs::write(game.join("data/save.dat"), b"original bytes").unwrap();
1728        let blocker = game.join("blocker");
1729        std::fs::write(&blocker, b"a file, not a directory").unwrap();
1730        let resolver = HookResolver::new(
1731            HookConfig {
1732                rules: vec![RedirectionRule {
1733                    glob_pattern: "data/save.dat".into(),
1734                    target: Some(RuleTarget::Path(blocker.join("save.dat"))),
1735                    mode: RedirectionMode::GracefulFallback,
1736                    patches: vec![],
1737                    enable_if: None,
1738                }],
1739            },
1740            game.clone(),
1741            dir.path().join("temp"),
1742        );
1743        assert_eq!(
1744            resolver.resolve_path_for_write("data/save.dat"),
1745            ResolvedAction::HardErrorMissingFile
1746        );
1747        assert!(resolver.has_write_redirect("data/save.dat"));
1748        assert!(resolver.resolve_write_target("data/save.dat").is_none());
1749        assert_eq!(
1750            std::fs::read(game.join("data/save.dat")).unwrap(),
1751            b"original bytes"
1752        );
1753    }
1754
1755    #[test]
1756    fn unreadable_original_blocks_redirect_instead_of_publishing_partial_target() {
1757        let dir = tempfile::tempdir().unwrap();
1758        let game = dir.path().join("game");
1759        std::fs::create_dir_all(game.join("data/save.dat")).unwrap();
1760        let target = game.join("mod/save.dat");
1761        let resolver = HookResolver::new(
1762            HookConfig {
1763                rules: vec![RedirectionRule {
1764                    glob_pattern: "data/save.dat".into(),
1765                    target: Some(RuleTarget::Path(target.clone())),
1766                    mode: RedirectionMode::GracefulFallback,
1767                    patches: vec![],
1768                    enable_if: None,
1769                }],
1770            },
1771            game,
1772            dir.path().join("temp"),
1773        );
1774        assert_eq!(
1775            resolver.resolve_path_for_write("data/save.dat"),
1776            ResolvedAction::HardErrorMissingFile
1777        );
1778        assert!(!target.exists());
1779    }
1780
1781    #[test]
1782    fn self_redirect_cannot_write_into_original_file() {
1783        let dir = tempfile::tempdir().unwrap();
1784        let game = dir.path().join("game");
1785        std::fs::create_dir_all(game.join("data")).unwrap();
1786        let original = game.join("data/save.dat");
1787        std::fs::write(&original, b"original bytes").unwrap();
1788        let resolver = HookResolver::new(
1789            HookConfig {
1790                rules: vec![RedirectionRule {
1791                    glob_pattern: "data/save.dat".into(),
1792                    target: Some(RuleTarget::Path(original.clone())),
1793                    mode: RedirectionMode::GracefulFallback,
1794                    patches: vec![],
1795                    enable_if: None,
1796                }],
1797            },
1798            game,
1799            dir.path().join("temp"),
1800        );
1801        assert_eq!(
1802            resolver.resolve_path_for_write("data/save.dat"),
1803            ResolvedAction::HardErrorMissingFile
1804        );
1805        assert!(resolver.has_write_redirect("data/save.dat"));
1806        assert!(resolver.resolve_write_target("data/save.dat").is_none());
1807        assert_eq!(std::fs::read(original).unwrap(), b"original bytes");
1808    }
1809}
1810
1811#[cfg(test)]
1812mod compute_target_path_tests {
1813    use super::compute_target_path;
1814    use crate::{RedirectionMode, RedirectionRule, RuleTarget};
1815    use std::path::PathBuf;
1816
1817    fn rule(glob_pattern: &str, target: &str) -> RedirectionRule {
1818        RedirectionRule {
1819            glob_pattern: glob_pattern.into(),
1820            target: Some(RuleTarget::Path(PathBuf::from(target))),
1821            mode: RedirectionMode::GracefulFallback,
1822            patches: vec![],
1823            enable_if: None,
1824        }
1825    }
1826
1827    #[test]
1828    fn double_star_preserves_subdirectory_structure() {
1829        let rule = rule("data/levels/**", "mods/levels");
1830        assert_eq!(
1831            compute_target_path(&rule, "data/levels/alpine/tracks/easy/replays/r2.dat"),
1832            Some(PathBuf::from(
1833                "mods/levels/alpine/tracks/easy/replays/r2.dat"
1834            ))
1835        );
1836    }
1837
1838    /// Regression test: a wildcard segment that isn't the literal `**`
1839    /// substring (a single `*` matching a whole directory component, here
1840    /// the per-level name) must preserve the sub-directory structure, the
1841    /// same answer `HookConfig::resolve`'s `matched_suffix` gives;
1842    /// `compute_target_path` delegates to it directly, so there is
1843    /// exactly one answer to "where does this rel_key's file live".
1844    #[test]
1845    fn single_star_mid_path_preserves_subdirectory_structure() {
1846        let rule = rule("data/levels/*/ghost/*.dat", "mods/ghosts");
1847        assert_eq!(
1848            compute_target_path(&rule, "data/levels/alpine/ghost/run_42.dat"),
1849            Some(PathBuf::from("mods/ghosts/alpine/ghost/run_42.dat"))
1850        );
1851    }
1852
1853    #[test]
1854    fn single_star_last_segment_reduces_to_basename() {
1855        // No subdirectory below the wildcard in this pattern shape, so
1856        // there's nothing to preserve, this should still just be the
1857        // basename, same as before.
1858        let rule = rule("data/levels/*.dat", "mods/levels");
1859        assert_eq!(
1860            compute_target_path(&rule, "data/levels/level1.dat"),
1861            Some(PathBuf::from("mods/levels/level1.dat"))
1862        );
1863    }
1864
1865    #[test]
1866    fn literal_glob_uses_target_directly() {
1867        let rule = rule("Saved_Data/high_times.txt", "mods/high_times_custom.txt");
1868        assert_eq!(
1869            compute_target_path(&rule, "saved_data/high_times.txt"),
1870            Some(PathBuf::from("mods/high_times_custom.txt"))
1871        );
1872    }
1873}
1874
1875#[cfg(test)]
1876mod write_then_delete_tests {
1877    //! End-to-end regression coverage for the exact bug reported against
1878    //! this resolver: a file created via `resolve_path_for_write` under a
1879    //! wildcard rule must be found (and only that file) by
1880    //! `resolve_path_for_delete` and `resolve_path` for the same rel_key,
1881    //! otherwise a "write a ghost recording, then delete it" cycle can
1882    //! delete the wrong file (or nothing at all), leaving the real one
1883    //! stranded. See `compute_target_path_tests` for the narrower unit-level
1884    //! version of the same regression.
1885
1886    use super::HookResolver;
1887    use crate::{HookConfig, RedirectionMode, RedirectionRule, RuleTarget};
1888    use std::fs;
1889    use std::path::PathBuf;
1890
1891    struct ScratchDir(PathBuf);
1892
1893    impl ScratchDir {
1894        fn new(name: &str) -> Self {
1895            let dir = std::env::temp_dir().join(format!(
1896                "hook_resolver_write_delete_test_{name}_{}",
1897                std::process::id()
1898            ));
1899            let _ = fs::remove_dir_all(&dir);
1900            fs::create_dir_all(&dir).unwrap();
1901            Self(dir)
1902        }
1903
1904        fn path(&self, name: &str) -> PathBuf {
1905            self.0.join(name)
1906        }
1907    }
1908
1909    impl Drop for ScratchDir {
1910        fn drop(&mut self) {
1911            let _ = fs::remove_dir_all(&self.0);
1912        }
1913    }
1914
1915    fn resolver_for(dir: &ScratchDir, glob_pattern: &str, mode: RedirectionMode) -> HookResolver {
1916        let config = HookConfig {
1917            rules: vec![RedirectionRule {
1918                glob_pattern: glob_pattern.into(),
1919                target: Some(RuleTarget::Path(dir.path("mods_ghosts"))),
1920                mode,
1921                patches: vec![],
1922                enable_if: None,
1923            }],
1924        };
1925        HookResolver::new(config, dir.path("game"), dir.path("temp"))
1926    }
1927
1928    #[test]
1929    fn delete_finds_the_same_file_a_wildcard_write_created() {
1930        let dir = ScratchDir::new("delete_finds_write");
1931        let resolver = resolver_for(
1932            &dir,
1933            "data/levels/*/ghost/*.dat",
1934            RedirectionMode::GracefulFallback,
1935        );
1936        let rel_key = "data/levels/alpine/ghost/run_42.dat";
1937
1938        let write_action = resolver.resolve_path_for_write(rel_key);
1939        let crate::ResolvedAction::Redirect {
1940            replacement_path: written_path,
1941        } = write_action
1942        else {
1943            panic!("expected a write redirect, got {write_action:?}");
1944        };
1945        fs::write(&written_path, b"ghost data").unwrap();
1946
1947        let delete_action = resolver.resolve_path_for_delete(rel_key);
1948        assert_eq!(
1949            delete_action,
1950            crate::ResolvedAction::Redirect {
1951                replacement_path: written_path.clone()
1952            },
1953            "delete must resolve to the exact same physical file the write created"
1954        );
1955
1956        // And nothing was ever written anywhere else under the target root.
1957        let mut all_files = Vec::new();
1958        for entry in walk(&dir.path("mods_ghosts")) {
1959            all_files.push(entry);
1960        }
1961        assert_eq!(all_files, vec![written_path]);
1962    }
1963
1964    #[test]
1965    fn read_also_finds_the_same_file_a_wildcard_write_created() {
1966        // Covers the case where the game reopens the file with read-only /
1967        // delete-only access (no write bits) after closing the handle it
1968        // wrote with, `is_write_operation` routes that through
1969        // `resolve_path`, not `resolve_path_for_write`, so this must agree
1970        // too.
1971        let dir = ScratchDir::new("read_finds_write");
1972        let resolver = resolver_for(
1973            &dir,
1974            "data/levels/*/ghost/*.dat",
1975            RedirectionMode::GracefulFallback,
1976        );
1977        let rel_key = "data/levels/alpine/ghost/run_42.dat";
1978
1979        let crate::ResolvedAction::Redirect {
1980            replacement_path: written_path,
1981        } = resolver.resolve_path_for_write(rel_key)
1982        else {
1983            panic!("expected a write redirect");
1984        };
1985        fs::write(&written_path, b"ghost data").unwrap();
1986
1987        assert_eq!(
1988            resolver.resolve_path(rel_key),
1989            crate::ResolvedAction::Redirect {
1990                replacement_path: written_path
1991            }
1992        );
1993    }
1994
1995    #[test]
1996    fn delete_hard_errors_when_nothing_was_ever_written() {
1997        let dir = ScratchDir::new("delete_hard_errors");
1998        let resolver = resolver_for(
1999            &dir,
2000            "data/levels/*/ghost/*.dat",
2001            RedirectionMode::HardError,
2002        );
2003
2004        assert_eq!(
2005            resolver.resolve_path_for_delete("data/levels/practice/ghost/never_written.dat"),
2006            crate::ResolvedAction::HardErrorMissingFile
2007        );
2008    }
2009
2010    #[test]
2011    fn delete_passes_through_when_nothing_was_ever_written_in_fallback_mode() {
2012        let dir = ScratchDir::new("delete_passes_through");
2013        let resolver = resolver_for(
2014            &dir,
2015            "data/levels/*/ghost/*.dat",
2016            RedirectionMode::GracefulFallback,
2017        );
2018
2019        assert_eq!(
2020            resolver.resolve_path_for_delete("data/levels/practice/ghost/never_written.dat"),
2021            crate::ResolvedAction::PassThrough
2022        );
2023    }
2024
2025    #[test]
2026    fn write_target_skips_targetless_rule_to_reach_a_real_one() {
2027        // A target: None rule matches first in the list; a later rule
2028        // for the same glob has a real target. The write must not stop
2029        // at the first (targetless) match.
2030        let dir = ScratchDir::new("write_skips_targetless");
2031        let config = HookConfig {
2032            rules: vec![
2033                RedirectionRule {
2034                    glob_pattern: "saves/*.sav".into(),
2035                    target: None,
2036                    mode: RedirectionMode::GracefulFallback,
2037                    patches: vec![],
2038                    enable_if: None,
2039                },
2040                RedirectionRule {
2041                    glob_pattern: "saves/*.sav".into(),
2042                    target: Some(RuleTarget::Path(dir.path("mod_saves"))),
2043                    mode: RedirectionMode::GracefulFallback,
2044                    patches: vec![],
2045                    enable_if: None,
2046                },
2047            ],
2048        };
2049        let resolver = HookResolver::new(config, dir.path("game"), dir.path("temp"));
2050
2051        let action = resolver.resolve_path_for_write("saves/slot1.sav");
2052        assert_eq!(
2053            action,
2054            crate::ResolvedAction::Redirect {
2055                replacement_path: dir.path("mod_saves/slot1.sav")
2056            }
2057        );
2058    }
2059
2060    #[test]
2061    fn write_target_follows_redirect_to_its_end() {
2062        // rule 1 redirects data/x.txt -> mid/x.txt (still under
2063        // game_dir); rule 2 redirects mid/x.txt -> mods/final. Writing
2064        // to data/x.txt should land at the FINAL stage's target, not
2065        // the intermediate one.
2066        let dir = ScratchDir::new("write_follows_chain");
2067        fs::create_dir_all(dir.path("game")).unwrap();
2068        let config = HookConfig {
2069            rules: vec![
2070                RedirectionRule {
2071                    glob_pattern: "data/x.txt".into(),
2072                    target: Some(RuleTarget::Path(dir.path("game/mid/x.txt"))),
2073                    mode: RedirectionMode::GracefulFallback,
2074                    patches: vec![],
2075                    enable_if: None,
2076                },
2077                RedirectionRule {
2078                    glob_pattern: "mid/x.txt".into(),
2079                    target: Some(RuleTarget::Path(dir.path("mods/final_x.txt"))),
2080                    mode: RedirectionMode::GracefulFallback,
2081                    patches: vec![],
2082                    enable_if: None,
2083                },
2084            ],
2085        };
2086        let resolver = HookResolver::new(config, dir.path("game"), dir.path("temp"));
2087
2088        let action = resolver.resolve_path_for_write("data/x.txt");
2089        assert_eq!(
2090            action,
2091            crate::ResolvedAction::Redirect {
2092                replacement_path: dir.path("mods/final_x.txt")
2093            }
2094        );
2095    }
2096
2097    #[test]
2098    fn delete_uses_last_reached_rule_mode_for_missing_file_fallback() {
2099        // rule 1 (graceful_fallback) redirects to a stage that rule 2
2100        // (hard_error) also matches, but rule 2 has no target of its
2101        // own -- with nothing ever written, the LAST rule reached
2102        // (rule 2, hard_error) should govern the fallback, not rule 1.
2103        let dir = ScratchDir::new("delete_last_rule_mode");
2104        fs::create_dir_all(dir.path("game")).unwrap();
2105        let config = HookConfig {
2106            rules: vec![
2107                RedirectionRule {
2108                    glob_pattern: "data/y.txt".into(),
2109                    target: Some(RuleTarget::Path(dir.path("game/mid/y.txt"))),
2110                    mode: RedirectionMode::GracefulFallback,
2111                    patches: vec![],
2112                    enable_if: None,
2113                },
2114                RedirectionRule {
2115                    glob_pattern: "mid/y.txt".into(),
2116                    target: None,
2117                    mode: RedirectionMode::HardError,
2118                    patches: vec![],
2119                    enable_if: None,
2120                },
2121            ],
2122        };
2123        let resolver = HookResolver::new(config, dir.path("game"), dir.path("temp"));
2124
2125        assert_eq!(
2126            resolver.resolve_path_for_delete("data/y.txt"),
2127            crate::ResolvedAction::HardErrorMissingFile,
2128            "the last rule reached (hard_error) should govern, not the first (graceful_fallback)"
2129        );
2130    }
2131
2132    #[test]
2133    fn write_redirects_through_a_wildcard_rule_even_when_the_target_matches_nothing_further() {
2134        // Regression guard for the exact bug reported: a single
2135        // catch-all wildcard rule (`Data/.../Easy/**`) redirects into a
2136        // directory under game_dir, and *nothing* else matches that
2137        // redirected path -- the chain has exactly one hop, then ends.
2138        // A write (and, by the same code path, a rename) must still
2139        // land in the rule's target directory, not silently fall
2140        // through to writing/renaming over the original game file.
2141        let dir = ScratchDir::new("write_wildcard_terminal_redirect");
2142        fs::create_dir_all(dir.path("game/data/levels/easy")).unwrap();
2143        fs::create_dir_all(dir.path("game/data/levels/practice_easy")).unwrap();
2144
2145        let config = HookConfig {
2146            rules: vec![RedirectionRule {
2147                glob_pattern: "data/levels/easy/**".into(),
2148                target: Some(RuleTarget::Path(
2149                    dir.path("game/data/levels/practice_easy/"),
2150                )),
2151                mode: RedirectionMode::HardError,
2152                patches: vec![],
2153                enable_if: None,
2154            }],
2155        };
2156        let resolver = HookResolver::new(config, dir.path("game"), dir.path("temp"));
2157
2158        let action = resolver.resolve_path_for_write("data/levels/easy/replays/replay_0.dat");
2159        assert_eq!(
2160            action,
2161            crate::ResolvedAction::Redirect {
2162                replacement_path: dir.path("game/data/levels/practice_easy/replays/replay_0.dat")
2163            },
2164            "a write through a single-hop wildcard redirect must land in the \
2165             rule's target directory, not fall through to the original file"
2166        );
2167    }
2168
2169    #[test]
2170    fn delete_redirects_through_a_wildcard_rule_even_when_the_target_matches_nothing_further() {
2171        // Same regression, delete side: once a replay has actually been
2172        // written into the target directory, deleting it (as part of
2173        // the game's shift-replays-down bookkeeping) must find it there
2174        // too, not report PassThrough/HardError against a file that was
2175        // never touched.
2176        let dir = ScratchDir::new("delete_wildcard_terminal_redirect");
2177        fs::create_dir_all(dir.path("game/data/levels/easy")).unwrap();
2178        fs::create_dir_all(dir.path("game/data/levels/practice_easy/replays")).unwrap();
2179        fs::write(
2180            dir.path("game/data/levels/practice_easy/replays/replay_4.dat"),
2181            b"a replay",
2182        )
2183        .unwrap();
2184
2185        let config = HookConfig {
2186            rules: vec![RedirectionRule {
2187                glob_pattern: "data/levels/easy/**".into(),
2188                target: Some(RuleTarget::Path(
2189                    dir.path("game/data/levels/practice_easy/"),
2190                )),
2191                mode: RedirectionMode::HardError,
2192                patches: vec![],
2193                enable_if: None,
2194            }],
2195        };
2196        let resolver = HookResolver::new(config, dir.path("game"), dir.path("temp"));
2197
2198        assert_eq!(
2199            resolver.resolve_path_for_delete("data/levels/easy/replays/replay_4.dat"),
2200            crate::ResolvedAction::Redirect {
2201                replacement_path: dir.path("game/data/levels/practice_easy/replays/replay_4.dat")
2202            }
2203        );
2204
2205        // And for a replay slot that was never written, hard_error still
2206        // blocks the delete, exactly as it blocks the read -- this part
2207        // of the original bug report was already correct and must stay
2208        // that way.
2209        assert_eq!(
2210            resolver.resolve_path_for_delete("data/levels/easy/replays/replay_0.dat"),
2211            crate::ResolvedAction::HardErrorMissingFile
2212        );
2213    }
2214
2215    #[test]
2216    fn literal_redirect_under_game_dir_keeps_the_declared_target_casing() {
2217        // field regression: a managed target's `local` under
2218        // game_dir (`.supreme/mods/...`, every mod target) used to be
2219        // resolved through `original_path(rel_key_of(target))`, whose
2220        // lowercase fold silently rewrote a MixedCase declared file name.
2221        // The write then landed on a differently-cased twin of the
2222        // registered local path, and the engine's byte-exact close-hook
2223        // comparison never captured the game's writes (the field 404
2224        // cascade). The terminal target must be the DECLARED path,
2225        // byte-exact.
2226        let dir = ScratchDir::new("literal_target_case_preserved");
2227        fs::create_dir_all(dir.path("game/saved_data")).unwrap();
2228
2229        let config = HookConfig {
2230            rules: vec![RedirectionRule {
2231                glob_pattern: "Saved_Data/village_Tracks_medium_high_times.txt".into(),
2232                target: Some(RuleTarget::Path(dir.path(
2233                    "game/.supreme/mods/m/High_Times/Village_Tracks_Medium_high_times.txt",
2234                ))),
2235                mode: RedirectionMode::GracefulFallback,
2236                patches: vec![],
2237                enable_if: None,
2238            }],
2239        };
2240        let resolver = HookResolver::new(config, dir.path("game"), dir.path("temp"));
2241
2242        let declared =
2243            dir.path("game/.supreme/mods/m/High_Times/Village_Tracks_Medium_high_times.txt");
2244        assert_eq!(
2245            resolver.resolve_path_for_write("saved_data/village_tracks_medium_high_times.txt"),
2246            crate::ResolvedAction::Redirect {
2247                replacement_path: declared.clone()
2248            },
2249            "the write target must carry the declared path byte-exactly, \
2250             not a lowercased re-expression of it"
2251        );
2252        // The delete side resolves through the same chain, same exactness.
2253        // (A delete only redirects to a target that exists on disk, in the
2254        // field the engine has materialized the managed local long before;
2255        // graceful_fallback would otherwise fall through by design.)
2256        std::fs::write(&declared, b"managed").unwrap();
2257        assert_eq!(
2258            resolver.resolve_path_for_delete("saved_data/village_tracks_medium_high_times.txt"),
2259            crate::ResolvedAction::Redirect {
2260                replacement_path: declared
2261            }
2262        );
2263    }
2264
2265    fn walk(dir: &std::path::Path) -> Vec<PathBuf> {
2266        let mut out = Vec::new();
2267        let Ok(entries) = fs::read_dir(dir) else {
2268            return out;
2269        };
2270        for entry in entries.flatten() {
2271            let path = entry.path();
2272            if path.is_dir() {
2273                out.extend(walk(&path));
2274            } else {
2275                out.push(path);
2276            }
2277        }
2278        out
2279    }
2280}
2281
2282#[cfg(test)]
2283mod listing_visibility_tests {
2284    //! `should_hide_original_entry` must agree with `resolve_path` about
2285    //! which files a rule actually governs, not just which ones currently
2286    //! have a replacement staged. Scenario used throughout: a directory
2287    //! `a/` redirected to `b/`, where `a/` has real files `2.txt` and
2288    //! `3.txt`, and only `b/1.txt` has been shipped so far (`1.txt` has no
2289    //! original counterpart in `a/` at all, it's a pure mod addition).
2290    //!
2291    //! `graceful_fallback`: only `1.txt` is hidden from `a/`'s listing (it
2292    //! has a real replacement); `2.txt` and `3.txt` stay visible, same as
2293    //! reading them directly would fall back to the originals.
2294    //!
2295    //! `hard_error`: every rel_key the rule matches is hidden the moment it
2296    //! matches, whether or not a replacement exists yet for it, same as
2297    //! reading `a/2.txt` directly reports "does not exist" under
2298    //! `hard_error`, even though the real file is still on disk.
2299
2300    use super::HookResolver;
2301    use crate::{HookConfig, RedirectionMode, RedirectionRule, RuleTarget};
2302    use std::fs;
2303    use std::path::PathBuf;
2304
2305    struct ScratchDir(PathBuf);
2306
2307    impl ScratchDir {
2308        fn new(name: &str) -> Self {
2309            let dir = std::env::temp_dir().join(format!(
2310                "hook_resolver_listing_visibility_test_{name}_{}",
2311                std::process::id()
2312            ));
2313            let _ = fs::remove_dir_all(&dir);
2314            fs::create_dir_all(dir.join("a")).unwrap();
2315            fs::create_dir_all(dir.join("b")).unwrap();
2316            Self(dir)
2317        }
2318
2319        fn path(&self, name: &str) -> PathBuf {
2320            self.0.join(name)
2321        }
2322    }
2323
2324    impl Drop for ScratchDir {
2325        fn drop(&mut self) {
2326            let _ = fs::remove_dir_all(&self.0);
2327        }
2328    }
2329
2330    fn resolver_for(dir: &ScratchDir, mode: RedirectionMode) -> HookResolver {
2331        let config = HookConfig {
2332            rules: vec![RedirectionRule {
2333                glob_pattern: "a/**".into(),
2334                target: Some(RuleTarget::Path(dir.path("b"))),
2335                mode,
2336                patches: vec![],
2337                enable_if: None,
2338            }],
2339        };
2340        HookResolver::new(config, dir.path("game"), dir.path("temp"))
2341    }
2342
2343    #[test]
2344    fn graceful_fallback_hides_only_the_entry_with_a_real_replacement() {
2345        let dir = ScratchDir::new("graceful");
2346        fs::write(dir.path("a/2.txt"), b"original 2").unwrap();
2347        fs::write(dir.path("a/3.txt"), b"original 3").unwrap();
2348        fs::write(dir.path("b/1.txt"), b"replacement 1").unwrap();
2349        let resolver = resolver_for(&dir, RedirectionMode::GracefulFallback);
2350
2351        assert!(
2352            resolver.should_hide_original_entry("a/1.txt"),
2353            "1.txt has a real replacement in b/, so it should be hidden \
2354             (the injected mod entry takes its place)"
2355        );
2356        assert!(
2357            !resolver.should_hide_original_entry("a/2.txt"),
2358            "2.txt has no replacement in b/ yet -- graceful_fallback must \
2359             keep showing the original"
2360        );
2361        assert!(
2362            !resolver.should_hide_original_entry("a/3.txt"),
2363            "same as 2.txt: no replacement yet, must stay visible"
2364        );
2365    }
2366
2367    /// The regression this module exists for: under `hard_error`, a
2368    /// redirected directory's own leftover files must never leak into a
2369    /// listing just because nothing has replaced them yet, `2.txt` and
2370    /// `3.txt` are still physically present in `a/`, but the rule matches
2371    /// them, so they must be hidden exactly as a direct read of either would
2372    /// report `HardErrorMissingFile` instead of falling back to serving them.
2373    #[test]
2374    fn hard_error_hides_every_matched_entry_even_without_a_replacement() {
2375        let dir = ScratchDir::new("hard_error");
2376        fs::write(dir.path("a/2.txt"), b"original 2").unwrap();
2377        fs::write(dir.path("a/3.txt"), b"original 3").unwrap();
2378        fs::write(dir.path("b/1.txt"), b"replacement 1").unwrap();
2379        let resolver = resolver_for(&dir, RedirectionMode::HardError);
2380
2381        assert!(
2382            resolver.should_hide_original_entry("a/1.txt"),
2383            "1.txt has a real replacement -- hidden either way"
2384        );
2385        assert!(
2386            resolver.should_hide_original_entry("a/2.txt"),
2387            "hard_error must hide 2.txt even though b/2.txt doesn't exist \
2388             yet -- the rule already claimed this rel_key"
2389        );
2390        assert!(
2391            resolver.should_hide_original_entry("a/3.txt"),
2392            "same as 2.txt"
2393        );
2394    }
2395
2396    #[test]
2397    fn unmatched_path_is_never_hidden() {
2398        let dir = ScratchDir::new("unmatched");
2399        let resolver = resolver_for(&dir, RedirectionMode::HardError);
2400
2401        assert!(
2402            !resolver.should_hide_original_entry("completely/unrelated/file.txt"),
2403            "no rule matches this rel_key at all, so there's nothing to hide"
2404        );
2405    }
2406
2407    #[test]
2408    fn agrees_with_resolve_path_across_every_case_in_this_scenario() {
2409        // Cross-check: for every rel_key/mode pair above, hiding must be
2410        // exactly the cases where resolve_path would NOT serve the
2411        // original -- i.e. exactly Redirect and HardErrorMissingFile, never
2412        // FallbackToOriginal or PassThrough. This pins the two decisions
2413        // together so they can't drift apart again in the future.
2414        let dir = ScratchDir::new("agrees_with_resolve_path");
2415        fs::write(dir.path("a/2.txt"), b"original 2").unwrap();
2416        fs::write(dir.path("a/3.txt"), b"original 3").unwrap();
2417        fs::write(dir.path("b/1.txt"), b"replacement 1").unwrap();
2418
2419        for mode in [
2420            RedirectionMode::GracefulFallback,
2421            RedirectionMode::HardError,
2422        ] {
2423            let resolver = resolver_for(&dir, mode);
2424            for rel_key in ["a/1.txt", "a/2.txt", "a/3.txt", "unrelated.txt"] {
2425                let hidden = resolver.should_hide_original_entry(rel_key);
2426                let would_serve_original = matches!(
2427                    resolver.resolve_path(rel_key),
2428                    crate::ResolvedAction::FallbackToOriginal | crate::ResolvedAction::PassThrough
2429                );
2430                assert_eq!(
2431                    hidden, !would_serve_original,
2432                    "mode={mode:?} rel_key={rel_key}: listing visibility must be the \
2433                     exact inverse of \"resolve_path would serve the original\""
2434                );
2435            }
2436        }
2437    }
2438
2439    #[test]
2440    fn agrees_with_resolve_path_for_a_chained_and_patched_scenario() {
2441        // Same invariant as above, but exercising the actual chain
2442        // (redirect hop into game_dir, followed by an identity/patch
2443        // hop) rather than the single-rule scenario every other test in
2444        // this module uses.
2445        use crate::{MemoryPatch, StringReplacement};
2446
2447        let dir = ScratchDir::new("agrees_with_resolve_path_chained");
2448        fs::write(dir.path("a/redirected.txt"), b"unused").unwrap();
2449        fs::write(dir.path("b/redirected.txt"), b"base content").unwrap();
2450
2451        let config = HookConfig {
2452            rules: vec![
2453                RedirectionRule {
2454                    glob_pattern: "a/redirected.txt".into(),
2455                    target: Some(RuleTarget::Path(dir.path("b/redirected.txt"))),
2456                    mode: RedirectionMode::GracefulFallback,
2457                    patches: vec![],
2458                    enable_if: None,
2459                },
2460                RedirectionRule {
2461                    glob_pattern: "b/redirected.txt".into(),
2462                    target: None,
2463                    mode: RedirectionMode::GracefulFallback,
2464                    patches: vec![MemoryPatch::ReplaceString {
2465                        replacements: vec![StringReplacement {
2466                            find: "base".into(),
2467                            replace: "patched".into(),
2468                            enable_if: None,
2469                        }],
2470                    }],
2471                    enable_if: None,
2472                },
2473            ],
2474        };
2475        let resolver = HookResolver::new(config, dir.path("game_root_unused"), dir.path("temp"));
2476        // Note: this scenario's rel_keys are matched directly against
2477        // "a/..."/"b/..." glob patterns above rather than routed through
2478        // game_dir -- game_dir only matters for re-expressing a target
2479        // as a rel_key mid-chain (see chain_resolution_tests for that),
2480        // which this scenario's second hop doesn't need since it's an
2481        // identity hop, not a further redirect.
2482
2483        let hidden = resolver.should_hide_original_entry("a/redirected.txt");
2484        let would_serve_original = matches!(
2485            resolver.resolve_path("a/redirected.txt"),
2486            crate::ResolvedAction::FallbackToOriginal | crate::ResolvedAction::PassThrough
2487        );
2488        assert_eq!(
2489            hidden, !would_serve_original,
2490            "a chained, patched rel_key must still agree between listing and read"
2491        );
2492        assert!(
2493            hidden,
2494            "this rel_key has a real, patched replacement -- must be hidden"
2495        );
2496    }
2497}
2498
2499#[cfg(test)]
2500mod managed_file_specs_tests {
2501    //! the declarative managed-file scan. The tournament-specific
2502    //! high-times discovery is gone; `managed_file_specs` is the generic
2503    //! successor, every `RuleTarget::Managed` rule, in config order.
2504
2505    use super::*;
2506
2507    fn plain_rule(glob: &str, target: Option<&str>) -> RedirectionRule {
2508        RedirectionRule {
2509            glob_pattern: glob.to_owned(),
2510            target: target.map(|t| RuleTarget::Path(PathBuf::from(t))),
2511            mode: RedirectionMode::HardError,
2512            patches: vec![],
2513            enable_if: None,
2514        }
2515    }
2516
2517    fn managed_rule(glob: &str, local: &str, remote: &str, poll: Option<u64>) -> RedirectionRule {
2518        managed_rule_with_push(glob, local, remote, poll, None)
2519    }
2520
2521    fn managed_rule_with_push(
2522        glob: &str,
2523        local: &str,
2524        remote: &str,
2525        poll: Option<u64>,
2526        push: Option<&str>,
2527    ) -> RedirectionRule {
2528        RedirectionRule {
2529            glob_pattern: glob.to_owned(),
2530            target: Some(RuleTarget::Managed {
2531                local: PathBuf::from(local),
2532                remote: remote.to_owned(),
2533                poll_interval_secs: poll,
2534                push_url: push.map(str::to_owned),
2535            }),
2536            mode: RedirectionMode::GracefulFallback,
2537            patches: vec![],
2538            enable_if: None,
2539        }
2540    }
2541
2542    fn config(rules: Vec<RedirectionRule>) -> HookConfig {
2543        HookConfig { rules }
2544    }
2545
2546    const GAME: &str = "/games/supreme";
2547
2548    #[test]
2549    fn collects_every_managed_rule_in_config_order() {
2550        let cfg = config(vec![
2551            plain_rule(
2552                "Data/Levels/village/Tracks/medium/replays/Replay_*.dat",
2553                Some(".supreme/mods/online-tournament/replays"),
2554            ),
2555            managed_rule_with_push(
2556                "Saved_Data/village_Tracks_medium_high_times.txt",
2557                ".supreme/mods/online-tournament/high_times/v.txt",
2558                "http://host/ssbz/index.php?action=room_high_times_file",
2559                Some(10),
2560                Some("http://host/ssbz/index.php?action=room_high_times_file"),
2561            ),
2562            managed_rule("Other/file.txt", "absolute-not", "https://host2/file", None),
2563        ]);
2564        let specs = managed_file_specs(Path::new(GAME), &cfg);
2565        assert_eq!(specs.len(), 2, "plain targets are not managed files");
2566        assert_eq!(
2567            specs[0].rel_key,
2568            "Saved_Data/village_Tracks_medium_high_times.txt"
2569        );
2570        assert_eq!(
2571            specs[0].local,
2572            PathBuf::from("/games/supreme/.supreme/mods/online-tournament/high_times/v.txt"),
2573            "relative local resolved against game_dir"
2574        );
2575        assert_eq!(specs[0].poll_interval_secs, Some(10));
2576        assert_eq!(
2577            specs[0].push_url.as_deref(),
2578            Some("http://host/ssbz/index.php?action=room_high_times_file"),
2579            "declared push URL carried into the spec"
2580        );
2581        assert_eq!(specs[1].poll_interval_secs, None, "absent = no polling");
2582        assert_eq!(specs[1].push_url, None, "absent = fetch-only");
2583    }
2584
2585    #[test]
2586    fn wildcard_managed_rules_are_skipped() {
2587        // validate() rejects these; the scan stays defensive.
2588        let cfg = config(vec![managed_rule(
2589            "Data/Updates/**",
2590            ".supreme/mods/m/updates",
2591            "http://host/blob",
2592            Some(5),
2593        )]);
2594        assert!(managed_file_specs(Path::new(GAME), &cfg).is_empty());
2595    }
2596
2597    #[test]
2598    fn plain_targets_only_is_empty() {
2599        let cfg = config(vec![
2600            plain_rule("Saved_Data/Defaults.txt", None),
2601            plain_rule("a.txt", Some("b.txt")),
2602        ]);
2603        assert!(managed_file_specs(Path::new(GAME), &cfg).is_empty());
2604    }
2605
2606    #[test]
2607    fn round_trips_through_serde_both_forms() {
2608        // The untagged enum must keep accepting the classic string form
2609        // (every mod.json) and the new struct form.
2610        let classic: RedirectionRule = serde_json::from_value(serde_json::json!({
2611            "glob_pattern": "a.txt",
2612            "target": "b.txt",
2613            "mode": "graceful_fallback"
2614        }))
2615        .expect("classic string target");
2616        assert_eq!(
2617            classic.target,
2618            Some(RuleTarget::Path(PathBuf::from("b.txt")))
2619        );
2620
2621        let managed: RedirectionRule = serde_json::from_value(serde_json::json!({
2622            "glob_pattern": "a.txt",
2623            "target": {
2624                "local": "b.txt",
2625                "remote": "http://host/f",
2626                "poll_interval_secs": 10
2627            },
2628            "mode": "graceful_fallback"
2629        }))
2630        .expect("managed struct target");
2631        match managed.target {
2632            Some(RuleTarget::Managed {
2633                local,
2634                remote,
2635                poll_interval_secs,
2636                push_url,
2637            }) => {
2638                assert_eq!(local, PathBuf::from("b.txt"));
2639                assert_eq!(remote, "http://host/f");
2640                assert_eq!(poll_interval_secs, Some(10));
2641                assert_eq!(push_url, None, "absent push_url deserializes as None");
2642            }
2643            other => panic!("expected managed target, got {other:?}"),
2644        }
2645
2646        // No-poll serializes without the field (clean mod.json diffs).
2647        let no_poll = RuleTarget::Managed {
2648            local: PathBuf::from("b.txt"),
2649            remote: "http://host/f".to_owned(),
2650            poll_interval_secs: None,
2651            push_url: None,
2652        };
2653        let v = serde_json::to_value(&no_poll).unwrap();
2654        assert!(v.get("poll_interval_secs").is_none());
2655        assert!(v.get("push_url").is_none());
2656
2657        // a full push-capable target round-trips with its URLs.
2658        let pushing: RedirectionRule = serde_json::from_value(serde_json::json!({
2659            "glob_pattern": "a.txt",
2660            "target": {
2661                "local": "b.txt",
2662                "remote": "http://host/f",
2663                "push_url": "http://host/push?f=${room}"
2664            },
2665            "mode": "graceful_fallback"
2666        }))
2667        .expect("managed struct target with push_url");
2668        match pushing.target {
2669            Some(RuleTarget::Managed {
2670                push_url: Some(push),
2671                poll_interval_secs,
2672                ..
2673            }) => {
2674                assert_eq!(push, "http://host/push?f=${room}");
2675                assert_eq!(poll_interval_secs, None);
2676            }
2677            other => panic!("expected managed target with push_url, got {other:?}"),
2678        }
2679    }
2680}