Skip to main content

openmw_config/config/
directorysetting.rs

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