Skip to main content

glacier_ini/
lib.rs

1use crate::ini_file::{IniFile, IniFileError, IniFileSection};
2use crate::utils::normalize_path;
3use glacier_base::encryption::xtea::{Xtea, XteaConfig};
4use pathdiff::diff_paths;
5use std::collections::VecDeque;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::str::from_utf8;
9
10pub mod ini_file;
11mod utils;
12
13/// ID for an INI option by section and option name.
14pub struct IniKey {
15    pub(crate) section: String,
16    pub(crate) option: String,
17}
18
19impl IniKey {
20    pub fn from_tuple(section: &str, option: &str) -> Self {
21        Self {
22            section: section.to_string(),
23            option: option.to_string(),
24        }
25    }
26
27    /// Creates a new [IniKey] from a `section/option` path string
28    //
29    /// ### examples:
30    /// - application/scene_file => section: "application", option: "scene_file"
31    /// - scene_file => section: "", option: "scene_file"
32    pub fn from_location(path: &str) -> Self {
33        match path.split_once("/") {
34            Some((section, option)) => Self {
35                section: section.to_string(),
36                option: option.to_string(),
37            },
38            None => Self {
39                section: "".to_string(),
40                option: path.to_string(),
41            },
42        }
43    }
44}
45
46impl From<&str> for IniKey {
47    fn from(path: &str) -> Self {
48        IniKey::from_location(path)
49    }
50}
51
52impl From<(&str, &str)> for IniKey {
53    fn from(tuple: (&str, &str)) -> Self {
54        IniKey::from_tuple(tuple.0, tuple.1)
55    }
56}
57
58// impl From<(&str, &str)> for IniKey {    }
59
60/// A hierarchical file system of [IniFile].
61///
62/// example usage:
63/// ```ignore
64///  use std::path::PathBuf;
65///  use rpkg_rs::misc::ini_file_system::IniFileSystem;
66///
67///  let retail_path = PathBuf::from("Path to retail folder");
68///  let thumbs_path = retail_path.join("thumbs.dat");
69///
70///  let thumbs = IniFileSystem::from(&thumbs_path.as_path())?;
71///
72///  let app_options = &thumbs.root()?;
73///
74///  if let (Some(proj_path), Some(runtime_path)) = (app_options.get("PROJECT_PATH"), app_options.get("RUNTIME_PATH")) {
75///     println!("Project path: {}", proj_path);
76///     println!("Runtime path: {}", runtime_path);
77///  }
78/// ```
79#[derive(Debug)]
80pub struct IniFileSystem {
81    root: IniFile,
82}
83
84impl IniFileSystem {
85    pub fn new(ini_file: IniFile) -> Self {
86        Self { root: ini_file }
87    }
88
89    /// Loads an [`IniFileSystem`] from a root file on disk.
90    ///
91    /// The file is read from `root_file` and decrypted using the provided
92    /// [`XteaConfig`], parsed as an [`IniFile`], and any `!include` directives
93    /// are resolved recursively relative to the root file's parent directory.
94    pub fn from_path(
95        root_file: impl AsRef<Path>,
96        xtea_config: XteaConfig,
97    ) -> Result<Self, IniFileError> {
98        let ini_file = Self::load_from_path(
99            root_file.as_ref(),
100            PathBuf::from(root_file.as_ref()).parent().unwrap(),
101            &Xtea::new(xtea_config),
102        )?;
103        Ok(Self { root: ini_file })
104    }
105
106    fn load_from_path(
107        path: &Path,
108        working_directory: &Path,
109        xtea: &Xtea,
110    ) -> Result<IniFile, IniFileError> {
111        let content = fs::read(path).map_err(IniFileError::IoError)?;
112        let mut content_decrypted = from_utf8(content.as_ref()).unwrap_or("").to_string();
113        if xtea.is_encrypted_text_file(&content) {
114            content_decrypted = xtea
115                .decrypt_text_file(&content)
116                .map_err(IniFileError::DecryptionError)?;
117        }
118
119        let ini_file_name = match diff_paths(path, working_directory) {
120            Some(relative_path) => relative_path.to_str().unwrap().to_string(),
121            None => path.to_str().unwrap().to_string(),
122        };
123        Self::load_from_string(
124            ini_file_name.as_str(),
125            content_decrypted.as_str(),
126            working_directory,
127            xtea,
128        )
129    }
130
131    fn load_from_string(
132        name: &str,
133        ini_file_content: &str,
134        working_directory: &Path,
135        xtea: &Xtea,
136    ) -> Result<IniFile, IniFileError> {
137        let mut active_section: String = "None".to_string();
138        let mut ini_file = IniFile::new(name);
139
140        for line in ini_file_content.lines() {
141            if let Some(description) = line.strip_prefix('#') {
142                if ini_file_content.starts_with(line) {
143                    //I don't really like this, but IOI seems to consistently use the first comment as a description.
144                    ini_file.description = Some(description.trim_start().to_string());
145                }
146            } else if let Some(line) = line.strip_prefix('!') {
147                if let Some((command, value)) = line.split_once(' ') {
148                    if command == "include" {
149                        let include = Self::load_from_path(
150                            working_directory.join(value).as_path(),
151                            working_directory,
152                            xtea,
153                        )?;
154                        ini_file.includes.push(include);
155                    }
156                }
157            } else if let Some(mut section_name) = line.strip_prefix('[') {
158                section_name = section_name
159                    .strip_suffix(']')
160                    .ok_or(IniFileError::ParsingError(
161                        "a section should always have a closing ] bracket".to_string(),
162                    ))?;
163                active_section = section_name.to_string();
164                if !ini_file.sections.contains_key(&active_section) {
165                    ini_file.sections.insert(
166                        active_section.clone(),
167                        IniFileSection::new(&active_section.clone()),
168                    );
169                }
170            } else if let Some(keyval) = line.strip_prefix("ConsoleCmd ") {
171                ini_file.console_cmds.push(keyval.to_string());
172            } else if let Some((key, val)) = line.split_once('=') {
173                if let Some(section) = ini_file.sections.get_mut(&active_section) {
174                    section.insert(key, val);
175                }
176            }
177        }
178        Ok(ini_file)
179    }
180
181    /// Writes the entire [`IniFileSystem`] to a folder on disk.
182    ///
183    /// The root file and all included files are written recursively, preserving
184    /// their relative paths. Output is encrypted with the provided
185    /// [`XteaConfig`].
186    ///
187    /// If `path` points to a file, its parent directory is used as the export root.
188    /// If you don't want multiple included ini files in the export path you should consider using the normalize function first first
189    pub fn write_to_folder<P: AsRef<Path>>(
190        &self,
191        path: P,
192        xtea_config: XteaConfig,
193    ) -> Result<(), IniFileError> {
194        let mut folder = path.as_ref();
195        if folder.is_file() {
196            folder = path.as_ref().parent().ok_or(IniFileError::InvalidInput(
197                "The export path cannot be empty".to_string(),
198            ))?;
199        }
200        fn write_children_to_folder(
201            path: &Path,
202            ini_file: &IniFile,
203            xtea: &Xtea,
204        ) -> Result<(), IniFileError> {
205            let mut file_path = path.join(&ini_file.name);
206            file_path = normalize_path(&file_path);
207
208            let parent_dir = file_path.parent().ok_or(IniFileError::InvalidInput(
209                "Invalid export path given".to_string(),
210            ))?;
211            fs::create_dir_all(parent_dir)?;
212
213            let mut writer = fs::OpenOptions::new()
214                .write(true)
215                .create(true)
216                .truncate(true)
217                .open(&file_path)?;
218            ini_file.write_to_file(&mut writer, xtea)?;
219
220            for include in ini_file.includes.iter() {
221                match write_children_to_folder(parent_dir, include, xtea) {
222                    Ok(_) => {}
223                    Err(e) => return Err(e),
224                };
225            }
226            Ok(())
227        }
228
229        write_children_to_folder(folder, &self.root, &Xtea::new(xtea_config))
230    }
231
232    /// Normalizes the [`IniFileSystem`] by merging all included files into the root file.
233    ///
234    /// Sections and console commands from included files are applied breadth-first.
235    /// When an option already exists in the root, later values overwrite earlier ones.
236    /// Included files are removed from the hierarchy as part of normalization.
237    pub fn normalize(&mut self) {
238        let mut queue: VecDeque<IniFile> = VecDeque::new();
239        for include in self.root.includes.drain(0..) {
240            queue.push_back(include);
241        }
242
243        while let Some(mut current_file) = queue.pop_front() {
244            let root_sections = &mut self.root.sections;
245
246            for (section_key, section) in current_file.sections.drain() {
247                if !root_sections.contains_key(&section_key) {
248                    root_sections.insert(section_key.clone(), section);
249                } else {
250                    let root_section = root_sections.get_mut(&section_key).unwrap();
251                    for (key, value) in section.options {
252                        if !root_section.has_option(&key) {
253                            root_section.insert(&key, &value);
254                        } else {
255                            root_section.insert(&key, value.as_str());
256                        }
257                    }
258                }
259            }
260
261            for console_cmd in current_file.console_cmds.drain(..) {
262                if !self.root.console_cmds.contains(&console_cmd) {
263                    self.root.console_cmds.push(console_cmd);
264                }
265            }
266            for include in current_file.includes.drain(0..) {
267                queue.push_back(include);
268            }
269        }
270    }
271
272    /// Retrieves all console commands from the IniFileSystem, including those from included files.
273    pub fn console_cmds(&self) -> Vec<String> {
274        let mut cmds: Vec<String> = vec![];
275
276        // Helper function to traverse the includes recursively
277        fn traverse_includes(ini_file: &IniFile, cmds: &mut Vec<String>) {
278            for include in &ini_file.includes {
279                cmds.extend_from_slice(&include.console_cmds);
280                traverse_includes(include, cmds);
281            }
282        }
283
284        cmds.extend_from_slice(&self.root.console_cmds);
285        traverse_includes(&self.root, &mut cmds);
286
287        cmds
288    }
289
290    /// Retrieves the value of an option in a section from the IniFileSystem, including values from included files.
291    pub fn option(&self, key: impl Into<IniKey> + Clone) -> Result<String, IniFileError> {
292        let mut queue: VecDeque<&IniFile> = VecDeque::new();
293        queue.push_back(&self.root);
294        let mut latest_value: Option<String> = None;
295
296        while let Some(current_file) = queue.pop_front() {
297            if let Ok(value) =
298                current_file.get_option(&key.clone().into().section, &key.clone().into().option)
299            {
300                // Update the latest value found
301                latest_value = Some(value.clone());
302            }
303            for include in &current_file.includes {
304                queue.push_back(include);
305            }
306        }
307
308        // Return the latest value found or an error if none
309        latest_value
310            .ok_or_else(|| IniFileError::OptionNotFound(key.clone().into().option.to_string()))
311    }
312
313    /// Retrieves a reference to the root IniFile of the IniFileSystem.
314    pub fn root(&self) -> &IniFile {
315        &self.root
316    }
317
318    /// Retrieves a reference to the root IniFile of the IniFileSystem.
319    pub fn root_mut(&mut self) -> &mut IniFile {
320        &mut self.root
321    }
322}