rpkg_rs/misc/
ini_file_system.rs

1use crate::encryption::xtea::Xtea;
2use crate::encryption::xtea::XteaError;
3use crate::utils::normalize_path;
4use itertools::Itertools;
5use pathdiff::diff_paths;
6use std::collections::VecDeque;
7use std::io::Write;
8use std::ops::{Index, IndexMut};
9use std::path::PathBuf;
10use std::str::from_utf8;
11use std::{collections::HashMap, fs, path::Path};
12use thiserror::Error;
13
14#[cfg(feature = "serde")]
15use serde::{Deserialize, Serialize};
16
17#[derive(Error, Debug)]
18pub enum IniFileError {
19    #[error("Option ({}) not found", _0)]
20    OptionNotFound(String),
21
22    #[error("Can't find section ({})", _0)]
23    SectionNotFound(String),
24
25    #[error("An error occurred when parsing: {}", _0)]
26    ParsingError(String),
27
28    #[error("An io error occurred: {}", _0)]
29    IoError(#[from] std::io::Error),
30
31    #[error("An io error occurred: {}", _0)]
32    DecryptionError(#[from] XteaError),
33
34    #[error("The given input was incorrect: {}", _0)]
35    InvalidInput(String),
36}
37
38#[derive(Default, Debug)]
39#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
40pub struct IniFileSection {
41    name: String,
42    options: HashMap<String, String>,
43}
44
45/// Represents a system config file for the Glacier engine
46/// ## Example contents
47///
48/// ```txt
49/// [application]
50/// ForceVSync=0
51/// CapWorkerThreads=1
52/// SCENE_FILE=assembly:/path/to/scene.entity
53/// ....
54///
55/// [Hitman5]
56/// usegamecontroller=1
57/// ConsoleCmd UI_EnableMouseEvents 0
58/// ....
59/// ```
60#[derive(Debug)]
61#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
62pub struct IniFile {
63    name: String,
64    description: Option<String>,
65    includes: Vec<IniFile>,
66    sections: HashMap<String, IniFileSection>,
67    console_cmds: Vec<String>,
68}
69
70/// A hierarchical file system of [IniFile].
71///
72/// example usage:
73/// ```ignore
74///  use std::path::PathBuf;
75///  use rpkg_rs::misc::ini_file_system::IniFileSystem;
76///
77///  let retail_path = PathBuf::from("Path to retail folder");
78///  let thumbs_path = retail_path.join("thumbs.dat");
79///
80///  let thumbs = IniFileSystem::from(&thumbs_path.as_path())?;
81///
82///  let app_options = &thumbs.root()?;
83///
84///  if let (Some(proj_path), Some(runtime_path)) = (app_options.get("PROJECT_PATH"), app_options.get("RUNTIME_PATH")) {
85///     println!("Project path: {}", proj_path);
86///     println!("Runtime path: {}", runtime_path);
87///  }
88/// ```
89#[derive(Default, Debug)]
90#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
91pub struct IniFileSystem {
92    root: IniFile,
93}
94
95impl IniFileSection {
96    fn new(name: String) -> Self {
97        Self {
98            name,
99            options: HashMap::new(),
100        }
101    }
102
103    pub fn name(&self) -> String {
104        self.name.to_owned()
105    }
106
107    pub fn options(&self) -> &HashMap<String, String> {
108        &self.options
109    }
110
111    pub fn has_option(&self, option_name: &str) -> bool {
112        self.options.contains_key(option_name)
113    }
114
115    fn set_option(&mut self, option_name: &str, value: &str) {
116        if let Some(key) = self.options.get_mut(option_name) {
117            *key = value.to_string();
118        } else {
119            self.options
120                .insert(option_name.to_string(), value.to_string());
121        }
122    }
123
124    pub fn write_section<W: std::fmt::Write>(&self, writer: &mut W) {
125        writeln!(writer, "[{}]", self.name).unwrap();
126        for (key, value) in &self.options {
127            writeln!(writer, "{}={}", key, value).unwrap();
128        }
129        writeln!(writer).unwrap();
130    }
131}
132
133impl Index<&str> for IniFileSection {
134    type Output = str;
135
136    fn index(&self, option_name: &str) -> &str {
137        self.options.get(option_name).expect("Option not found")
138    }
139}
140
141impl IndexMut<&str> for IniFileSection {
142    fn index_mut(&mut self, option_name: &str) -> &mut str {
143        self.options.entry(option_name.to_string()).or_default()
144    }
145}
146
147impl Default for IniFile {
148    fn default() -> Self {
149        Self {
150            name: "thumbs.dat".to_string(),
151            description: Some(String::from("System config file for the engine")),
152            includes: vec![],
153            sections: Default::default(),
154            console_cmds: vec![],
155        }
156    }
157}
158
159impl IniFile {
160    pub fn new(name: &str) -> Self {
161        Self {
162            name: name.to_string(),
163            description: None,
164            includes: vec![],
165            sections: Default::default(),
166            console_cmds: vec![],
167        }
168    }
169    pub fn name(&self) -> String {
170        self.name.to_string()
171    }
172    pub fn sections(&self) -> &HashMap<String, IniFileSection> {
173        &self.sections
174    }
175
176    pub fn includes(&self) -> &Vec<IniFile> {
177        &self.includes
178    }
179
180    pub fn find_include(&self, include_name: &str) -> Option<&IniFile> {
181        self.includes.iter().find(|incl| incl.name == include_name)
182    }
183
184    pub fn get_option(
185        &self,
186        section_name: &str,
187        option_name: &str,
188    ) -> Result<String, IniFileError> {
189        match self.sections.get(section_name) {
190            Some(v) => match v.options.get(option_name.to_uppercase().as_str()) {
191                Some(o) => Ok(o.clone()),
192                None => Err(IniFileError::OptionNotFound(option_name.to_string())),
193            },
194            None => Err(IniFileError::SectionNotFound(section_name.to_string())),
195        }
196    }
197
198    pub fn set_value(
199        &mut self,
200        section_name: &str,
201        option_name: &str,
202        value: &str,
203    ) -> Result<(), IniFileError> {
204        match self.sections.get_mut(section_name) {
205            Some(v) => match v.options.get_mut(option_name) {
206                Some(o) => {
207                    *o = value.to_string();
208                    Ok(())
209                }
210                None => Err(IniFileError::OptionNotFound(option_name.to_string())),
211            },
212            None => Err(IniFileError::SectionNotFound(section_name.to_string())),
213        }
214    }
215
216    pub fn push_console_command(&mut self, command: String) {
217        self.console_cmds.push(command);
218    }
219
220    pub fn console_cmds(&self) -> &Vec<String> {
221        &self.console_cmds
222    }
223
224    pub fn write_ini_file<W: std::fmt::Write>(&self, writer: &mut W) {
225        if let Some(description) = &self.description {
226            writeln!(writer, "# {}", description).unwrap();
227            writeln!(writer, "\n# -----------------------------------------------------------------------------\n", ).unwrap();
228        }
229        for include in &self.includes {
230            writeln!(writer, "!include {}", include.name).unwrap();
231        }
232        for section_name in self
233            .sections
234            .keys()
235            .sorted_by(|a, b| Ord::cmp(&a.to_lowercase(), &b.to_lowercase()))
236        {
237            if let Some(section) = self.sections().get(section_name) {
238                section.write_section(writer);
239            }
240        }
241        for console_cmd in &self.console_cmds {
242            writeln!(writer, "ConsoleCmd {}", console_cmd).unwrap();
243        }
244    }
245}
246
247impl IniFileSystem {
248    pub fn new() -> Self {
249        Self {
250            root: IniFile::new("thumbs.dat"),
251        }
252    }
253
254    /// Loads an IniFileSystem from the given root file.
255    pub fn load(&mut self, root_file: impl AsRef<Path>) -> Result<(), IniFileError> {
256        let ini_file = Self::load_from_path(
257            root_file.as_ref(),
258            PathBuf::from(root_file.as_ref()).parent().unwrap(),
259        )?;
260        self.root = ini_file;
261        Ok(())
262    }
263
264    pub fn from(root_file: impl AsRef<Path>) -> Result<Self, IniFileError> {
265        let mut ret = Self::new();
266        match ret.load(root_file) {
267            Ok(_) => Ok(ret),
268            Err(e) => Err(e),
269        }
270    }
271
272    fn load_from_path(path: &Path, working_directory: &Path) -> Result<IniFile, IniFileError> {
273        let content = fs::read(path).map_err(IniFileError::IoError)?;
274        let mut content_decrypted = from_utf8(content.as_ref()).unwrap_or("").to_string();
275        if Xtea::is_encrypted_text_file(&content) {
276            content_decrypted =
277                Xtea::decrypt_text_file(&content).map_err(IniFileError::DecryptionError)?;
278        }
279
280        let ini_file_name = match diff_paths(path, working_directory) {
281            Some(relative_path) => relative_path.to_str().unwrap().to_string(),
282            None => path.to_str().unwrap().to_string(),
283        };
284        Self::load_from_string(
285            ini_file_name.as_str(),
286            content_decrypted.as_str(),
287            working_directory,
288        )
289    }
290
291    fn load_from_string(
292        name: &str,
293        ini_file_content: &str,
294        working_directory: &Path,
295    ) -> Result<IniFile, IniFileError> {
296        let mut active_section: String = "None".to_string();
297        let mut ini_file = IniFile::new(name);
298
299        for line in ini_file_content.lines() {
300            if let Some(description) = line.strip_prefix('#') {
301                if ini_file_content.starts_with(line) {
302                    //I don't really like this, but IOI seems to consistently use the first comment as a description.
303                    ini_file.description = Some(description.trim_start().to_string());
304                }
305            } else if let Some(line) = line.strip_prefix('!') {
306                if let Some((command, value)) = line.split_once(' ') {
307                    if command == "include" {
308                        let include = Self::load_from_path(
309                            working_directory.join(value).as_path(),
310                            working_directory,
311                        )?;
312                        ini_file.includes.push(include);
313                    }
314                }
315            } else if let Some(mut section_name) = line.strip_prefix('[') {
316                section_name = section_name
317                    .strip_suffix(']')
318                    .ok_or(IniFileError::ParsingError(
319                        "a section should always have a closing ] bracket".to_string(),
320                    ))?;
321                active_section = section_name.to_string();
322                if !ini_file.sections.contains_key(&active_section) {
323                    ini_file.sections.insert(
324                        active_section.clone(),
325                        IniFileSection::new(active_section.clone()),
326                    );
327                }
328            } else if let Some(keyval) = line.strip_prefix("ConsoleCmd ") {
329                ini_file.console_cmds.push(keyval.to_string());
330            } else if let Some((key, val)) = line.split_once('=') {
331                if let Some(section) = ini_file.sections.get_mut(&active_section) {
332                    section.set_option(key.to_uppercase().as_str(), val);
333                }
334            }
335        }
336        Ok(ini_file)
337    }
338
339    pub fn write_to_folder<P: AsRef<Path>>(&self, path: P) -> Result<(), IniFileError> {
340        let mut folder = path.as_ref();
341        if folder.is_file() {
342            folder = path.as_ref().parent().ok_or(IniFileError::InvalidInput(
343                "The export path cannot be empty".to_string(),
344            ))?;
345        }
346        fn write_children_to_folder(path: &Path, ini_file: &IniFile) -> Result<(), IniFileError> {
347            let mut file_path = path.join(&ini_file.name);
348            file_path = normalize_path(&file_path);
349
350            let parent_dir = file_path.parent().ok_or(IniFileError::InvalidInput(
351                "Invalid export path given".to_string(),
352            ))?;
353            fs::create_dir_all(parent_dir)?;
354
355            let mut writer = fs::OpenOptions::new()
356                .write(true)
357                .create(true)
358                .truncate(true)
359                .open(&file_path)?;
360            let mut contents = String::new();
361            ini_file.write_ini_file(&mut contents);
362            let _ = writer.write_all(contents.as_bytes());
363
364            for include in ini_file.includes.iter() {
365                match write_children_to_folder(parent_dir, include) {
366                    Ok(_) => {}
367                    Err(e) => return Err(e),
368                };
369            }
370            Ok(())
371        }
372
373        write_children_to_folder(folder, &self.root)
374    }
375
376    /// Normalizes the IniFileSystem by merging sections and console commands from included files into the root file.
377    pub fn normalize(&mut self) {
378        let mut queue: VecDeque<IniFile> = VecDeque::new();
379        for include in self.root.includes.drain(0..) {
380            queue.push_back(include);
381        }
382
383        while let Some(mut current_file) = queue.pop_front() {
384            let root_sections = &mut self.root.sections;
385
386            for (section_key, section) in current_file.sections.drain() {
387                if !root_sections.contains_key(&section_key) {
388                    root_sections.insert(section_key.clone(), section);
389                } else {
390                    let root_section = root_sections.get_mut(&section_key).unwrap();
391                    for (key, value) in section.options {
392                        if !root_section.has_option(&key) {
393                            root_section.set_option(&key, &value);
394                        } else {
395                            root_section.set_option(&key, value.as_str());
396                        }
397                    }
398                }
399            }
400
401            for console_cmd in current_file.console_cmds.drain(..) {
402                if !self.root.console_cmds.contains(&console_cmd) {
403                    self.root.console_cmds.push(console_cmd);
404                }
405            }
406            for include in current_file.includes.drain(0..) {
407                queue.push_back(include);
408            }
409        }
410    }
411
412    /// Retrieves all console commands from the IniFileSystem, including those from included files.
413    pub fn console_cmds(&self) -> Vec<String> {
414        let mut cmds: Vec<String> = vec![];
415
416        // Helper function to traverse the includes recursively
417        fn traverse_includes(ini_file: &IniFile, cmds: &mut Vec<String>) {
418            for include in &ini_file.includes {
419                cmds.extend_from_slice(&include.console_cmds);
420                traverse_includes(include, cmds);
421            }
422        }
423
424        cmds.extend_from_slice(&self.root.console_cmds);
425        traverse_includes(&self.root, &mut cmds);
426
427        cmds
428    }
429
430    /// Retrieves the value of an option in a section from the IniFileSystem, including values from included files.
431    pub fn option(&self, section_name: &str, option_name: &str) -> Result<String, IniFileError> {
432        let mut queue: VecDeque<&IniFile> = VecDeque::new();
433        queue.push_back(&self.root);
434        let mut latest_value: Option<String> = None;
435
436        while let Some(current_file) = queue.pop_front() {
437            if let Ok(value) = current_file.get_option(section_name, option_name) {
438                // Update the latest value found
439                latest_value = Some(value.clone());
440            }
441            for include in &current_file.includes {
442                queue.push_back(include);
443            }
444        }
445
446        // Return the latest value found or an error if none
447        latest_value.ok_or_else(|| IniFileError::OptionNotFound(option_name.to_string()))
448    }
449
450    /// Retrieves a reference to the root IniFile of the IniFileSystem.
451    pub fn root(&self) -> &IniFile {
452        &self.root
453    }
454}
455
456impl Index<&str> for IniFile {
457    type Output = IniFileSection;
458
459    fn index(&self, section_name: &str) -> &IniFileSection {
460        self.sections.get(section_name).expect("Section not found")
461    }
462}
463
464impl IndexMut<&str> for IniFile {
465    fn index_mut(&mut self, section_name: &str) -> &mut IniFileSection {
466        self.sections
467            .entry(section_name.to_string())
468            .or_insert(IniFileSection::new(section_name.to_string()))
469    }
470}