Skip to main content

openmw_config/config/
directorysetting.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2025 Dave Corley (S3kshun8)
3
4use crate::config::strings;
5use std::{fmt, path::PathBuf};
6
7/// A directory path entry from an `openmw.cfg` file (`data=`, `config=`, `user-data=`, etc.).
8///
9/// Stores both the *original* string exactly as it appeared in the file (for round-trip
10/// serialisation) and a *parsed* `PathBuf` with quotes stripped, token substitution applied
11/// (`?local?`, `?global?`, `?userdata?`, `?userconfig?`), and the path resolved relative to the
12/// config file's directory.
13#[derive(Debug, Clone)]
14pub struct DirectorySetting {
15    pub meta: crate::GameSettingMeta,
16    original: String,
17    parsed: PathBuf,
18}
19
20impl std::fmt::Display for DirectorySetting {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        writeln!(f, "{}", self.original)
23    }
24}
25
26impl crate::GameSetting for DirectorySetting {
27    fn meta(&self) -> &crate::GameSettingMeta {
28        &self.meta
29    }
30
31    fn meta_mut(&mut self) -> &mut crate::GameSettingMeta {
32        &mut self.meta
33    }
34}
35
36impl DirectorySetting {
37    /// Parses `value` as a directory path relative to `source_config`.
38    ///
39    /// Consumes the accumulated `comment` string (via [`std::mem::take`]) and stores it in the
40    /// setting's metadata so comments are preserved through serialisation.
41    pub fn new<S: Into<String>>(value: S, source_config: PathBuf, comment: &mut String) -> Self {
42        let original = value.into();
43        let parse_base = if source_config.file_name().is_some_and(|f| f == "openmw.cfg") {
44            source_config.parent().unwrap_or(source_config.as_path())
45        } else {
46            source_config.as_path()
47        };
48        let parsed = strings::parse_data_directory(&parse_base, &original);
49
50        let meta = crate::GameSettingMeta {
51            source_config,
52            comment: std::mem::take(comment),
53        };
54
55        Self {
56            meta,
57            original,
58            parsed,
59        }
60    }
61
62    /// The raw string exactly as it appeared in the `openmw.cfg` file, including any quotes.
63    ///
64    /// Use this when serialising back to `openmw.cfg` format to preserve the original style.
65    #[must_use]
66    pub fn original(&self) -> &String {
67        &self.original
68    }
69
70    /// Borrowed string view of [`Self::original`].
71    #[must_use]
72    pub fn original_str(&self) -> &str {
73        &self.original
74    }
75
76    /// The resolved, normalised path after quote-stripping, token substitution, and
77    /// relative-to-config-dir resolution.
78    ///
79    /// Use this when working with the filesystem.
80    #[must_use]
81    pub fn parsed(&self) -> &std::path::Path {
82        &self.parsed
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use std::path::PathBuf;
90
91    #[test]
92    fn test_directory_setting_basic_construction() {
93        let config_path = PathBuf::from("/my/config");
94        let mut comment = "some comment".to_string();
95
96        let setting = DirectorySetting::new("data", config_path.clone(), &mut comment);
97
98        assert_eq!(setting.original, "data");
99        assert_eq!(setting.parsed, config_path.join("data"));
100        assert_eq!(setting.meta.source_config, config_path);
101        assert_eq!(setting.meta.comment, "some comment");
102        assert!(comment.is_empty()); // Should have been cleared
103    }
104
105    #[test]
106    fn test_directory_setting_with_user_data_token() {
107        let config_path = PathBuf::from("/irrelevant");
108        let mut comment = String::new();
109
110        let setting = DirectorySetting::new("?userdata?/foo", config_path, &mut comment);
111
112        let expected_prefix = crate::default_userdata_path();
113        assert!(setting.parsed.starts_with(expected_prefix));
114        assert!(setting.parsed.ends_with("foo/"));
115    }
116
117    #[test]
118    fn test_directory_setting_with_user_config_token() {
119        let config_path = PathBuf::from("/config/dir");
120        let mut comment = String::new();
121
122        let setting = DirectorySetting::new("?userconfig?/bar", config_path, &mut comment);
123        dbg!(setting.parsed());
124
125        let expected_prefix = crate::default_config_path();
126        assert!(setting.parsed.starts_with(expected_prefix));
127        assert!(setting.parsed.ends_with("bar"));
128    }
129
130    #[test]
131    fn test_directory_setting_quoted_path() {
132        let config_path = PathBuf::from("/my/config");
133        let mut comment = String::new();
134
135        let setting =
136            DirectorySetting::new("\"path/with spaces\"", config_path.clone(), &mut comment);
137
138        assert_eq!(setting.original, "\"path/with spaces\"");
139        assert_eq!(setting.parsed, config_path.join("path").join("with spaces"));
140    }
141
142    #[test]
143    fn test_directory_setting_relative_path_normalization() {
144        let config_path = PathBuf::from("/my/config");
145        let mut comment = String::new();
146
147        let setting = DirectorySetting::new("subdir\\nested", config_path.clone(), &mut comment);
148
149        let expected = config_path.join("subdir").join("nested");
150        assert_eq!(setting.parsed, expected);
151    }
152
153    fn mock_path(path: &str) -> PathBuf {
154        PathBuf::from(path)
155    }
156
157    #[test]
158    fn test_dot_component_is_removed() {
159        let config = mock_path("/etc/openmw");
160        let mut comment = String::from("comment");
161        let setting = DirectorySetting::new("./data", config.clone(), &mut comment);
162        assert_eq!(setting.parsed(), &config.join("data"));
163    }
164
165    #[test]
166    fn test_double_dot_not_normalized() {
167        // OpenMW does not normalize .. — the raw joined path is preserved
168        let config = mock_path("/home/user/.config/openmw");
169        let mut comment = String::from("comment");
170        let setting = DirectorySetting::new("../common", config.clone(), &mut comment);
171        let expected = config.join("../common");
172        assert_eq!(setting.parsed(), &expected);
173    }
174
175    #[test]
176    fn test_dot_components_not_normalized() {
177        // OpenMW does not normalize . or .. in the middle of a path
178        let config = mock_path("/opt/game/config");
179        let mut comment = String::new();
180        let setting = DirectorySetting::new("foo/./bar/../baz", config.clone(), &mut comment);
181        let expected = config.join("foo/./bar/../baz");
182        assert_eq!(setting.parsed(), &expected);
183    }
184
185    // --- Absolute paths ---
186
187    #[test]
188    fn test_absolute_path_not_joined_to_config() {
189        // An absolute value must not be prepended with the config dir
190        let config = mock_path("/etc/openmw");
191        let mut comment = String::new();
192        let setting = DirectorySetting::new("/absolute/path/to/data", config, &mut comment);
193        assert_eq!(setting.parsed(), &PathBuf::from("/absolute/path/to/data"));
194    }
195
196    #[test]
197    fn test_absolute_path_original_preserved() {
198        let config = mock_path("/etc/openmw");
199        let mut comment = String::new();
200        let setting = DirectorySetting::new("/absolute/data", config, &mut comment);
201        assert_eq!(setting.original(), "/absolute/data");
202        assert_eq!(setting.original_str(), "/absolute/data");
203    }
204
205    // --- Backslash / separator normalisation ---
206
207    #[test]
208    fn test_backslash_normalised_to_separator() {
209        // Backslashes in values must be converted to the platform separator
210        let config = mock_path("/my/config");
211        let mut comment = String::new();
212        let setting = DirectorySetting::new("subdir\\nested\\leaf", config.clone(), &mut comment);
213        let expected = config.join("subdir").join("nested").join("leaf");
214        assert_eq!(setting.parsed(), &expected);
215    }
216
217    #[test]
218    fn test_mixed_separators_normalised() {
219        let config = mock_path("/my/config");
220        let mut comment = String::new();
221        let setting = DirectorySetting::new("a\\b/c", config.clone(), &mut comment);
222        let expected = config.join("a").join("b").join("c");
223        assert_eq!(setting.parsed(), &expected);
224    }
225
226    // --- Quote handling ---
227
228    #[test]
229    fn test_quoted_path_stripped_of_quotes() {
230        let config = mock_path("/cfg");
231        let mut comment = String::new();
232        let setting = DirectorySetting::new("\"simple\"", config.clone(), &mut comment);
233        assert_eq!(setting.parsed(), &config.join("simple"));
234    }
235
236    #[test]
237    fn test_quoted_path_ampersand_escapes_next_char() {
238        // & inside quotes escapes the following character (OpenMW quote escape rule)
239        let config = mock_path("/cfg");
240        let mut comment = String::new();
241        // "&"" inside the quoted string should yield a literal "
242        let setting = DirectorySetting::new("\"foo&\"bar\"", config.clone(), &mut comment);
243        assert_eq!(setting.parsed(), &config.join("foo\"bar"));
244    }
245
246    #[test]
247    fn test_quoted_path_ampersand_escapes_ampersand() {
248        let config = mock_path("/cfg");
249        let mut comment = String::new();
250        let setting = DirectorySetting::new("\"foo&&bar\"", config.clone(), &mut comment);
251        assert_eq!(setting.parsed(), &config.join("foo&bar"));
252    }
253
254    #[test]
255    fn test_original_preserves_quotes() {
256        // original() must round-trip back exactly as it appeared in openmw.cfg
257        let config = mock_path("/cfg");
258        let mut comment = String::new();
259        let setting = DirectorySetting::new("\"path with spaces\"", config, &mut comment);
260        assert_eq!(setting.original(), "\"path with spaces\"");
261    }
262
263    // --- Token expansion ---
264
265    #[test]
266    fn test_userdata_token_only() {
267        let config = mock_path("/irrelevant");
268        let mut comment = String::new();
269        let setting = DirectorySetting::new("?userdata?", config, &mut comment);
270        // With no suffix, should resolve exactly to the userdata base dir
271        assert_eq!(setting.parsed(), &crate::default_userdata_path());
272    }
273
274    #[test]
275    fn test_userconfig_token_only() {
276        let config = mock_path("/irrelevant");
277        let mut comment = String::new();
278        let setting = DirectorySetting::new("?userconfig?", config, &mut comment);
279        assert_eq!(setting.parsed(), &crate::default_config_path());
280    }
281
282    #[test]
283    fn test_userdata_token_with_nested_path() {
284        let config = mock_path("/irrelevant");
285        let mut comment = String::new();
286        let setting = DirectorySetting::new("?userdata?/saves/slot1", config, &mut comment);
287        let expected = crate::default_userdata_path().join("saves").join("slot1");
288        assert_eq!(setting.parsed(), &expected);
289    }
290
291    #[test]
292    fn test_local_token_only() {
293        let config = mock_path("/irrelevant");
294        let mut comment = String::new();
295        let setting = DirectorySetting::new("?local?", config, &mut comment);
296        assert_eq!(setting.parsed(), &crate::default_local_path());
297    }
298
299    #[test]
300    fn test_local_token_with_nested_path() {
301        let config = mock_path("/irrelevant");
302        let mut comment = String::new();
303        let setting = DirectorySetting::new("?local?/mods/common", config, &mut comment);
304        let expected = crate::default_local_path().join("mods").join("common");
305        assert_eq!(setting.parsed(), &expected);
306    }
307
308    #[test]
309    #[cfg(not(windows))]
310    fn test_global_token_only_on_supported_platforms() {
311        let config = mock_path("/irrelevant");
312        let mut comment = String::new();
313        let setting = DirectorySetting::new("?global?", config, &mut comment);
314        assert_eq!(setting.parsed(), &crate::default_global_path());
315    }
316
317    #[test]
318    #[cfg(not(windows))]
319    fn test_global_token_with_nested_path_on_supported_platforms() {
320        let config = mock_path("/irrelevant");
321        let mut comment = String::new();
322        let setting = DirectorySetting::new("?global?/openmw", config, &mut comment);
323        let expected = crate::default_global_path().join("openmw");
324        assert_eq!(setting.parsed(), &expected);
325    }
326
327    #[test]
328    #[cfg(windows)]
329    fn test_global_token_is_left_unexpanded_on_windows() {
330        let config = mock_path(r"C:\OpenMW");
331        let mut comment = String::new();
332        let setting = DirectorySetting::new("?global?/data", config.clone(), &mut comment);
333        let expected = config.join("?global?").join("data");
334        assert_eq!(setting.parsed(), &expected);
335    }
336
337    // --- Meta / comment handling ---
338
339    #[test]
340    fn test_source_config_stored_verbatim() {
341        let config = mock_path("/etc/openmw/openmw.cfg");
342        let mut comment = String::new();
343        let setting = DirectorySetting::new("data", config.clone(), &mut comment);
344        assert_eq!(setting.meta.source_config, config);
345    }
346
347    #[test]
348    fn test_comment_cleared_after_new() {
349        let config = mock_path("/etc/openmw");
350        let mut comment = String::from("# a comment\n");
351        let setting = DirectorySetting::new("data", config, &mut comment);
352        assert_eq!(setting.meta.comment, "# a comment\n");
353        assert!(
354            comment.is_empty(),
355            "comment should be cleared after construction"
356        );
357    }
358
359    #[test]
360    fn test_empty_comment_stays_empty() {
361        let config = mock_path("/etc/openmw");
362        let mut comment = String::new();
363        let setting = DirectorySetting::new("data", config, &mut comment);
364        assert!(setting.meta.comment.is_empty());
365    }
366}