1use std::collections::BTreeSet;
4use std::fs::{self, OpenOptions};
5use std::io::{self, ErrorKind, Write};
6#[cfg(windows)]
7use std::os::windows::ffi::OsStrExt;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use openmw_config::{EncodingSetting, OpenMWConfiguration};
12
13use crate::{ImportError, MultiMap};
14
15static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
16
17pub fn serialize_resolved_cfg(
22 cfg: &MultiMap,
23 user_config_dir: &Path,
24) -> Result<String, ImportError> {
25 Ok(serialize_resolved_configuration(
26 &configuration_from_multimap_resolved(cfg, user_config_dir)?,
27 ))
28}
29
30pub fn save_resolved_cfg_to_path(cfg: &MultiMap, output_path: &Path) -> Result<(), ImportError> {
36 let user_config_dir = output_path.parent().unwrap_or_else(|| Path::new(""));
37 save_resolved_configuration_to_path(
38 &configuration_from_multimap_resolved(cfg, user_config_dir)?,
39 output_path,
40 )
41}
42
43pub fn save_resolved_configuration_to_path(
49 config: &OpenMWConfiguration,
50 output_path: &Path,
51) -> Result<(), ImportError> {
52 write_atomic(
53 output_path,
54 serialize_resolved_configuration(config).as_bytes(),
55 )?;
56 Ok(())
57}
58
59pub fn save_preserved_cfg_document_to_path(
64 config: &OpenMWConfiguration,
65 source_path: &Path,
66 output_path: &Path,
67 update: &PreservedCfgUpdate,
68 changed_keys: &BTreeSet<String>,
69) -> Result<(), ImportError> {
70 let write_path = write_target_path(output_path);
71 write_atomic(
72 &write_path,
73 serialize_preserved_cfg_document(config, source_path, update, changed_keys).as_bytes(),
74 )?;
75 Ok(())
76}
77
78fn write_target_path(output_path: &Path) -> PathBuf {
79 fs::canonicalize(output_path).unwrap_or_else(|_| output_path.to_owned())
80}
81
82#[must_use]
83pub fn serialize_preserved_cfg_document(
84 config: &OpenMWConfiguration,
85 source_path: &Path,
86 update: &PreservedCfgUpdate,
87 changed_keys: &BTreeSet<String>,
88) -> String {
89 let source_path = source_path.to_path_buf();
90 let canonical_source_path = fs::canonicalize(&source_path).ok();
91 let mut write_keys = changed_keys.clone();
92 if update.data_local.is_some() {
93 write_keys.insert("data-local".to_owned());
94 }
95 if update.resources.is_some() {
96 write_keys.insert("resources".to_owned());
97 }
98 if update.user_data.is_some() {
99 write_keys.insert("user-data".to_owned());
100 }
101 let user_config_path = config.user_config_path().join("openmw.cfg");
102 let mut document = String::new();
103 for setting in config.settings_matching(|setting| {
104 let source = setting.meta().source_config();
105 source == source_path.as_path()
106 || canonical_source_path
107 .as_deref()
108 .is_some_and(|canonical_source_path| source == canonical_source_path)
109 || (source == user_config_path
110 && setting_key(setting).is_some_and(|key| write_keys.contains(&key)))
111 }) {
112 document.push_str(&setting.to_string());
113 }
114 document
115}
116
117fn setting_key(setting: &impl ToString) -> Option<String> {
118 let text = setting.to_string();
119 text.lines()
120 .last()?
121 .split_once('=')
122 .map(|(key, _)| key.to_owned())
123}
124
125#[derive(Debug, Clone)]
127pub struct PreservedCfgUpdate {
128 pub import_game_files: bool,
129 pub import_archives: bool,
130 pub data_local: Option<PathBuf>,
131 pub resources: Option<PathBuf>,
132 pub user_data: Option<PathBuf>,
133}
134
135pub fn load_cfg_document(path: &Path) -> Result<OpenMWConfiguration, ImportError> {
140 OpenMWConfiguration::load_optional(path).map_err(|error| config_error(&error))
141}
142
143pub fn serialize_cfg_output(cfg: &MultiMap, user_config_dir: &Path) -> Result<String, ImportError> {
148 Ok(configuration_from_multimap_preserving(cfg, user_config_dir)?.to_string())
149}
150
151pub fn save_cfg_output_to_path(cfg: &MultiMap, output_path: &Path) -> Result<(), ImportError> {
157 let user_config_dir = output_path.parent().unwrap_or_else(|| Path::new(""));
158 write_atomic(
159 output_path,
160 configuration_from_multimap_preserving(cfg, user_config_dir)?
161 .to_string()
162 .as_bytes(),
163 )?;
164 Ok(())
165}
166
167pub fn apply_preserved_cfg_update(
173 config: &mut OpenMWConfiguration,
174 imported_cfg: &MultiMap,
175 update: &PreservedCfgUpdate,
176 changed_keys: &BTreeSet<String>,
177) -> Result<(), ImportError> {
178 if changed_keys.contains("encoding")
179 && let Some(encoding) = imported_cfg
180 .get("encoding")
181 .and_then(|values| values.last())
182 {
183 set_encoding(config, encoding)?;
184 }
185 if changed_keys.contains("no-sound") {
186 config.set_generic_settings("no-sound", imported_cfg.get("no-sound").cloned());
187 }
188 if changed_keys.contains("fallback") {
189 config
190 .set_game_settings(imported_cfg.get("fallback").cloned())
191 .map_err(|error| config_error(&error))?;
192 }
193
194 if changed_keys.contains("data") {
195 for data_dir in imported_cfg.get("data").into_iter().flatten() {
196 if !config.has_data_dir(data_dir) {
197 config.add_data_directory(Path::new(data_dir));
198 }
199 }
200 }
201
202 if update.import_game_files && changed_keys.contains("content") {
203 config.set_content_files(imported_cfg.get("content").cloned());
204 }
205 if update.import_archives && changed_keys.contains("fallback-archive") {
206 config.set_fallback_archives(imported_cfg.get("fallback-archive").cloned());
207 }
208 if let Some(path) = &update.data_local {
209 clear_preserved_key(config, "data-local");
210 config.set_data_local_path(path);
211 }
212 if let Some(path) = &update.resources {
213 clear_preserved_key(config, "resources");
214 config.set_resources_path(path);
215 }
216 if let Some(path) = &update.user_data {
217 clear_preserved_key(config, "user-data");
218 config.set_user_data_path(path);
219 }
220
221 Ok(())
222}
223
224pub(crate) fn load_resolved_cfg(path: &Path) -> Result<MultiMap, ImportError> {
225 let config = OpenMWConfiguration::load_optional(path).map_err(|error| config_error(&error))?;
226 let mut cfg = crate::parse_cfg_str(&config.to_resolved_string());
227 remove_composed_non_import_data_dirs(&mut cfg);
228 Ok(cfg)
229}
230
231pub(crate) fn normalize_cfg(
232 cfg: &MultiMap,
233 user_config_dir: Option<&Path>,
234) -> Result<MultiMap, ImportError> {
235 let Some(user_config_dir) = user_config_dir else {
236 return Ok(cfg.clone());
237 };
238 let mut cfg = crate::parse_cfg_str(
239 &configuration_from_multimap_resolved(cfg, user_config_dir)?.to_resolved_string(),
240 );
241 remove_composed_non_import_data_dirs(&mut cfg);
242 Ok(cfg)
243}
244
245#[must_use]
246pub fn serialize_resolved_configuration(config: &OpenMWConfiguration) -> String {
247 let mut cfg = crate::parse_cfg_str(&config.to_resolved_string());
248 remove_composed_non_import_data_dirs(&mut cfg);
249 crate::serialize_cfg(&cfg)
250}
251
252fn configuration_from_multimap_preserving(
253 cfg: &MultiMap,
254 user_config_dir: &Path,
255) -> Result<OpenMWConfiguration, ImportError> {
256 let user_config_dir = effective_user_config_dir(user_config_dir);
257 let mut config =
258 OpenMWConfiguration::new_empty(&user_config_dir).map_err(|error| config_error(&error))?;
259
260 for (key, values) in cfg {
261 match key.as_str() {
262 "data" => config.set_data_directories(Some(paths(values))),
263 "data-local" | "resources" | "user-data" => {
264 config.set_generic_settings(key, Some(values.clone()));
265 }
266 "content" => config.set_content_files(Some(values.clone())),
267 "fallback-archive" => config.set_fallback_archives(Some(values.clone())),
268 "fallback" => config
269 .set_game_settings(Some(values.clone()))
270 .map_err(|error| config_error(&error))?,
271 other => config.set_generic_settings(other, Some(values.clone())),
272 }
273 }
274
275 Ok(config)
276}
277
278fn configuration_from_multimap_resolved(
279 cfg: &MultiMap,
280 user_config_dir: &Path,
281) -> Result<OpenMWConfiguration, ImportError> {
282 let user_config_dir = effective_user_config_dir(user_config_dir);
283 let mut config =
284 OpenMWConfiguration::new_empty(&user_config_dir).map_err(|error| config_error(&error))?;
285
286 for (key, values) in cfg {
287 match key.as_str() {
288 "data" => config.set_data_directories(Some(paths(values))),
289 "data-local" => set_last_path(values, |path| config.set_data_local_path(path)),
290 "resources" => set_last_path(values, |path| config.set_resources_path(path)),
291 "user-data" => set_last_path(values, |path| config.set_user_data_path(path)),
292 "content" => config.set_content_files(Some(values.clone())),
293 "fallback-archive" => config.set_fallback_archives(Some(values.clone())),
294 "fallback" => config
295 .set_game_settings(Some(values.clone()))
296 .map_err(|error| config_error(&error))?,
297 other => config.set_generic_settings(other, Some(values.clone())),
298 }
299 }
300
301 Ok(config)
302}
303
304fn effective_user_config_dir(path: &Path) -> PathBuf {
305 if path.as_os_str().is_empty() {
306 return std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
307 }
308
309 path.to_owned()
310}
311
312fn paths(values: &[String]) -> Vec<PathBuf> {
313 values.iter().map(PathBuf::from).collect()
314}
315
316fn set_last_path<F>(values: &[String], mut set: F)
317where
318 F: FnMut(&Path),
319{
320 if let Some(value) = values.last() {
321 set(Path::new(value));
322 }
323}
324
325fn set_encoding(config: &mut OpenMWConfiguration, encoding: &str) -> Result<(), ImportError> {
326 clear_preserved_key(config, "encoding");
327 let cfg_path = config.user_config_path().join("openmw.cfg");
328 let mut comment = String::new();
329 let setting = EncodingSetting::try_from((encoding.to_owned(), cfg_path, &mut comment))
330 .map_err(|error| config_error(&error))?;
331 config.set_encoding(Some(setting));
332 Ok(())
333}
334
335fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), ImportError> {
336 let parent = path
337 .parent()
338 .filter(|parent| !parent.as_os_str().is_empty())
339 .unwrap_or_else(|| Path::new("."));
340
341 for _ in 0..16 {
342 let temp_path = temporary_path_for(path);
343 let file = match OpenOptions::new()
344 .write(true)
345 .create_new(true)
346 .open(&temp_path)
347 {
348 Ok(file) => file,
349 Err(error) if error.kind() == ErrorKind::AlreadyExists => continue,
350 Err(source) => {
351 return Err(ImportError::Io {
352 path: path.to_owned(),
353 source,
354 });
355 }
356 };
357
358 if let Err(source) = finish_atomic_write(path, parent, &temp_path, file, bytes) {
359 let _ = fs::remove_file(&temp_path);
360 return Err(ImportError::Io {
361 path: path.to_owned(),
362 source,
363 });
364 }
365
366 return Ok(());
367 }
368
369 Err(ImportError::Io {
370 path: path.to_owned(),
371 source: io::Error::new(
372 ErrorKind::AlreadyExists,
373 "could not create a unique temporary cfg file",
374 ),
375 })
376}
377
378fn finish_atomic_write(
379 path: &Path,
380 parent: &Path,
381 temp_path: &Path,
382 mut file: fs::File,
383 bytes: &[u8],
384) -> io::Result<()> {
385 if let Ok(metadata) = fs::metadata(path) {
386 file.set_permissions(metadata.permissions())?;
390 }
391 file.write_all(bytes)?;
392 file.sync_all()?;
393 drop(file);
394 replace_file(temp_path, path)?;
395 sync_parent_dir(parent)
396}
397
398#[cfg(not(windows))]
399fn replace_file(source: &Path, destination: &Path) -> io::Result<()> {
400 fs::rename(source, destination)
401}
402
403#[cfg(windows)]
404fn replace_file(source: &Path, destination: &Path) -> io::Result<()> {
405 const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
406 const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
407
408 unsafe extern "system" {
409 fn MoveFileExW(
410 existing_file_name: *const u16,
411 new_file_name: *const u16,
412 flags: u32,
413 ) -> i32;
414 }
415
416 let source = wide_null(source);
417 let destination = wide_null(destination);
418 let result = unsafe {
419 MoveFileExW(
420 source.as_ptr(),
421 destination.as_ptr(),
422 MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
423 )
424 };
425 if result == 0 {
426 Err(io::Error::last_os_error())
427 } else {
428 Ok(())
429 }
430}
431
432#[cfg(windows)]
433fn wide_null(path: &Path) -> Vec<u16> {
434 path.as_os_str().encode_wide().chain([0]).collect()
435}
436
437fn temporary_path_for(path: &Path) -> PathBuf {
438 let counter = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
439 let file_name = path
440 .file_name()
441 .and_then(|name| name.to_str())
442 .unwrap_or("openmw.cfg");
443 let temp_name = format!(
444 ".{file_name}.dream-ini-{}-{counter}.tmp",
445 std::process::id()
446 );
447 path.with_file_name(temp_name)
448}
449
450#[cfg(unix)]
451fn sync_parent_dir(parent: &Path) -> io::Result<()> {
452 fs::File::open(parent)?.sync_all()
453}
454
455#[cfg(not(unix))]
456fn sync_parent_dir(_parent: &Path) -> io::Result<()> {
457 Ok(())
458}
459
460fn clear_preserved_key(config: &mut OpenMWConfiguration, key: &str) {
461 let prefix = format!("{key}=");
462 config.clear_matching(|setting| {
463 setting
464 .to_string()
465 .lines()
466 .last()
467 .is_some_and(|line| line.starts_with(&prefix))
468 });
469}
470
471fn remove_composed_non_import_data_dirs(cfg: &mut MultiMap) {
472 remove_composed_data_dir(cfg, "data-local", Path::to_owned);
476 remove_composed_data_dir(cfg, "resources", |path| path.join("vfs"));
477}
478
479fn remove_composed_data_dir<F>(cfg: &mut MultiMap, key: &str, mut composed_path: F)
480where
481 F: FnMut(&Path) -> PathBuf,
482{
483 let Some(value) = cfg.get(key).and_then(|values| values.last()) else {
484 return;
485 };
486 let composed = composed_path(Path::new(value))
487 .to_string_lossy()
488 .into_owned();
489
490 if let Some(data_dirs) = cfg.get_mut("data") {
491 data_dirs.retain(|data_dir| data_dir != &composed);
492 }
493}
494
495fn config_error(error: &openmw_config::ConfigError) -> ImportError {
496 ImportError::OpenMwConfig(error.to_string())
497}