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}