Skip to main content

dream_ini/
importer.rs

1// SPDX-License-Identifier: GPL-3.0-only
2
3use std::collections::BTreeSet;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::content_files::{
8    ArchiveImportRequest, ContentFileImportRequest, import_archives, import_content_files,
9};
10use crate::events::ImportEvent;
11use crate::fallback_keys::MORROWIND_FALLBACK_KEYS;
12use crate::openmw_cfg::{load_resolved_cfg, normalize_cfg};
13use crate::parser::{insert_multimap, parse_ini_bytes_with_warnings, set_single_value};
14use crate::{Game, ImportError, ImportWarning, MultiMap, TextEncoding};
15
16#[derive(Debug, Clone)]
17#[allow(clippy::struct_excessive_bools)]
18pub struct ImportOptions {
19    pub game: Game,
20    pub import_game_files: bool,
21    pub import_fonts: bool,
22    pub import_archives: bool,
23    pub data_dirs: Vec<PathBuf>,
24    pub data_dir_base: Option<PathBuf>,
25    pub write_resolved_data_dirs: bool,
26    pub data_local: Option<PathBuf>,
27    pub resources: Option<PathBuf>,
28    pub user_data: Option<PathBuf>,
29    pub cfg_dir: Option<PathBuf>,
30    pub encoding: Option<TextEncoding>,
31    pub verbose: bool,
32}
33
34impl Default for ImportOptions {
35    fn default() -> Self {
36        Self {
37            game: Game::Morrowind,
38            import_game_files: false,
39            import_fonts: false,
40            import_archives: true,
41            data_dirs: Vec::new(),
42            data_dir_base: None,
43            write_resolved_data_dirs: false,
44            data_local: None,
45            resources: None,
46            user_data: None,
47            cfg_dir: None,
48            encoding: None,
49            verbose: false,
50        }
51    }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ImportResult {
56    pub cfg: MultiMap,
57    pub warnings: Vec<ImportWarning>,
58    pub events: Vec<ImportEvent>,
59    pub changed_keys: BTreeSet<String>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ImportReport {
64    pub warnings: Vec<ImportWarning>,
65    pub events: Vec<ImportEvent>,
66    pub changed_keys: BTreeSet<String>,
67}
68
69#[derive(Debug, Clone)]
70pub struct IniImporter {
71    options: ImportOptions,
72}
73
74impl IniImporter {
75    #[must_use]
76    pub fn new(options: ImportOptions) -> Self {
77        Self { options }
78    }
79
80    /// Imports from paths into the lightweight map model.
81    ///
82    /// # Errors
83    /// Returns [`ImportError`] when files cannot be read, encoding is unsupported, content or
84    /// archive names are invalid, fallback archives cannot be resolved, or cfg parsing fails.
85    pub fn import_paths(
86        &self,
87        ini_path: &Path,
88        cfg_path: &Path,
89    ) -> Result<ImportResult, ImportError> {
90        self.import_optional_cfg_path(ini_path, Some(cfg_path))
91    }
92
93    /// Imports from an INI path and an optional cfg path.
94    ///
95    /// # Errors
96    /// Returns [`ImportError`] when files cannot be read, encoding is unsupported, content or
97    /// archive names are invalid, fallback archives cannot be resolved, or cfg parsing fails.
98    pub fn import_optional_cfg_path(
99        &self,
100        ini_path: &Path,
101        cfg_path: Option<&Path>,
102    ) -> Result<ImportResult, ImportError> {
103        let mut cfg = match cfg_path {
104            Some(path) => load_resolved_cfg(path)?,
105            _ => MultiMap::new(),
106        };
107        let cfg_dir = cfg_path.and_then(cfg_parent_dir);
108
109        let mut changed_keys = BTreeSet::new();
110        let encoding = self.effective_encoding(&cfg)?;
111        if self.options.encoding.is_some() || !cfg.contains_key("encoding") {
112            changed_keys.insert("encoding".to_owned());
113        }
114        set_single_value(&mut cfg, "encoding", encoding.as_label().to_owned());
115
116        let ini_bytes = read_bytes(ini_path)?;
117        let parsed_ini = parse_ini_bytes_with_warnings(&ini_bytes, encoding);
118        let mut report = self.import_maps_with_cfg_dir(
119            &mut cfg,
120            &parsed_ini.entries,
121            ini_path,
122            cfg_dir.as_deref(),
123        )?;
124        report.warnings.splice(0..0, parsed_ini.warnings);
125        changed_keys.extend(report.changed_keys);
126        Ok(ImportResult {
127            cfg,
128            warnings: report.warnings,
129            events: report.events,
130            changed_keys,
131        })
132    }
133
134    /// Imports already parsed maps into the lightweight map model.
135    ///
136    /// # Errors
137    /// Returns [`ImportError`] when content or archive names are invalid, fallback archives cannot
138    /// be resolved, or cfg normalization fails.
139    pub fn import_maps(
140        &self,
141        cfg: &mut MultiMap,
142        ini: &MultiMap,
143        ini_path: &Path,
144    ) -> Result<ImportReport, ImportError> {
145        self.import_maps_with_cfg_dir(cfg, ini, ini_path, self.options.cfg_dir.as_deref())
146    }
147
148    fn import_maps_with_cfg_dir(
149        &self,
150        cfg: &mut MultiMap,
151        ini: &MultiMap,
152        ini_path: &Path,
153        cfg_dir: Option<&Path>,
154    ) -> Result<ImportReport, ImportError> {
155        let mut warnings = Vec::new();
156        let mut events = Vec::new();
157        let mut changed_keys = BTreeSet::new();
158        let mut search_cfg = normalize_cfg(cfg, cfg_dir)?;
159        let mut imported_cfg = cfg.clone();
160
161        if merge(&mut imported_cfg, ini) {
162            changed_keys.insert("no-sound".to_owned());
163        }
164        if merge_fallback(&mut imported_cfg, ini, self.options.import_fonts) {
165            changed_keys.insert("fallback".to_owned());
166        }
167
168        if self.options.import_game_files {
169            let imported_content = import_content_files(ContentFileImportRequest {
170                ini,
171                cfg: &search_cfg,
172                ini_path,
173                cfg_dir,
174                explicit_data_dirs: &self.options.data_dirs,
175                explicit_data_dir_base: self.options.data_dir_base.as_deref(),
176                write_resolved_data_dirs: self.options.write_resolved_data_dirs,
177                verbose: self.options.verbose,
178            })?;
179            for data_dir in imported_content.data_dirs {
180                changed_keys.insert("data".to_owned());
181                insert_multimap(&mut imported_cfg, "data".to_owned(), data_dir.cfg_value);
182                insert_multimap(
183                    &mut search_cfg,
184                    "data".to_owned(),
185                    data_dir.path.to_string_lossy().into_owned(),
186                );
187            }
188            imported_cfg.insert("content".to_owned(), imported_content.content);
189            changed_keys.insert("content".to_owned());
190            events.extend(imported_content.events);
191            warnings.extend(imported_content.warnings);
192        }
193
194        if self.options.import_archives {
195            let imported_archives = import_archives(ArchiveImportRequest {
196                ini,
197                cfg: &search_cfg,
198                ini_path,
199                cfg_dir,
200                explicit_data_dirs: &self.options.data_dirs,
201                explicit_data_dir_base: self.options.data_dir_base.as_deref(),
202                write_resolved_data_dirs: self.options.write_resolved_data_dirs,
203                verbose: self.options.verbose,
204            })?;
205            for data_dir in imported_archives.data_dirs {
206                changed_keys.insert("data".to_owned());
207                insert_multimap(&mut imported_cfg, "data".to_owned(), data_dir.cfg_value);
208            }
209            imported_cfg.insert("fallback-archive".to_owned(), imported_archives.archives);
210            changed_keys.insert("fallback-archive".to_owned());
211            events.extend(imported_archives.events);
212        }
213
214        self.apply_singleton_path_overrides(&mut imported_cfg, &mut changed_keys);
215
216        *cfg = imported_cfg;
217        Ok(ImportReport {
218            warnings,
219            events,
220            changed_keys,
221        })
222    }
223
224    fn effective_encoding(&self, cfg: &MultiMap) -> Result<TextEncoding, ImportError> {
225        if let Some(encoding) = self.options.encoding {
226            return Ok(encoding);
227        }
228
229        if let Some(value) = cfg.get("encoding").and_then(|values| values.last()) {
230            return TextEncoding::parse(value);
231        }
232
233        Ok(TextEncoding::Win1252)
234    }
235
236    fn apply_singleton_path_overrides(
237        &self,
238        cfg: &mut MultiMap,
239        changed_keys: &mut BTreeSet<String>,
240    ) {
241        set_path_override(
242            cfg,
243            changed_keys,
244            "data-local",
245            self.options.data_local.as_deref(),
246        );
247        set_path_override(
248            cfg,
249            changed_keys,
250            "resources",
251            self.options.resources.as_deref(),
252        );
253        set_path_override(
254            cfg,
255            changed_keys,
256            "user-data",
257            self.options.user_data.as_deref(),
258        );
259    }
260}
261
262fn merge(cfg: &mut MultiMap, ini: &MultiMap) -> bool {
263    if let Some(values) = ini.get("General:Disable Audio")
264        && let Some(value) = values.last()
265    {
266        cfg.insert("no-sound".to_owned(), vec![value.clone()]);
267        return true;
268    }
269    false
270}
271
272fn merge_fallback(cfg: &mut MultiMap, ini: &MultiMap, import_fonts: bool) -> bool {
273    let mut imported = Vec::new();
274    for key in MORROWIND_FALLBACK_KEYS {
275        if !import_fonts && matches!(*key, "Fonts:Font 0" | "Fonts:Font 1" | "Fonts:Font 2") {
276            continue;
277        }
278        if let Some(values) = ini.get(*key) {
279            for value in values {
280                let fallback_key = key.replace([' ', ':'], "_");
281                imported.push(format!("{fallback_key},{value}"));
282            }
283        }
284    }
285
286    if imported.is_empty() {
287        return false;
288    }
289
290    cfg.insert("fallback".to_owned(), imported);
291    true
292}
293
294fn set_path_override(
295    cfg: &mut MultiMap,
296    changed_keys: &mut BTreeSet<String>,
297    key: &str,
298    path: Option<&Path>,
299) {
300    if let Some(path) = path {
301        set_single_value(cfg, key, path.to_string_lossy().into_owned());
302        changed_keys.insert(key.to_owned());
303    }
304}
305
306fn cfg_parent_dir(path: &Path) -> Option<PathBuf> {
307    path.parent().map(Path::to_owned)
308}
309
310fn read_bytes(path: &Path) -> Result<Vec<u8>, ImportError> {
311    fs::read(path).map_err(|source| ImportError::Io {
312        path: path.to_owned(),
313        source,
314    })
315}