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    /// Root config discovery tried both local and global config candidates and found neither.
221    CannotFindRootConfig { local: PathBuf, global: PathBuf },
222    /// External-tool config discovery found neither root nor user config candidates.
223    CannotFindAnyConfig {
224        local: PathBuf,
225        global: PathBuf,
226        user: PathBuf,
227    },
228    /// The target path for a save operation is not writable.
229    NotWritable(PathBuf),
230    /// [`OpenMWConfiguration::save_subconfig`](crate::OpenMWConfiguration::save_subconfig)
231    /// was called with a path that is not part of the loaded configuration chain.
232    SubconfigNotLoaded(PathBuf),
233    /// The `config=` chain exceeded the maximum nesting depth, likely due to a circular reference.
234    MaxDepthExceeded(PathBuf),
235    /// Could not resolve a platform default path via `dirs`.
236    PlatformPathUnavailable(&'static str),
237}
238
239fn line_suffix(line: Option<usize>) -> String {
240    line.map_or_else(String::new, |line| format!(" at line {line}"))
241}
242
243fn duplicate_message(file: &str, kind: &str, config_path: &Path, line: Option<usize>) -> String {
244    format!(
245        "{file} has appeared in the {kind} list twice. Its second occurence was in: {}{}",
246        config_path.display(),
247        line_suffix(line)
248    )
249}
250
251fn cannot_find_root_message(local: &Path, global: &Path) -> String {
252    format!(
253        "OpenMW root config discovery found no openmw.cfg at local path {} or global config path {}",
254        local.display(),
255        global.display()
256    )
257}
258
259fn cannot_find_any_message(local: &Path, global: &Path, user: &Path) -> String {
260    format!(
261        "OpenMW config discovery found no openmw.cfg at local path {}, global config path {}, or user config path {}",
262        local.display(),
263        global.display(),
264        user.display()
265    )
266}
267
268fn fmt_duplicate_or_add_error(
269    error: &ConfigError,
270    f: &mut fmt::Formatter<'_>,
271) -> Option<fmt::Result> {
272    match error {
273        ConfigError::DuplicateContentFile {
274            file,
275            config_path,
276            line,
277        } => Some(f.write_str(&duplicate_message(
278            file,
279            "content files",
280            config_path,
281            *line,
282        ))),
283        ConfigError::CannotAddContentFile { file, config_path } => Some(write!(
284            f,
285            "{file} cannot be added to the configuration map as a content file because it was already defined by: {}",
286            config_path.display()
287        )),
288        ConfigError::DuplicateGroundcoverFile {
289            file,
290            config_path,
291            line,
292        } => Some(f.write_str(&duplicate_message(file, "groundcover", config_path, *line))),
293        ConfigError::CannotAddGroundcoverFile { file, config_path } => Some(write!(
294            f,
295            "{file} cannot be added to the configuration map as a groundcover plugin because it was already defined by: {}",
296            config_path.display()
297        )),
298        ConfigError::DuplicateArchiveFile {
299            file,
300            config_path,
301            line,
302        } => Some(f.write_str(&duplicate_message(file, "BSA/Archive", config_path, *line))),
303        ConfigError::CannotAddArchiveFile { file, config_path } => Some(write!(
304            f,
305            "{file} cannot be added to the configuration map as a fallback-archive because it was already defined by: {}",
306            config_path.display()
307        )),
308        _ => None,
309    }
310}
311
312impl fmt::Display for ConfigError {
313    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314        if let Some(result) = fmt_duplicate_or_add_error(self, f) {
315            return result;
316        }
317
318        match self {
319            ConfigError::InvalidGameSetting {
320                value,
321                config_path,
322                line,
323            } => write!(
324                f,
325                "Invalid fallback setting '{}' in config file '{}'{}",
326                value,
327                config_path.display(),
328                line_suffix(*line)
329            ),
330            ConfigError::Io(e) => write!(f, "IO error: {e}"),
331            ConfigError::NotFileOrDirectory(config_path) => write!(
332                f,
333                "Unable to determine whether {} was a file or directory, refusing to read.",
334                config_path.display()
335            ),
336            ConfigError::CannotFind(config_path) => write!(
337                f,
338                "An openmw.cfg does not exist at: {}",
339                config_path.display()
340            ),
341            ConfigError::CannotFindRootConfig { local, global } => {
342                write!(f, "{}", cannot_find_root_message(local, global))
343            }
344            ConfigError::CannotFindAnyConfig {
345                local,
346                global,
347                user,
348            } => write!(f, "{}", cannot_find_any_message(local, global, user)),
349            ConfigError::DuplicateContentFile { .. }
350            | ConfigError::CannotAddContentFile { .. }
351            | ConfigError::DuplicateGroundcoverFile { .. }
352            | ConfigError::CannotAddGroundcoverFile { .. }
353            | ConfigError::DuplicateArchiveFile { .. }
354            | ConfigError::CannotAddArchiveFile { .. } => {
355                unreachable!("duplicate/add errors are handled before the main display match")
356            }
357            ConfigError::BadEncoding {
358                value,
359                config_path,
360                line,
361            } => write!(
362                f,
363                "Invalid encoding type: {value} in config file {}{}",
364                config_path.display(),
365                line_suffix(*line)
366            ),
367            ConfigError::InvalidLine {
368                value,
369                config_path,
370                line,
371            } => write!(
372                f,
373                "Invalid pair in openmw.cfg {value} was defined by {}{}",
374                config_path.display(),
375                line_suffix(*line)
376            ),
377            ConfigError::NotWritable(path) => {
378                write!(f, "Target path is not writable: {}", path.display())
379            }
380            ConfigError::SubconfigNotLoaded(path) => write!(
381                f,
382                "Cannot save to {}; it is not part of the loaded configuration chain",
383                path.display()
384            ),
385            ConfigError::MaxDepthExceeded(path) => write!(
386                f,
387                "Maximum config= nesting depth exceeded while loading {}",
388                path.display()
389            ),
390            ConfigError::PlatformPathUnavailable(kind) => {
391                write!(f, "Failed to resolve platform default {kind} path")
392            }
393        }
394    }
395}
396
397impl std::error::Error for ConfigError {}
398
399impl From<std::io::Error> for ConfigError {
400    fn from(err: std::io::Error) -> Self {
401        ConfigError::Io(err)
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_display_messages_include_key_context() {
411        let path = PathBuf::from("/tmp/openmw.cfg");
412
413        let cannot_find = ConfigError::CannotFind(path.clone()).to_string();
414        assert!(cannot_find.contains("openmw.cfg"));
415
416        let cannot_find_root = ConfigError::CannotFindRootConfig {
417            local: PathBuf::from("/tmp/local/openmw.cfg"),
418            global: PathBuf::from("/tmp/global/openmw.cfg"),
419        }
420        .to_string();
421        assert!(cannot_find_root.contains("/tmp/local/openmw.cfg"));
422        assert!(cannot_find_root.contains("/tmp/global/openmw.cfg"));
423
424        let cannot_find_any = ConfigError::CannotFindAnyConfig {
425            local: PathBuf::from("/tmp/local/openmw.cfg"),
426            global: PathBuf::from("/tmp/global/openmw.cfg"),
427            user: PathBuf::from("/tmp/user/openmw.cfg"),
428        }
429        .to_string();
430        assert!(cannot_find_any.contains("/tmp/local/openmw.cfg"));
431        assert!(cannot_find_any.contains("/tmp/global/openmw.cfg"));
432        assert!(cannot_find_any.contains("/tmp/user/openmw.cfg"));
433
434        let duplicate = ConfigError::DuplicateContentFile {
435            file: "Morrowind.esm".into(),
436            config_path: path.clone(),
437            line: None,
438        }
439        .to_string();
440        assert!(duplicate.contains("Morrowind.esm"));
441
442        let invalid_line = ConfigError::InvalidLine {
443            value: "broken".into(),
444            config_path: path,
445            line: Some(42),
446        }
447        .to_string();
448        assert!(invalid_line.contains("broken"));
449        assert!(invalid_line.contains("line 42"));
450    }
451
452    #[test]
453    fn test_from_io_error_wraps_variant() {
454        let io = std::io::Error::other("boom");
455        let converted: ConfigError = io.into();
456        assert!(matches!(converted, ConfigError::Io(_)));
457    }
458}