modde_games/generic/
spec.rs1use std::path::{Component, Path, PathBuf};
4
5use anyhow::{Result, bail};
6use serde::{Deserialize, Serialize};
7
8use crate::registry::SUPPORTED_GAME_IDS;
9
10#[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#[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
45pub 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 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}