Skip to main content

modde_games/generic/
spec.rs

1//! TOML specification for user-defined generic games and its validation.
2
3use std::path::{Component, Path, PathBuf};
4
5use anyhow::{Result, bail};
6use serde::{Deserialize, Serialize};
7
8use crate::registry::SUPPORTED_GAME_IDS;
9
10/// Deserialized configuration for a user-defined generic game.
11#[derive(Debug, Clone, Deserialize)]
12pub struct GameSpec {
13    pub id: String,
14    pub display_name: String,
15    pub steam_app_id: Option<String>,
16    pub install_dir_name: Option<String>,
17    pub install_path_override: Option<PathBuf>,
18    pub executable_dir: PathBuf,
19    pub mod_dir: Option<PathBuf>,
20    pub nexus_domain: Option<String>,
21    #[serde(default)]
22    pub proxy_dlls: Vec<String>,
23}
24
25/// Borrowing serialization view of a [`GameSpec`] for writing TOML.
26#[derive(Debug, Clone, Serialize)]
27pub struct GameSpecToml<'a> {
28    pub id: &'a str,
29    pub display_name: &'a str,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub steam_app_id: Option<&'a str>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub install_dir_name: Option<&'a str>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub install_path_override: Option<&'a std::path::Path>,
36    pub executable_dir: &'a std::path::Path,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub mod_dir: Option<&'a std::path::Path>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub nexus_domain: Option<&'a str>,
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub proxy_dlls: Vec<&'a str>,
43}
44
45/// Serialize a [`GameSpec`] to a pretty TOML string.
46pub fn serialize(game: &GameSpec) -> Result<String> {
47    let toml = GameSpecToml {
48        id: &game.id,
49        display_name: &game.display_name,
50        steam_app_id: game.steam_app_id.as_deref(),
51        install_dir_name: game.install_dir_name.as_deref(),
52        install_path_override: game.install_path_override.as_deref(),
53        executable_dir: &game.executable_dir,
54        mod_dir: game.mod_dir.as_deref(),
55        nexus_domain: game.nexus_domain.as_deref(),
56        proxy_dlls: game.proxy_dlls.iter().map(String::as_str).collect(),
57    };
58
59    Ok(toml::to_string_pretty(&toml)?)
60}
61
62impl GameSpec {
63    /// Validate the spec: well-formed `id`, no built-in collision, relative
64    /// install-root paths, and a non-empty display name.
65    pub fn validate(&self) -> Result<()> {
66        if !is_valid_game_id(&self.id) {
67            bail!(
68                "invalid game id '{}': must match ^[a-z0-9][a-z0-9-]*$",
69                self.id
70            );
71        }
72
73        if SUPPORTED_GAME_IDS.contains(&self.id.as_str()) {
74            bail!("game id '{}' collides with a built-in game", self.id);
75        }
76
77        ensure_relative_path(&self.executable_dir, "executable_dir")?;
78        if let Some(mod_dir) = &self.mod_dir {
79            ensure_relative_path(mod_dir, "mod_dir")?;
80        }
81
82        if self.display_name.trim().is_empty() {
83            bail!("display_name must not be empty");
84        }
85
86        Ok(())
87    }
88}
89
90fn is_valid_game_id(id: &str) -> bool {
91    let mut chars = id.chars();
92    match chars.next() {
93        Some(first) if first.is_ascii_lowercase() || first.is_ascii_digit() => {}
94        _ => return false,
95    }
96
97    chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
98}
99
100fn ensure_relative_path(path: &Path, field: &str) -> Result<()> {
101    if path.is_absolute() {
102        bail!("{field} must be relative to the install root");
103    }
104
105    if path.components().any(|component| {
106        matches!(
107            component,
108            Component::ParentDir | Component::RootDir | Component::Prefix(_)
109        )
110    }) {
111        bail!("{field} must not escape the install root");
112    }
113
114    Ok(())
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn valid_minimal_spec_parses() {
123        let spec: GameSpec = toml::from_str(
124            r#"
125                id = "custom-game"
126                display_name = "Custom Game"
127                executable_dir = "bin/x64"
128            "#,
129        )
130        .expect("spec should parse");
131
132        spec.validate().expect("spec should validate");
133        assert_eq!(spec.proxy_dlls, Vec::<String>::new());
134    }
135
136    #[test]
137    fn reject_absolute_executable_dir() {
138        let spec: GameSpec = toml::from_str(
139            r#"
140                id = "custom-game"
141                display_name = "Custom Game"
142                executable_dir = "/opt/game/bin"
143            "#,
144        )
145        .expect("spec should parse");
146
147        let err = spec
148            .validate()
149            .expect_err("absolute path should be rejected");
150        assert!(err.to_string().contains("executable_dir"));
151    }
152
153    #[test]
154    fn reject_built_in_id_collision() {
155        let spec: GameSpec = toml::from_str(
156            r#"
157                id = "skyrim-se"
158                display_name = "Not Skyrim"
159                executable_dir = "bin/x64"
160            "#,
161        )
162        .expect("spec should parse");
163
164        let err = spec
165            .validate()
166            .expect_err("built-in collision should be rejected");
167        assert!(err.to_string().contains("collides with a built-in game"));
168    }
169}