Skip to main content

modde_core/installer/
probe.rs

1//! Game-specific install detection hooks.
2//!
3//! `modde-core` cannot reference `modde-games` (would create a dependency
4//! cycle), so the analyzer takes an [`InstallProbe`] that the caller
5//! constructs from whatever game plugin context it has. `modde-games`
6//! provides a `game_probe(plugin)` helper that wraps a
7//! `&'static dyn GamePlugin` into a probe.
8//!
9//! The probe owns its closures (as `Box<dyn Fn>`), so analysis does not
10//! leak memory and the probe can be passed across tasks.
11
12use std::path::Path;
13
14use super::types::InstallMethod;
15
16/// Callbacks the analyzer uses to delegate to a game plugin.
17///
18/// Both hooks have sensible defaults ([`InstallProbe::noop`]), so a game
19/// plugin with no special layouts can just leave them unimplemented.
20pub struct InstallProbe {
21    /// Return a game-specific [`InstallMethod`] if the plugin recognizes
22    /// the extracted archive authoritatively (e.g. Cyberpunk identifying
23    /// a `REDmod` by `info.json` + `archives/` presence). Runs **before**
24    /// the generic probes so it can claim layouts that also happen to
25    /// trigger generic heuristics.
26    pub analyze: Box<dyn Fn(&Path) -> Option<InstallMethod> + Send + Sync>,
27
28    /// Return `true` if the extracted archive looks like a bare-extract
29    /// for this game (e.g. top-level `Data/` for Bethesda). Runs as the
30    /// last fallback before [`InstallMethod::Unknown`].
31    pub recognizes_bare: Box<dyn Fn(&Path) -> bool + Send + Sync>,
32
33    /// Plugin-supplied id of a `DeployTargetKind::UserConfig` root, if
34    /// this game advertises one. The analyzer falls back to a
35    /// [`InstallMethod::UserConfigOverlay`] keyed on this id when the
36    /// archive contains only config-shaped files. `None` means the
37    /// game has no user-config target — config-only archives will go
38    /// straight to `Unknown`.
39    pub user_config_target: Option<&'static str>,
40}
41
42impl InstallProbe {
43    /// Construct a probe from two closures.
44    pub fn new<A, B>(analyze: A, recognizes_bare: B) -> Self
45    where
46        A: Fn(&Path) -> Option<InstallMethod> + Send + Sync + 'static,
47        B: Fn(&Path) -> bool + Send + Sync + 'static,
48    {
49        Self {
50            analyze: Box::new(analyze),
51            recognizes_bare: Box::new(recognizes_bare),
52            user_config_target: None,
53        }
54    }
55
56    /// Builder: attach a user-config target id. The analyzer will
57    /// route config-only archives to `UserConfigOverlay { target_id }`.
58    #[must_use]
59    pub fn with_user_config_target(mut self, target_id: &'static str) -> Self {
60        self.user_config_target = Some(target_id);
61        self
62    }
63
64    /// A probe that never claims anything game-specific. Used by tests of
65    /// the generic detection pipeline and as a "no plugin available"
66    /// fallback.
67    #[must_use]
68    pub fn noop() -> Self {
69        Self {
70            analyze: Box::new(|_| None),
71            recognizes_bare: Box::new(|_| false),
72            user_config_target: None,
73        }
74    }
75}