Skip to main content

openmw_config/config/
error.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2025 Dave Corley (S3kshun8)
3
4use std::{
5    fmt,
6    path::{Path, PathBuf},
7};
8
9macro_rules! config_err {
10    // InvalidGameSetting: value, path
11    (invalid_game_setting, $value:expr, $path:expr) => {
12        $crate::ConfigError::InvalidGameSetting {
13            value: $value.to_string(),
14            config_path: $path.to_path_buf(),
15            line: None,
16        }
17    };
18
19    (invalid_game_setting, $value:expr, $path:expr, $line:expr) => {
20        $crate::ConfigError::InvalidGameSetting {
21            value: $value.to_string(),
22            config_path: $path.to_path_buf(),
23            line: Some($line),
24        }
25    };
26
27    (not_file_or_directory, $config_path:expr) => {
28        $crate::ConfigError::NotFileOrDirectory($config_path.to_path_buf())
29    };
30
31    (cannot_find, $config_path:expr) => {
32        $crate::ConfigError::CannotFind($config_path.to_path_buf())
33    };
34
35    (duplicate_content_file, $content_file:expr, $config_path:expr) => {
36        $crate::ConfigError::DuplicateContentFile {
37            file: $content_file,
38            config_path: $config_path.to_path_buf(),
39            line: None,
40        }
41    };
42
43    (duplicate_content_file, $content_file:expr, $config_path:expr, $line:expr) => {
44        $crate::ConfigError::DuplicateContentFile {
45            file: $content_file,
46            config_path: $config_path.to_path_buf(),
47            line: Some($line),
48        }
49    };
50
51    (duplicate_archive_file, $archive_file:expr, $config_path:expr) => {
52        $crate::ConfigError::DuplicateArchiveFile {
53            file: $archive_file,
54            config_path: $config_path.to_path_buf(),
55            line: None,
56        }
57    };
58
59    (duplicate_archive_file, $archive_file:expr, $config_path:expr, $line:expr) => {
60        $crate::ConfigError::DuplicateArchiveFile {
61            file: $archive_file,
62            config_path: $config_path.to_path_buf(),
63            line: Some($line),
64        }
65    };
66
67    (archive_already_defined, $content_file:expr, $config_path:expr) => {
68        $crate::ConfigError::CannotAddArchiveFile {
69            file: $content_file,
70            config_path: $config_path.to_path_buf(),
71        }
72    };
73
74    (content_already_defined, $content_file:expr, $config_path:expr) => {
75        $crate::ConfigError::CannotAddContentFile {
76            file: $content_file,
77            config_path: $config_path.to_path_buf(),
78        }
79    };
80
81    (groundcover_already_defined, $groundcover_file:expr, $config_path:expr) => {
82        $crate::ConfigError::CannotAddGroundcoverFile {
83            file: $groundcover_file,
84            config_path: $config_path.to_path_buf(),
85        }
86    };
87
88    (duplicate_groundcover_file, $groundcover_file:expr, $config_path:expr) => {
89        $crate::ConfigError::DuplicateGroundcoverFile {
90            file: $groundcover_file,
91            config_path: $config_path.to_path_buf(),
92            line: None,
93        }
94    };
95
96    (duplicate_groundcover_file, $groundcover_file:expr, $config_path:expr, $line:expr) => {
97        $crate::ConfigError::DuplicateGroundcoverFile {
98            file: $groundcover_file,
99            config_path: $config_path.to_path_buf(),
100            line: Some($line),
101        }
102    };
103
104    (bad_encoding, $encoding:expr, $config_path:expr) => {
105        $crate::ConfigError::BadEncoding {
106            value: $encoding,
107            config_path: $config_path,
108            line: None,
109        }
110    };
111
112    (bad_encoding, $encoding:expr, $config_path:expr, $line:expr) => {
113        $crate::ConfigError::BadEncoding {
114            value: $encoding,
115            config_path: $config_path,
116            line: Some($line),
117        }
118    };
119
120    (invalid_line, $value:expr, $config_path:expr) => {
121        $crate::ConfigError::InvalidLine {
122            value: $value,
123            config_path: $config_path,
124            line: None,
125        }
126    };
127
128    (invalid_line, $value:expr, $config_path:expr, $line:expr) => {
129        $crate::ConfigError::InvalidLine {
130            value: $value,
131            config_path: $config_path,
132            line: Some($line),
133        }
134    };
135
136    (not_writable, $path:expr) => {
137        $crate::ConfigError::NotWritable($path.to_path_buf())
138    };
139
140    (subconfig_not_loaded, $path:expr) => {
141        $crate::ConfigError::SubconfigNotLoaded($path.to_path_buf())
142    };
143
144    (max_depth_exceeded, $path:expr) => {
145        $crate::ConfigError::MaxDepthExceeded($path.to_path_buf())
146    };
147
148    // Wrap std::io::Error
149    (io, $err:expr) => {
150        $crate::ConfigError::Io($err)
151    };
152}
153
154pub(crate) use config_err;
155
156macro_rules! bail_config {
157    ($($tt:tt)*) => {{
158        return Err($crate::config::error::config_err!($($tt)*));
159    }};
160}
161pub(crate) use bail_config;
162
163/// Errors that can arise while loading, mutating, or saving an `OpenMW` configuration.
164#[derive(Debug)]
165#[non_exhaustive]
166pub enum ConfigError {
167    /// A content file (`content=`) appeared twice in the configuration chain.
168    DuplicateContentFile {
169        file: String,
170        config_path: PathBuf,
171        line: Option<usize>,
172    },
173    /// A fallback-archive (`fallback-archive=`) appeared twice in the configuration chain.
174    DuplicateArchiveFile {
175        file: String,
176        config_path: PathBuf,
177        line: Option<usize>,
178    },
179    /// [`OpenMWConfiguration::add_content_file`](crate::OpenMWConfiguration::add_content_file)
180    /// was called for a file that is already present.
181    CannotAddContentFile { file: String, config_path: PathBuf },
182    /// [`OpenMWConfiguration::add_archive_file`](crate::OpenMWConfiguration::add_archive_file)
183    /// was called for an archive that is already present.
184    CannotAddArchiveFile { file: String, config_path: PathBuf },
185    /// A groundcover file (`groundcover=`) appeared twice in the configuration chain.
186    DuplicateGroundcoverFile {
187        file: String,
188        config_path: PathBuf,
189        line: Option<usize>,
190    },
191    /// [`OpenMWConfiguration::add_groundcover_file`](crate::OpenMWConfiguration::add_groundcover_file)
192    /// was called for a file that is already present.
193    CannotAddGroundcoverFile { file: String, config_path: PathBuf },
194    /// A `fallback=` entry could not be parsed as a valid `Key,Value` pair.
195    InvalidGameSetting {
196        value: String,
197        config_path: PathBuf,
198        line: Option<usize>,
199    },
200    /// An `encoding=` entry contained an unrecognised encoding name.
201    /// Only `win1250`, `win1251`, and `win1252` are valid.
202    BadEncoding {
203        value: String,
204        config_path: PathBuf,
205        line: Option<usize>,
206    },
207    /// A line in an `openmw.cfg` file did not match any recognised `key=value` format.
208    InvalidLine {
209        value: String,
210        config_path: PathBuf,
211        line: Option<usize>,
212    },
213    /// An I/O error occurred while reading or writing a config file.
214    Io(std::io::Error),
215    /// The supplied path could not be classified as a file or directory.
216    NotFileOrDirectory(PathBuf),
217    /// No `openmw.cfg` was found at the given path.
218    CannotFind(PathBuf),
219    /// Root config discovery tried both local and global config candidates and found neither.
220    /// `global` is empty on platforms without a global config directory (Windows, macOS).
221    CannotFindRootConfig { local: PathBuf, global: PathBuf },
222    /// External-tool config discovery found neither root nor user config candidates. `global` is
223    /// empty on platforms without a global config directory (Windows, macOS).
224    CannotFindAnyConfig {
225        local: PathBuf,
226        global: PathBuf,
227        user: PathBuf,
228    },
229    /// The target path for a save operation is not writable.
230    NotWritable(PathBuf),
231    /// [`OpenMWConfiguration::save_subconfig`](crate::OpenMWConfiguration::save_subconfig)
232    /// was called with a path that is not part of the loaded configuration chain.
233    SubconfigNotLoaded(PathBuf),
234    /// The `config=` chain nests more than 16 levels below the root. A chain that loops does not
235    /// get here: a directory the chain already tried is skipped, as `OpenMW` skips it.
236    MaxDepthExceeded(PathBuf),
237    /// A platform default path could not be determined, because the environment or the
238    /// operating system gave none: `HOME` unset or empty, no Documents known folder, or a
239    /// platform `OpenMW` has no such path on. The string names the path: `home`, `documents`,
240    /// `config`, `userdata`, `local`, `global` or `global_config`.
241    PlatformPathUnavailable(&'static str),
242}
243
244fn line_suffix(line: Option<usize>) -> String {
245    line.map_or_else(String::new, |line| format!(" at line {line}"))
246}
247
248fn duplicate_message(file: &str, kind: &str, config_path: &Path, line: Option<usize>) -> String {
249    format!(
250        "{file} has appeared in the {kind} list twice. Its second occurence was in: {}{}",
251        config_path.display(),
252        line_suffix(line)
253    )
254}
255
256fn global_candidate(global: &Path) -> String {
257    if global.as_os_str().is_empty() {
258        "no global config path on this platform".to_string()
259    } else {
260        format!("global config path {}", global.display())
261    }
262}
263
264fn cannot_find_root_message(local: &Path, global: &Path) -> String {
265    format!(
266        "OpenMW root config discovery found no openmw.cfg at local path {}, and {}",
267        local.display(),
268        global_candidate(global)
269    )
270}
271
272fn cannot_find_any_message(local: &Path, global: &Path, user: &Path) -> String {
273    format!(
274        "OpenMW config discovery found no openmw.cfg at local path {}, {}, or user config path {}",
275        local.display(),
276        global_candidate(global),
277        user.display()
278    )
279}
280
281fn fmt_duplicate_or_add_error(
282    error: &ConfigError,
283    f: &mut fmt::Formatter<'_>,
284) -> Option<fmt::Result> {
285    match error {
286        ConfigError::DuplicateContentFile {
287            file,
288            config_path,
289            line,
290        } => Some(f.write_str(&duplicate_message(
291            file,
292            "content files",
293            config_path,
294            *line,
295        ))),
296        ConfigError::CannotAddContentFile { file, config_path } => Some(write!(
297            f,
298            "{file} cannot be added to the configuration map as a content file because it was already defined by: {}",
299            config_path.display()
300        )),
301        ConfigError::DuplicateGroundcoverFile {
302            file,
303            config_path,
304            line,
305        } => Some(f.write_str(&duplicate_message(file, "groundcover", config_path, *line))),
306        ConfigError::CannotAddGroundcoverFile { file, config_path } => Some(write!(
307            f,
308            "{file} cannot be added to the configuration map as a groundcover plugin because it was already defined by: {}",
309            config_path.display()
310        )),
311        ConfigError::DuplicateArchiveFile {
312            file,
313            config_path,
314            line,
315        } => Some(f.write_str(&duplicate_message(file, "BSA/Archive", config_path, *line))),
316        ConfigError::CannotAddArchiveFile { file, config_path } => Some(write!(
317            f,
318            "{file} cannot be added to the configuration map as a fallback-archive because it was already defined by: {}",
319            config_path.display()
320        )),
321        _ => None,
322    }
323}
324
325impl fmt::Display for ConfigError {
326    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327        if let Some(result) = fmt_duplicate_or_add_error(self, f) {
328            return result;
329        }
330
331        match self {
332            ConfigError::InvalidGameSetting {
333                value,
334                config_path,
335                line,
336            } => write!(
337                f,
338                "Invalid fallback setting '{}' in config file '{}'{}",
339                value,
340                config_path.display(),
341                line_suffix(*line)
342            ),
343            ConfigError::Io(e) => write!(f, "IO error: {e}"),
344            ConfigError::NotFileOrDirectory(config_path) => write!(
345                f,
346                "Unable to determine whether {} was a file or directory, refusing to read.",
347                config_path.display()
348            ),
349            ConfigError::CannotFind(config_path) => write!(
350                f,
351                "An openmw.cfg does not exist at: {}",
352                config_path.display()
353            ),
354            ConfigError::CannotFindRootConfig { local, global } => {
355                write!(f, "{}", cannot_find_root_message(local, global))
356            }
357            ConfigError::CannotFindAnyConfig {
358                local,
359                global,
360                user,
361            } => write!(f, "{}", cannot_find_any_message(local, global, user)),
362            ConfigError::DuplicateContentFile { .. }
363            | ConfigError::CannotAddContentFile { .. }
364            | ConfigError::DuplicateGroundcoverFile { .. }
365            | ConfigError::CannotAddGroundcoverFile { .. }
366            | ConfigError::DuplicateArchiveFile { .. }
367            | ConfigError::CannotAddArchiveFile { .. } => {
368                unreachable!("duplicate/add errors are handled before the main display match")
369            }
370            ConfigError::BadEncoding {
371                value,
372                config_path,
373                line,
374            } => write!(
375                f,
376                "Invalid encoding type: {value} in config file {}{}",
377                config_path.display(),
378                line_suffix(*line)
379            ),
380            ConfigError::InvalidLine {
381                value,
382                config_path,
383                line,
384            } => write!(
385                f,
386                "Invalid pair in openmw.cfg {value} was defined by {}{}",
387                config_path.display(),
388                line_suffix(*line)
389            ),
390            ConfigError::NotWritable(path) => {
391                write!(f, "Target path is not writable: {}", path.display())
392            }
393            ConfigError::SubconfigNotLoaded(path) => write!(
394                f,
395                "Cannot save to {}; it is not part of the loaded configuration chain",
396                path.display()
397            ),
398            ConfigError::MaxDepthExceeded(path) => write!(
399                f,
400                "Maximum config= nesting depth exceeded while loading {}",
401                path.display()
402            ),
403            ConfigError::PlatformPathUnavailable(kind) => {
404                write!(f, "Failed to resolve platform default {kind} path")
405            }
406        }
407    }
408}
409
410impl std::error::Error for ConfigError {
411    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
412        match self {
413            ConfigError::Io(error) => Some(error),
414            _ => None,
415        }
416    }
417}
418
419impl From<std::io::Error> for ConfigError {
420    fn from(err: std::io::Error) -> Self {
421        ConfigError::Io(err)
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn test_display_messages_include_key_context() {
431        let path = PathBuf::from("/tmp/openmw.cfg");
432
433        let cannot_find = ConfigError::CannotFind(path.clone()).to_string();
434        assert!(cannot_find.contains("openmw.cfg"));
435
436        let cannot_find_root = ConfigError::CannotFindRootConfig {
437            local: PathBuf::from("/tmp/local/openmw.cfg"),
438            global: PathBuf::from("/tmp/global/openmw.cfg"),
439        }
440        .to_string();
441        assert!(cannot_find_root.contains("/tmp/local/openmw.cfg"));
442        assert!(cannot_find_root.contains("/tmp/global/openmw.cfg"));
443
444        let without_global = ConfigError::CannotFindRootConfig {
445            local: PathBuf::from("/tmp/local/openmw.cfg"),
446            global: PathBuf::new(),
447        }
448        .to_string();
449        assert!(without_global.contains("/tmp/local/openmw.cfg"));
450        assert!(without_global.contains("no global config path on this platform"));
451
452        let cannot_find_any = ConfigError::CannotFindAnyConfig {
453            local: PathBuf::from("/tmp/local/openmw.cfg"),
454            global: PathBuf::from("/tmp/global/openmw.cfg"),
455            user: PathBuf::from("/tmp/user/openmw.cfg"),
456        }
457        .to_string();
458        assert!(cannot_find_any.contains("/tmp/local/openmw.cfg"));
459        assert!(cannot_find_any.contains("/tmp/global/openmw.cfg"));
460        assert!(cannot_find_any.contains("/tmp/user/openmw.cfg"));
461
462        let duplicate = ConfigError::DuplicateContentFile {
463            file: "Morrowind.esm".into(),
464            config_path: path.clone(),
465            line: None,
466        }
467        .to_string();
468        assert!(duplicate.contains("Morrowind.esm"));
469
470        let invalid_line = ConfigError::InvalidLine {
471            value: "broken".into(),
472            config_path: path,
473            line: Some(42),
474        }
475        .to_string();
476        assert!(invalid_line.contains("broken"));
477        assert!(invalid_line.contains("line 42"));
478    }
479
480    #[test]
481    fn test_io_error_is_the_source() {
482        use std::error::Error;
483
484        let error = ConfigError::Io(std::io::Error::other("boom"));
485        assert_eq!(
486            error.source().map(ToString::to_string),
487            Some("boom".to_string())
488        );
489        assert!(
490            ConfigError::CannotFind(PathBuf::from("openmw.cfg"))
491                .source()
492                .is_none()
493        );
494    }
495
496    #[test]
497    fn test_from_io_error_wraps_variant() {
498        let io = std::io::Error::other("boom");
499        let converted: ConfigError = io.into();
500        assert!(matches!(converted, ConfigError::Io(_)));
501    }
502}