use std::fs;
use std::path::{Path, PathBuf};
use crate::test_support::{unique_test_dir, values};
use crate::{
ImportError, ImportOptions, ImportWarning, IniImporter, MultiMap, parse_cfg_str, parse_ini_str,
save_resolved_cfg_to_path, serialize_cfg, serialize_resolved_cfg,
};
#[test]
fn imports_merge_fallback_and_archives() {
let dir = unique_test_dir("merge-fallback-archives");
create_archives(&dir, &["Morrowind.bsa", "Tribunal.bsa", "Bloodmoon.bsa"]);
let ini_path = dir.join("Morrowind.ini");
let importer = IniImporter::new(ImportOptions::default());
let mut cfg = parse_cfg_str("no-sound=0\nfallback=old\n");
let ini = parse_ini_str(
"[General]\nDisable Audio=1\nDisable Audio=0\n[Fonts]\nFont 0=magic\n[Archives]\nArchive 0=Tribunal.bsa\nArchive 1=Bloodmoon.bsa\n[Movies]\nNew Game=intro.bik\n",
);
let result = importer.import_maps(&mut cfg, &ini, &ini_path).unwrap();
assert_eq!(values(&cfg, "no-sound"), &["0".to_owned()]);
assert_eq!(
values(&cfg, "fallback-archive"),
&[
"Morrowind.bsa".to_owned(),
"Tribunal.bsa".to_owned(),
"Bloodmoon.bsa".to_owned()
]
);
assert_eq!(
values(&cfg, "fallback"),
&["Movies_New_Game,intro.bik".to_owned()]
);
assert!(result.warnings.is_empty());
assert_eq!(result.events.len(), 1);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn resolved_cfg_serialization_resolves_singleton_directories() {
let dir = unique_test_dir("resolved-singletons");
fs::create_dir_all(&dir).unwrap();
let cfg = parse_cfg_str("data-local=local\nresources=resources\nuser-data=user\n");
let written = serialize_resolved_cfg(&cfg, &dir).unwrap();
assert!(written.contains(&format!("data-local={}\n", dir.join("local").display())));
assert!(written.contains(&format!("resources={}\n", dir.join("resources").display())));
assert!(written.contains(&format!("user-data={}\n", dir.join("user").display())));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn resolved_cfg_serialization_does_not_persist_composed_resource_vfs_data_dir() {
let dir = unique_test_dir("resolved-resource-vfs");
let resources = dir.join("resources");
fs::create_dir_all(resources.join("vfs")).unwrap();
let cfg = parse_cfg_str(&format!("resources={}\n", resources.display()));
let written = serialize_resolved_cfg(&cfg, &dir).unwrap();
assert!(written.contains(&format!("resources={}\n", resources.display())));
assert!(!written.contains(&format!("data={}\n", resources.join("vfs").display())));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn resolved_cfg_serialization_does_not_persist_composed_data_local_data_dir() {
let dir = unique_test_dir("resolved-data-local");
let local = dir.join("local");
fs::create_dir_all(&local).unwrap();
let cfg = parse_cfg_str(&format!("data-local={}\n", local.display()));
let written = serialize_resolved_cfg(&cfg, &dir).unwrap();
assert!(written.contains(&format!("data-local={}\n", local.display())));
assert!(!written.contains(&format!("data={}\n", local.display())));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn save_resolved_cfg_replaces_existing_file_and_cleans_temp_file() {
let dir = unique_test_dir("atomic-resolved-save");
let resources = dir.join("resources");
fs::create_dir_all(resources.join("vfs")).unwrap();
let output = dir.join("openmw.cfg");
fs::write(&output, "stale=true\n").unwrap();
let cfg = parse_cfg_str(&format!("resources={}\n", resources.display()));
save_resolved_cfg_to_path(&cfg, &output).unwrap();
let written = fs::read_to_string(&output).unwrap();
assert!(!written.contains("stale=true\n"));
assert!(written.contains(&format!("resources={}\n", resources.display())));
assert!(!written.contains(&format!("data={}\n", resources.join("vfs").display())));
let temp_entries = fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.file_name().to_string_lossy().contains(".dream-ini-"))
.count();
assert_eq!(temp_entries, 0);
fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn save_resolved_cfg_preserves_existing_file_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = unique_test_dir("atomic-resolved-save-permissions");
fs::create_dir_all(&dir).unwrap();
let output = dir.join("openmw.cfg");
fs::write(&output, "stale=true\n").unwrap();
fs::set_permissions(&output, fs::Permissions::from_mode(0o600)).unwrap();
let cfg = parse_cfg_str("encoding=win1252\n");
save_resolved_cfg_to_path(&cfg, &output).unwrap();
let mode = fs::metadata(&output).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn font_import_is_option_gated() {
let ini = parse_ini_str("[Fonts]\nFont 0=magic\n[Movies]\nNew Game=intro.bik\n");
let mut cfg = MultiMap::new();
let importer = IniImporter::new(ImportOptions {
import_archives: false,
..ImportOptions::default()
});
let result = importer
.import_maps(&mut cfg, &ini, Path::new("Morrowind.ini"))
.unwrap();
assert_eq!(
values(&cfg, "fallback"),
&["Movies_New_Game,intro.bik".to_owned()]
);
assert!(result.events.is_empty());
let mut cfg = MultiMap::new();
let importer = IniImporter::new(ImportOptions {
import_fonts: true,
import_archives: false,
..ImportOptions::default()
});
let result = importer
.import_maps(&mut cfg, &ini, Path::new("Morrowind.ini"))
.unwrap();
assert_eq!(
values(&cfg, "fallback"),
&[
"Fonts_Font_0,magic".to_owned(),
"Movies_New_Game,intro.bik".to_owned()
]
);
assert!(result.events.is_empty());
}
#[test]
fn archive_import_stops_at_first_missing_index() {
let dir = unique_test_dir("archive-gap");
create_archives(&dir, &["Morrowind.bsa", "First.bsa"]);
let ini_path = dir.join("Morrowind.ini");
let ini = parse_ini_str("[Archives]\nArchive 0=First.bsa\nArchive 2=Skipped.bsa\n");
let mut cfg = MultiMap::new();
let importer = IniImporter::new(ImportOptions::default());
importer.import_maps(&mut cfg, &ini, &ini_path).unwrap();
assert_eq!(
values(&cfg, "fallback-archive"),
&["Morrowind.bsa".to_owned(), "First.bsa".to_owned()]
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn archive_import_writes_data_dir_used_to_resolve_archives() {
let dir = unique_test_dir("archive-data-dir");
create_archives(&dir, &["Morrowind.bsa", "Tribunal.bsa"]);
let ini_path = dir.join("Morrowind.ini");
let ini = parse_ini_str("[Archives]\nArchive 0=Tribunal.bsa\n");
let mut cfg = MultiMap::new();
let importer = IniImporter::new(ImportOptions::default());
importer.import_maps(&mut cfg, &ini, &ini_path).unwrap();
assert_eq!(
values(&cfg, "data"),
&[dir.join("Data Files").display().to_string()]
);
assert_eq!(
values(&cfg, "fallback-archive"),
&["Morrowind.bsa".to_owned(), "Tribunal.bsa".to_owned()]
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn missing_archive_import_leaves_cfg_unchanged() {
let dir = unique_test_dir("missing-archive");
create_archives(&dir, &["Morrowind.bsa"]);
let ini_path = dir.join("Morrowind.ini");
let ini = parse_ini_str("[Archives]\nArchive 0=Tribunal.bsa\n");
let mut cfg = parse_cfg_str("fallback-archive=old.bsa\n");
let importer = IniImporter::new(ImportOptions::default());
let error = importer.import_maps(&mut cfg, &ini, &ini_path).unwrap_err();
match error {
ImportError::MissingArchives { files, .. } => {
assert_eq!(files, vec!["Tribunal.bsa".to_owned()]);
}
other => panic!("unexpected error: {other}"),
}
assert_eq!(values(&cfg, "fallback-archive"), &["old.bsa".to_owned()]);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn import_paths_preserves_existing_cfg_and_writes_imports() {
let dir = unique_test_dir("path-import");
fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("openmw.cfg");
let ini = dir.join("Morrowind.ini");
create_archives(&dir, &["Morrowind.bsa", "Tribunal.bsa"]);
let output = dir.join("imported.cfg");
fs::write(
&cfg,
"no-sound=0\nfallback=Old_Setting,old\nencoding=win1252\n",
)
.unwrap();
fs::write(
&ini,
"[General]\nDisable Audio=1\n[Movies]\nNew Game=intro.bik\n[Archives]\nArchive 0=Tribunal.bsa\n",
)
.unwrap();
let importer = IniImporter::new(ImportOptions::default());
let result = importer.import_paths(&ini, &cfg).unwrap();
assert_eq!(values(&result.cfg, "no-sound"), &["1".to_owned()]);
assert_eq!(values(&result.cfg, "encoding"), &["win1252".to_owned()]);
assert_eq!(
values(&result.cfg, "fallback"),
&["Movies_New_Game,intro.bik".to_owned()]
);
assert_eq!(
values(&result.cfg, "fallback-archive"),
&["Morrowind.bsa".to_owned(), "Tribunal.bsa".to_owned()]
);
fs::write(&output, serialize_cfg(&result.cfg)).unwrap();
let written = fs::read_to_string(&output).unwrap();
assert!(written.contains("no-sound=1"));
assert!(written.contains("fallback=Movies_New_Game,intro.bik"));
assert!(written.contains("fallback-archive=Morrowind.bsa"));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn import_paths_writes_exact_golden_output() {
let dir = unique_test_dir("golden-output");
fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("openmw.cfg");
let ini = dir.join("Morrowind.ini");
create_archives(&dir, &["Morrowind.bsa", "Tribunal.bsa"]);
fs::write(
&cfg,
"resources=resources\nno-sound=0\nfallback=Old_Setting,old\n",
)
.unwrap();
fs::write(
&ini,
"[General]\nDisable Audio=1\n[Movies]\nNew Game=intro.bik\n[Archives]\nArchive 0=Tribunal.bsa\n",
)
.unwrap();
let importer = IniImporter::new(ImportOptions::default());
let result = importer.import_paths(&ini, &cfg).unwrap();
assert_eq!(
serialize_cfg(&result.cfg),
format!(
concat!(
"data={}\n",
"encoding=win1252\n",
"fallback=Movies_New_Game,intro.bik\n",
"fallback-archive=Morrowind.bsa\n",
"fallback-archive=Tribunal.bsa\n",
"no-sound=1\n",
"resources={}\n",
),
dir.join("Data Files").display(),
dir.join("resources").display()
)
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn import_paths_absolutizes_existing_cfg_directory_paths() {
let dir = unique_test_dir("absolutize-cfg-directories");
fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("openmw.cfg");
let ini = dir.join("Morrowind.ini");
fs::write(
&cfg,
concat!(
"data=Data Files\n",
"data=\"Extra Data\"\n",
"data-local=Local Data\n",
"resources=resources\n",
"user-data=user-data\n",
),
)
.unwrap();
fs::write(&ini, "[General]\nDisable Audio=1\n").unwrap();
let importer = IniImporter::new(ImportOptions {
import_archives: false,
..ImportOptions::default()
});
let result = importer.import_paths(&ini, &cfg).unwrap();
assert_eq!(
values(&result.cfg, "data"),
&[
dir.join("Data Files").display().to_string(),
dir.join("Extra Data").display().to_string(),
]
);
assert_eq!(
values(&result.cfg, "data-local"),
&[dir.join("Local Data").display().to_string()]
);
assert_eq!(
values(&result.cfg, "resources"),
&[dir.join("resources").display().to_string()]
);
assert_eq!(
values(&result.cfg, "user-data"),
&[dir.join("user-data").display().to_string()]
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn import_options_set_singleton_paths() {
let dir = unique_test_dir("singleton-path-options");
fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("openmw.cfg");
let ini = dir.join("Morrowind.ini");
fs::write(
&cfg,
concat!(
"data-local=old-local\n",
"data-local=other-local\n",
"resources=old-resources\n",
"user-data=old-user-data\n",
),
)
.unwrap();
fs::write(&ini, "[General]\nDisable Audio=1\n").unwrap();
let importer = IniImporter::new(ImportOptions {
data_local: Some(PathBuf::from("new-local")),
resources: Some(PathBuf::from("new-resources")),
user_data: Some(PathBuf::from("new-user-data")),
import_archives: false,
..ImportOptions::default()
});
let result = importer.import_paths(&ini, &cfg).unwrap();
assert_eq!(values(&result.cfg, "data-local"), &["new-local".to_owned()]);
assert_eq!(
values(&result.cfg, "resources"),
&["new-resources".to_owned()]
);
assert_eq!(
values(&result.cfg, "user-data"),
&["new-user-data".to_owned()]
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn import_paths_does_not_include_composed_synthetic_entries() {
let dir = unique_test_dir("user-output-only");
let resources = dir.join("resources");
fs::create_dir_all(resources.join("vfs")).unwrap();
let cfg = dir.join("openmw.cfg");
let ini = dir.join("Morrowind.ini");
fs::write(&cfg, format!("resources={}\n", resources.display())).unwrap();
fs::write(&ini, "[General]\nDisable Audio=1\n").unwrap();
let importer = IniImporter::new(ImportOptions {
import_archives: false,
..ImportOptions::default()
});
let result = importer.import_paths(&ini, &cfg).unwrap();
let output = serialize_cfg(&result.cfg);
assert!(output.contains("resources="));
assert!(output.contains("no-sound=1"));
assert!(!output.contains("data="));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn imports_fallback_values_with_legacy_shapes() {
let dir = unique_test_dir("fallback-shapes");
fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("openmw.cfg");
let ini = dir.join("Morrowind.ini");
let output = dir.join("imported.cfg");
fs::write(&cfg, "encoding=win1252\n").unwrap();
fs::write(
&ini,
concat!(
"[Movies]\n",
"New Game=movie,with,commas.bik\n",
"[Weather]\n",
"Sunrise Time=6\n",
"Sun Glare Fader Max=0.75\n",
"[Weather Clear]\n",
"Sky Day Color=10,20,30\n",
),
)
.unwrap();
let importer = IniImporter::new(ImportOptions {
import_archives: false,
..ImportOptions::default()
});
let result = importer.import_paths(&ini, &cfg).unwrap();
fs::write(&output, serialize_cfg(&result.cfg)).unwrap();
let written = fs::read_to_string(&output).unwrap();
assert!(written.contains("fallback=Movies_New_Game,movie,with,commas.bik"));
assert!(written.contains("fallback=Weather_Sunrise_Time,6"));
assert!(written.contains("fallback=Weather_Sun_Glare_Fader_Max,0.75"));
assert!(written.contains("fallback=Weather_Clear_Sky_Day_Color,10,20,30"));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn import_paths_surfaces_ini_parse_warnings() {
let dir = unique_test_dir("import-paths-ini-warnings");
fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("openmw.cfg");
let ini = dir.join("Morrowind.ini");
fs::write(&cfg, "encoding=win1252\n").unwrap();
fs::write(
&ini,
"[General]\nEmpty=\n[bad\n[Movies]\nNew Game=intro.bik\n",
)
.unwrap();
let importer = IniImporter::new(ImportOptions {
import_archives: false,
..ImportOptions::default()
});
let result = importer.import_paths(&ini, &cfg).unwrap();
assert_eq!(
result.warnings,
vec![
ImportWarning::IgnoredEmptyValue {
key: "General:Empty".to_owned()
},
ImportWarning::MalformedIniLine {
line: "[bad".to_owned()
},
]
);
assert_eq!(
values(&result.cfg, "fallback"),
&["Movies_New_Game,intro.bik".to_owned()]
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn import_paths_accumulates_repeated_fallback_sections() {
let dir = unique_test_dir("import-paths-repeated-fallback-sections");
fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("openmw.cfg");
let ini = dir.join("Morrowind.ini");
fs::write(&cfg, "encoding=win1252\nfallback=Old_Setting,old\n").unwrap();
fs::write(
&ini,
concat!(
"[Movies]\n",
"New Game=intro.bik\n",
"[Weather]\n",
"Sunrise Time=6\n",
"[Movies]\n",
"New Game=outro.bik\n",
),
)
.unwrap();
let importer = IniImporter::new(ImportOptions {
import_archives: false,
..ImportOptions::default()
});
let result = importer.import_paths(&ini, &cfg).unwrap();
assert_eq!(
values(&result.cfg, "fallback"),
&[
"Movies_New_Game,intro.bik".to_owned(),
"Movies_New_Game,outro.bik".to_owned(),
"Weather_Sunrise_Time,6".to_owned(),
]
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn empty_fallback_values_warn_without_replacing_existing_cfg() {
let dir = unique_test_dir("import-paths-empty-fallback");
fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("openmw.cfg");
let ini = dir.join("Morrowind.ini");
fs::write(&cfg, "encoding=win1252\nfallback=Old_Setting,old\n").unwrap();
fs::write(&ini, "[Movies]\nNew Game=\n").unwrap();
let importer = IniImporter::new(ImportOptions {
import_archives: false,
..ImportOptions::default()
});
let result = importer.import_paths(&ini, &cfg).unwrap();
assert_eq!(
result.warnings,
vec![ImportWarning::IgnoredEmptyValue {
key: "Movies:New Game".to_owned()
}]
);
assert_eq!(
values(&result.cfg, "fallback"),
&["Old_Setting,old".to_owned()]
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn import_paths_missing_cfg_starts_empty() {
let dir = unique_test_dir("import-paths-missing-cfg");
fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("missing.cfg");
let ini = dir.join("Morrowind.ini");
fs::write(&ini, "[General]\nDisable Audio=1\n").unwrap();
let importer = IniImporter::new(ImportOptions {
import_archives: false,
..ImportOptions::default()
});
let result = importer.import_paths(&ini, &cfg).unwrap();
assert!(!cfg.exists());
assert_eq!(values(&result.cfg, "no-sound"), &["1".to_owned()]);
assert_eq!(values(&result.cfg, "encoding"), &["win1252".to_owned()]);
fs::remove_dir_all(dir).unwrap();
}
fn create_archives(dir: &Path, archives: &[&str]) {
let data_dir = dir.join("Data Files");
fs::create_dir_all(&data_dir).unwrap();
for archive in archives {
fs::write(data_dir.join(archive), []).unwrap();
}
}