Skip to main content

openstranded_s2mod_tool/
util.rs

1// openstranded-s2mod-tool — convert Stranded II mods to .s2mod format
2// Copyright (C) 2025  openstranded-s2mod-tool contributors
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17//! General utilities for file I/O, path handling, and data serialisation.
18
19use std::fs;
20use std::path::{Path, PathBuf};
21
22use anyhow::{Context, Result};
23use serde::Serialize;
24
25/// Compute the relative path of a file within the input tree.
26pub fn relative_path(file: &Path, input_root: &Path) -> PathBuf {
27    file.strip_prefix(input_root).unwrap_or(file).to_path_buf()
28}
29
30/// Read a file to String, trying UTF-8 first, falling back to
31/// Latin-1 (ISO-8859-1) which maps bytes 128-255 directly to
32/// Unicode codepoints U+0080–U+00FF.
33pub fn read_file_lossy(path: &Path) -> Result<String> {
34    let bytes = fs::read(path).with_context(|| format!("reading {:?}", path))?;
35    if let Ok(s) = String::from_utf8(bytes.clone()) {
36        return Ok(s);
37    }
38    Ok(bytes.iter().map(|&b| b as char).collect())
39}
40
41/// Parse an .inf file, handling non-UTF-8 encodings gracefully.
42pub fn parse_inf_file(path: &Path) -> Result<Vec<inf2ron::InfEntry>> {
43    let content = read_file_lossy(path)?;
44    inf2ron::InfParser::parse_str(&content).map_err(|e| anyhow::anyhow!("{}", e))
45}
46
47/// Serialize data to RON and write to path.
48pub fn write_ron<T: Serialize + ?Sized>(path: &Path, data: &T) -> Result<()> {
49    let ron_str = ron::to_string(data)?;
50    if let Some(parent) = path.parent() {
51        fs::create_dir_all(parent)?;
52    }
53    fs::write(path, &ron_str)?;
54    Ok(())
55}
56
57/// Copy a file from the input tree into the staging directory,
58/// preserving its relative path.
59pub fn copy_to_staging(file: &Path, input_root: &Path, stage: &Path) -> Result<()> {
60    let relative = relative_path(file, input_root);
61    let dest = stage.join(relative);
62    if let Some(parent) = dest.parent() {
63        fs::create_dir_all(parent)?;
64    }
65    fs::copy(file, &dest)?;
66    Ok(())
67}
68
69/// Convert a directory/file name into a safe alphanumeric ID for use as a pack identifier.
70/// E.g. "Stranded II" → "stranded_ii",  "MyMod-v1!" → "mymod_v1".
71pub fn normalize_id(name: &str) -> String {
72    let mut out = String::with_capacity(name.len());
73    let mut prev_was_space = false;
74    for ch in name.chars() {
75        if ch.is_ascii_alphanumeric() {
76            out.push(ch.to_ascii_lowercase());
77            prev_was_space = false;
78        } else if ch == '_' || ch == '-' {
79            out.push(ch);
80            prev_was_space = false;
81        } else if ch.is_ascii_whitespace() {
82            if !prev_was_space {
83                out.push('_');
84                prev_was_space = true;
85            }
86        } else {
87            if !prev_was_space && !out.is_empty() {
88                out.push('_');
89                prev_was_space = true;
90            }
91        }
92    }
93    let trimmed = out.trim_end_matches('_').to_string();
94    if trimmed.is_empty() { "content".to_string() } else { trimmed }
95}