1use std::error;
21use std::ffi::OsString;
22use std::fmt;
23use std::io;
24use std::path::Path;
25use std::path::PathBuf;
26use std::slice::EscapeAscii;
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
29#[non_exhaustive]
30pub enum LoadOrderMethod {
31 Timestamp,
32 Textfile,
33 Asterisk,
34 OpenMW,
35}
36
37#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
38#[non_exhaustive]
39pub enum GameId {
40 Morrowind = 1,
41 Oblivion,
42 Skyrim,
43 Fallout3,
44 FalloutNV,
45 Fallout4,
46 SkyrimSE,
47 Fallout4VR,
48 SkyrimVR,
49 Starfield,
50 OpenMW,
51 OblivionRemastered,
52}
53
54impl GameId {
55 pub fn to_esplugin_id(self) -> esplugin::GameId {
56 match self {
57 GameId::Morrowind | GameId::OpenMW => esplugin::GameId::Morrowind,
58 GameId::Oblivion | GameId::OblivionRemastered => esplugin::GameId::Oblivion,
59 GameId::Skyrim => esplugin::GameId::Skyrim,
60 GameId::SkyrimSE | GameId::SkyrimVR => esplugin::GameId::SkyrimSE,
61 GameId::Fallout3 => esplugin::GameId::Fallout3,
62 GameId::FalloutNV => esplugin::GameId::FalloutNV,
63 GameId::Fallout4 | GameId::Fallout4VR => esplugin::GameId::Fallout4,
64 GameId::Starfield => esplugin::GameId::Starfield,
65 }
66 }
67
68 pub fn supports_light_plugins(self) -> bool {
69 matches!(
70 self,
71 Self::Fallout4 | Self::Fallout4VR | Self::SkyrimSE | Self::SkyrimVR | Self::Starfield
72 )
73 }
74
75 pub fn supports_medium_plugins(self) -> bool {
76 self == GameId::Starfield
77 }
78
79 pub fn allow_plugin_ghosting(self) -> bool {
80 self != GameId::OpenMW
81 }
82
83 pub(crate) fn treats_master_files_differently(self) -> bool {
84 !matches!(self, GameId::OpenMW | GameId::OblivionRemastered)
85 }
86}
87
88#[expect(clippy::error_impl_error)]
89#[derive(Debug)]
90#[non_exhaustive]
91pub enum Error {
92 InvalidPath(PathBuf),
93 IoError(PathBuf, io::Error),
94 NoFilename(PathBuf),
95 DecodeError(Vec<u8>),
96 EncodeError(String),
97 PluginParsingError(PathBuf, Box<dyn error::Error + Send + Sync + 'static>),
98 PluginNotFound(String),
99 TooManyActivePlugins {
100 light_count: usize,
101 medium_count: usize,
102 full_count: usize,
103 },
104 DuplicatePlugin(String),
105 NonMasterBeforeMaster {
106 master: String,
107 non_master: String,
108 },
109 InvalidEarlyLoadingPluginPosition {
110 name: String,
111 pos: usize,
112 expected_pos: usize,
113 },
114 ImplicitlyActivePlugin(String),
115 BlueprintPluginImplicitlyActiveOnly(String),
116 BlueprintShipsPluginImplicitlyActiveOnly(String),
117 NoLocalAppData,
118 NoDocumentsPath,
119 NoUserConfigPath,
120 NoUserDataPath,
121 NoProgramFilesPath,
122 UnrepresentedHoist {
123 plugin: String,
124 master: String,
125 },
126 InstalledPlugin(String),
127 IniParsingError {
128 path: PathBuf,
129 line: usize,
130 column: usize,
131 message: String,
132 },
133 VdfParsingError(PathBuf, String),
134 SystemError(i32, OsString),
135 InvalidBlueprintPluginPosition {
136 name: String,
137 pos: usize,
138 expected_pos: usize,
139 },
140}
141
142#[cfg(windows)]
143impl From<windows_result::Error> for Error {
144 fn from(error: windows_result::Error) -> Self {
145 Error::SystemError(error.code().0, error.message().into())
146 }
147}
148
149impl fmt::Display for Error {
150 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
151 match self {
152 Error::InvalidPath(path) => write!(f, "The path \"{}\" is invalid", escape_ascii(path)),
153 Error::IoError(path, error) =>
154 write!(f, "I/O error involving the path \"{}\": {error}", escape_ascii(path)),
155 Error::NoFilename(path) =>
156 write!(f, "The plugin path \"{}\" has no filename part", escape_ascii(path)),
157 Error::DecodeError(bytes) => write!(f, "String could not be decoded from Windows-1252: {}", bytes.escape_ascii()),
158 Error::EncodeError(string) => write!(f, "The string \"{string}\" could not be encoded to Windows-1252"),
159 Error::PluginParsingError(path, err) => {
160 write!(f, "An error was encountered while parsing the plugin at \"{}\": {err}", escape_ascii(path))
161 }
162 Error::PluginNotFound(name) => {
163 write!(f, "The plugin \"{name}\" is not in the load order")
164 }
165 Error::TooManyActivePlugins {light_count, medium_count, full_count } =>
166 write!(f, "Maximum number of active plugins exceeded: there are {full_count} active full plugins, {medium_count} active medium plugins and {light_count} active light plugins"),
167 Error::DuplicatePlugin(name) =>
168 write!(f, "The given plugin list contains more than one instance of \"{name}\""),
169 Error::NonMasterBeforeMaster{ master, non_master} =>
170 write!(f, "Attempted to load the non-master plugin \"{non_master}\" before the master plugin \"{master}\""),
171 Error::InvalidEarlyLoadingPluginPosition{ name, pos, expected_pos } =>
172 write!(f, "Attempted to load the early-loading plugin \"{name}\" at position {pos}, its expected position is {expected_pos}"),
173 Error::ImplicitlyActivePlugin(name) =>
174 write!(f, "The implicitly active plugin \"{name}\" cannot be deactivated"),
175 Error::BlueprintPluginImplicitlyActiveOnly(name) =>
176 write!(f, "The blueprint plugin \"{name}\" cannot be explicitly activated because that state will not be recorded when the load order is saved"),
177 Error::BlueprintShipsPluginImplicitlyActiveOnly(name) =>
178 write!(f, "The BlueprintShips plugin \"{name}\" cannot be explicitly activated because that state will not be recorded when the load order is saved"),
179 Error::NoLocalAppData => {
180 write!(f, "The game's local app data folder could not be detected")
181 }
182 Error::NoDocumentsPath => write!(f, "The user's Documents path could not be detected"),
183 Error::NoUserConfigPath => write!(f, "The user's config path could not be detected"),
184 Error::NoUserDataPath => write!(f, "The user's data path could not be detected"),
185 Error::NoProgramFilesPath => write!(f, "The Program Files path could not be obtained"),
186 Error::UnrepresentedHoist { plugin, master } =>
187 write!(f, "The plugin \"{plugin}\" is a master of \"{master}\", which will hoist it"),
188 Error::InstalledPlugin(plugin) =>
189 write!(f, "The plugin \"{plugin}\" is installed, so cannot be removed from the load order"),
190 Error::IniParsingError {
191 path,
192 line,
193 column,
194 message,
195 } => write!(f, "Failed to parse ini file at \"{}\", error at line {line}, column {column}: {message}", escape_ascii(path)),
196 Error::VdfParsingError(path, message) =>
197 write!(f, "Failed to parse VDF file at \"{}\": {message}", escape_ascii(path)),
198 Error::SystemError(code, message) =>
199 write!(f, "Error returned by the operating system, code {code}: \"{}\"", message.as_encoded_bytes().escape_ascii()),
200 Error::InvalidBlueprintPluginPosition{ name, pos, expected_pos } =>
201 write!(f, "Attempted to load the blueprint plugin \"{name}\" at position {pos}, its expected position is {expected_pos}"),
202 }
203 }
204}
205
206impl error::Error for Error {
207 fn cause(&self) -> Option<&dyn error::Error> {
208 match self {
209 Error::IoError(_, x) => Some(x),
210 Error::PluginParsingError(_, x) => Some(x.as_ref()),
211 _ => None,
212 }
213 }
214}
215
216fn escape_ascii(path: &Path) -> EscapeAscii<'_> {
217 path.as_os_str().as_encoded_bytes().escape_ascii()
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn game_id_should_map_to_libespm_id_correctly() {
226 assert_eq!(
227 esplugin::GameId::Morrowind,
228 GameId::Morrowind.to_esplugin_id()
229 );
230 assert_eq!(
231 esplugin::GameId::Oblivion,
232 GameId::Oblivion.to_esplugin_id()
233 );
234 assert_eq!(esplugin::GameId::Skyrim, GameId::Skyrim.to_esplugin_id());
235 assert_eq!(
236 esplugin::GameId::SkyrimSE,
237 GameId::SkyrimSE.to_esplugin_id()
238 );
239 assert_eq!(
240 esplugin::GameId::SkyrimSE,
241 GameId::SkyrimVR.to_esplugin_id()
242 );
243 assert_eq!(
244 esplugin::GameId::Fallout3,
245 GameId::Fallout3.to_esplugin_id()
246 );
247 assert_eq!(
248 esplugin::GameId::FalloutNV,
249 GameId::FalloutNV.to_esplugin_id()
250 );
251 assert_eq!(
252 esplugin::GameId::Fallout4,
253 GameId::Fallout4.to_esplugin_id()
254 );
255 assert_eq!(
256 esplugin::GameId::Fallout4,
257 GameId::Fallout4VR.to_esplugin_id()
258 );
259 assert_eq!(
260 esplugin::GameId::Starfield,
261 GameId::Starfield.to_esplugin_id()
262 );
263 }
264
265 #[test]
266 fn game_id_supports_light_plugins_should_be_false_until_fallout_4() {
267 assert!(!GameId::OpenMW.supports_light_plugins());
268 assert!(!GameId::Morrowind.supports_light_plugins());
269 assert!(!GameId::Oblivion.supports_light_plugins());
270 assert!(!GameId::Skyrim.supports_light_plugins());
271 assert!(GameId::SkyrimSE.supports_light_plugins());
272 assert!(GameId::SkyrimVR.supports_light_plugins());
273 assert!(!GameId::Fallout3.supports_light_plugins());
274 assert!(!GameId::FalloutNV.supports_light_plugins());
275 assert!(GameId::Fallout4.supports_light_plugins());
276 assert!(GameId::Fallout4VR.supports_light_plugins());
277 assert!(GameId::Starfield.supports_light_plugins());
278 }
279
280 #[test]
281 fn game_id_supports_medium_plugins_should_be_false_except_for_starfield() {
282 assert!(!GameId::OpenMW.supports_medium_plugins());
283 assert!(!GameId::Morrowind.supports_medium_plugins());
284 assert!(!GameId::Oblivion.supports_medium_plugins());
285 assert!(!GameId::Skyrim.supports_medium_plugins());
286 assert!(!GameId::SkyrimSE.supports_medium_plugins());
287 assert!(!GameId::SkyrimVR.supports_medium_plugins());
288 assert!(!GameId::Fallout3.supports_medium_plugins());
289 assert!(!GameId::FalloutNV.supports_medium_plugins());
290 assert!(!GameId::Fallout4.supports_medium_plugins());
291 assert!(!GameId::Fallout4VR.supports_medium_plugins());
292 assert!(GameId::Starfield.supports_medium_plugins());
293 }
294
295 #[test]
296 fn game_id_allow_plugin_ghosting_should_be_false_for_openmw_only() {
297 assert!(!GameId::OpenMW.allow_plugin_ghosting());
298 assert!(GameId::Morrowind.allow_plugin_ghosting());
299 assert!(GameId::Oblivion.allow_plugin_ghosting());
300 assert!(GameId::Skyrim.allow_plugin_ghosting());
301 assert!(GameId::SkyrimSE.allow_plugin_ghosting());
302 assert!(GameId::SkyrimVR.allow_plugin_ghosting());
303 assert!(GameId::Fallout3.allow_plugin_ghosting());
304 assert!(GameId::FalloutNV.allow_plugin_ghosting());
305 assert!(GameId::Fallout4.allow_plugin_ghosting());
306 assert!(GameId::Fallout4VR.allow_plugin_ghosting());
307 assert!(GameId::Starfield.allow_plugin_ghosting());
308 }
309
310 #[test]
311 fn game_id_treats_master_files_differently_should_be_false_for_openmw_and_oblivion_remastered_only(
312 ) {
313 assert!(!GameId::OpenMW.treats_master_files_differently());
314 assert!(GameId::Morrowind.treats_master_files_differently());
315 assert!(GameId::Oblivion.treats_master_files_differently());
316 assert!(!GameId::OblivionRemastered.treats_master_files_differently());
317 assert!(GameId::Skyrim.treats_master_files_differently());
318 assert!(GameId::SkyrimSE.treats_master_files_differently());
319 assert!(GameId::SkyrimVR.treats_master_files_differently());
320 assert!(GameId::Fallout3.treats_master_files_differently());
321 assert!(GameId::FalloutNV.treats_master_files_differently());
322 assert!(GameId::Fallout4.treats_master_files_differently());
323 assert!(GameId::Fallout4VR.treats_master_files_differently());
324 assert!(GameId::Starfield.treats_master_files_differently());
325 }
326
327 #[test]
328 fn error_display_should_print_double_quoted_paths() {
329 let string = format!("{}", Error::InvalidPath(PathBuf::from("foo")));
330
331 assert_eq!("The path \"foo\" is invalid", string);
332 }
333
334 #[test]
335 fn error_display_should_print_os_string_as_quoted_string() {
336 let string = format!("{}", Error::SystemError(1, OsString::from("foo")));
337
338 assert_eq!(
339 "Error returned by the operating system, code 1: \"foo\"",
340 string
341 );
342 }
343}