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