Skip to main content

openmw_config/config/
error.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (c) 2025 Dave Corley (S3kshun8)
3
4use std::{
5    fmt,
6    path::{Path, PathBuf},
7};
8
9#[macro_export]
10macro_rules! config_err {
11    // InvalidGameSetting: value, path
12    (invalid_game_setting, $value:expr, $path:expr) => {
13        $crate::ConfigError::InvalidGameSetting {
14            value: $value.to_string(),
15            config_path: $path.to_path_buf(),
16            line: None,
17        }
18    };
19
20    (invalid_game_setting, $value:expr, $path:expr, $line:expr) => {
21        $crate::ConfigError::InvalidGameSetting {
22            value: $value.to_string(),
23            config_path: $path.to_path_buf(),
24            line: Some($line),
25        }
26    };
27
28    (not_file_or_directory, $config_path:expr) => {
29        $crate::ConfigError::NotFileOrDirectory($config_path.to_path_buf())
30    };
31
32    (cannot_find, $config_path:expr) => {
33        $crate::ConfigError::CannotFind($config_path.to_path_buf())
34    };
35
36    (duplicate_content_file, $content_file:expr, $config_path:expr) => {
37        $crate::ConfigError::DuplicateContentFile {
38            file: $content_file,
39            config_path: $config_path.to_path_buf(),
40            line: None,
41        }
42    };
43
44    (duplicate_content_file, $content_file:expr, $config_path:expr, $line:expr) => {
45        $crate::ConfigError::DuplicateContentFile {
46            file: $content_file,
47            config_path: $config_path.to_path_buf(),
48            line: Some($line),
49        }
50    };
51
52    (duplicate_archive_file, $archive_file:expr, $config_path:expr) => {
53        $crate::ConfigError::DuplicateArchiveFile {
54            file: $archive_file,
55            config_path: $config_path.to_path_buf(),
56            line: None,
57        }
58    };
59
60    (duplicate_archive_file, $archive_file:expr, $config_path:expr, $line:expr) => {
61        $crate::ConfigError::DuplicateArchiveFile {
62            file: $archive_file,
63            config_path: $config_path.to_path_buf(),
64            line: Some($line),
65        }
66    };
67
68    (archive_already_defined, $content_file:expr, $config_path:expr) => {
69        $crate::ConfigError::CannotAddArchiveFile {
70            file: $content_file,
71            config_path: $config_path.to_path_buf(),
72        }
73    };
74
75    (content_already_defined, $content_file:expr, $config_path:expr) => {
76        $crate::ConfigError::CannotAddContentFile {
77            file: $content_file,
78            config_path: $config_path.to_path_buf(),
79        }
80    };
81
82    (groundcover_already_defined, $groundcover_file:expr, $config_path:expr) => {
83        $crate::ConfigError::CannotAddGroundcoverFile {
84            file: $groundcover_file,
85            config_path: $config_path.to_path_buf(),
86        }
87    };
88
89    (duplicate_groundcover_file, $groundcover_file:expr, $config_path:expr) => {
90        $crate::ConfigError::DuplicateGroundcoverFile {
91            file: $groundcover_file,
92            config_path: $config_path.to_path_buf(),
93            line: None,
94        }
95    };
96
97    (duplicate_groundcover_file, $groundcover_file:expr, $config_path:expr, $line:expr) => {
98        $crate::ConfigError::DuplicateGroundcoverFile {
99            file: $groundcover_file,
100            config_path: $config_path.to_path_buf(),
101            line: Some($line),
102        }
103    };
104
105    (bad_encoding, $encoding:expr, $config_path:expr) => {
106        $crate::ConfigError::BadEncoding {
107            value: $encoding,
108            config_path: $config_path,
109            line: None,
110        }
111    };
112
113    (bad_encoding, $encoding:expr, $config_path:expr, $line:expr) => {
114        $crate::ConfigError::BadEncoding {
115            value: $encoding,
116            config_path: $config_path,
117            line: Some($line),
118        }
119    };
120
121    (invalid_line, $value:expr, $config_path:expr) => {
122        $crate::ConfigError::InvalidLine {
123            value: $value,
124            config_path: $config_path,
125            line: None,
126        }
127    };
128
129    (invalid_line, $value:expr, $config_path:expr, $line:expr) => {
130        $crate::ConfigError::InvalidLine {
131            value: $value,
132            config_path: $config_path,
133            line: Some($line),
134        }
135    };
136
137    (not_writable, $path:expr) => {
138        $crate::ConfigError::NotWritable($path.to_path_buf())
139    };
140
141    (subconfig_not_loaded, $path:expr) => {
142        $crate::ConfigError::SubconfigNotLoaded($path.to_path_buf())
143    };
144
145    (max_depth_exceeded, $path:expr) => {
146        $crate::ConfigError::MaxDepthExceeded($path.to_path_buf())
147    };
148
149    // Wrap std::io::Error
150    (io, $err:expr) => {
151        $crate::ConfigError::Io($err)
152    };
153}
154
155#[macro_export]
156macro_rules! bail_config {
157    ($($tt:tt)*) => {
158        {
159        return Err($crate::config_err!($($tt)*));
160    }
161};
162}
163
164/// Errors that can arise while loading, mutating, or saving an `OpenMW` configuration.
165#[derive(Debug)]
166#[non_exhaustive]
167pub enum ConfigError {
168    /// A content file (`content=`) appeared twice in the configuration chain.
169    DuplicateContentFile {
170        file: String,
171        config_path: PathBuf,
172        line: Option<usize>,
173    },
174    /// A fallback-archive (`fallback-archive=`) appeared twice in the configuration chain.
175    DuplicateArchiveFile {
176        file: String,
177        config_path: PathBuf,
178        line: Option<usize>,
179    },
180    /// [`OpenMWConfiguration::add_content_file`](crate::OpenMWConfiguration::add_content_file)
181    /// was called for a file that is already present.
182    CannotAddContentFile { file: String, config_path: PathBuf },
183    /// [`OpenMWConfiguration::add_archive_file`](crate::OpenMWConfiguration::add_archive_file)
184    /// was called for an archive that is already present.
185    CannotAddArchiveFile { file: String, config_path: PathBuf },
186    /// A groundcover file (`groundcover=`) appeared twice in the configuration chain.
187    DuplicateGroundcoverFile {
188        file: String,
189        config_path: PathBuf,
190        line: Option<usize>,
191    },
192    /// [`OpenMWConfiguration::add_groundcover_file`](crate::OpenMWConfiguration::add_groundcover_file)
193    /// was called for a file that is already present.
194    CannotAddGroundcoverFile { file: String, config_path: PathBuf },
195    /// A `fallback=` entry could not be parsed as a valid `Key,Value` pair.
196    InvalidGameSetting {
197        value: String,
198        config_path: PathBuf,
199        line: Option<usize>,
200    },
201    /// An `encoding=` entry contained an unrecognised encoding name.
202    /// Only `win1250`, `win1251`, and `win1252` are valid.
203    BadEncoding {
204        value: String,
205        config_path: PathBuf,
206        line: Option<usize>,
207    },
208    /// A line in an `openmw.cfg` file did not match any recognised `key=value` format.
209    InvalidLine {
210        value: String,
211        config_path: PathBuf,
212        line: Option<usize>,
213    },
214    /// An I/O error occurred while reading or writing a config file.
215    Io(std::io::Error),
216    /// The supplied path could not be classified as a file or directory.
217    NotFileOrDirectory(PathBuf),
218    /// No `openmw.cfg` was found at the given path.
219    CannotFind(PathBuf),
220    /// The target path for a save operation is not writable.
221    NotWritable(PathBuf),
222    /// [`OpenMWConfiguration::save_subconfig`](crate::OpenMWConfiguration::save_subconfig)
223    /// was called with a path that is not part of the loaded configuration chain.
224    SubconfigNotLoaded(PathBuf),
225    /// The `config=` chain exceeded the maximum nesting depth, likely due to a circular reference.
226    MaxDepthExceeded(PathBuf),
227    /// Could not resolve a platform default path via `dirs`.
228    PlatformPathUnavailable(&'static str),
229}
230
231fn line_suffix(line: Option<usize>) -> String {
232    line.map_or_else(String::new, |line| format!(" at line {line}"))
233}
234
235fn duplicate_message(file: &str, kind: &str, config_path: &Path, line: Option<usize>) -> String {
236    format!(
237        "{file} has appeared in the {kind} list twice. Its second occurence was in: {}{}",
238        config_path.display(),
239        line_suffix(line)
240    )
241}
242
243impl fmt::Display for ConfigError {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        match self {
246            ConfigError::InvalidGameSetting {
247                value,
248                config_path,
249                line,
250            } => write!(
251                f,
252                "Invalid fallback setting '{}' in config file '{}'{}",
253                value,
254                config_path.display(),
255                line_suffix(*line)
256            ),
257            ConfigError::Io(e) => write!(f, "IO error: {e}"),
258            ConfigError::NotFileOrDirectory(config_path) => write!(
259                f,
260                "Unable to determine whether {} was a file or directory, refusing to read.",
261                config_path.display()
262            ),
263            ConfigError::CannotFind(config_path) => {
264                write!(
265                    f,
266                    "An openmw.cfg does not exist at: {}",
267                    config_path.display()
268                )
269            }
270            ConfigError::DuplicateContentFile {
271                file,
272                config_path,
273                line,
274            } => f.write_str(&duplicate_message(
275                file,
276                "content files",
277                config_path,
278                *line,
279            )),
280            ConfigError::CannotAddContentFile { file, config_path } => write!(
281                f,
282                "{file} cannot be added to the configuration map as a content file because it was already defined by: {}",
283                config_path.display()
284            ),
285            ConfigError::DuplicateGroundcoverFile {
286                file,
287                config_path,
288                line,
289            } => f.write_str(&duplicate_message(file, "groundcover", config_path, *line)),
290            ConfigError::CannotAddGroundcoverFile { file, config_path } => write!(
291                f,
292                "{file} cannot be added to the configuration map as a groundcover plugin because it was already defined by: {}",
293                config_path.display()
294            ),
295            ConfigError::DuplicateArchiveFile {
296                file,
297                config_path,
298                line,
299            } => f.write_str(&duplicate_message(file, "BSA/Archive", config_path, *line)),
300            ConfigError::CannotAddArchiveFile { file, config_path } => write!(
301                f,
302                "{file} cannot be added to the configuration map as a fallback-archive because it was already defined by: {}",
303                config_path.display()
304            ),
305            ConfigError::BadEncoding {
306                value,
307                config_path,
308                line,
309            } => write!(
310                f,
311                "Invalid encoding type: {value} in config file {}{}",
312                config_path.display(),
313                line_suffix(*line)
314            ),
315            ConfigError::InvalidLine {
316                value,
317                config_path,
318                line,
319            } => write!(
320                f,
321                "Invalid pair in openmw.cfg {value} was defined by {}{}",
322                config_path.display(),
323                line_suffix(*line)
324            ),
325            ConfigError::NotWritable(path) => {
326                write!(f, "Target path is not writable: {}", path.display())
327            }
328            ConfigError::SubconfigNotLoaded(path) => write!(
329                f,
330                "Cannot save to {}; it is not part of the loaded configuration chain",
331                path.display()
332            ),
333            ConfigError::MaxDepthExceeded(path) => write!(
334                f,
335                "Maximum config= nesting depth exceeded while loading {}",
336                path.display()
337            ),
338            ConfigError::PlatformPathUnavailable(kind) => {
339                write!(f, "Failed to resolve platform default {kind} path")
340            }
341        }
342    }
343}
344
345impl std::error::Error for ConfigError {}
346
347impl From<std::io::Error> for ConfigError {
348    fn from(err: std::io::Error) -> Self {
349        ConfigError::Io(err)
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn test_display_messages_include_key_context() {
359        let path = PathBuf::from("/tmp/openmw.cfg");
360
361        let cannot_find = ConfigError::CannotFind(path.clone()).to_string();
362        assert!(cannot_find.contains("openmw.cfg"));
363
364        let duplicate = ConfigError::DuplicateContentFile {
365            file: "Morrowind.esm".into(),
366            config_path: path.clone(),
367            line: None,
368        }
369        .to_string();
370        assert!(duplicate.contains("Morrowind.esm"));
371
372        let invalid_line = ConfigError::InvalidLine {
373            value: "broken".into(),
374            config_path: path,
375            line: Some(42),
376        }
377        .to_string();
378        assert!(invalid_line.contains("broken"));
379        assert!(invalid_line.contains("line 42"));
380    }
381
382    #[test]
383    fn test_from_io_error_wraps_variant() {
384        let io = std::io::Error::other("boom");
385        let converted: ConfigError = io.into();
386        assert!(matches!(converted, ConfigError::Io(_)));
387    }
388}