Skip to main content

rmskin_builder/
lib.rs

1//! See description of [`CliArgs`] for basic info.
2use std::{env, fs::OpenOptions, io::Write, path::PathBuf};
3use tempfile::TempDir;
4
5mod discover;
6pub use discover::{HasComponents, discover_components};
7
8mod cli;
9pub use cli::{CliArgs, CliError};
10
11pub(crate) mod file_utils;
12pub use file_utils::{
13    bitness::{Bitness, get_dll_bitness},
14    header_img::validate_header_image,
15    parse_ini::parse_rmskin_ini,
16    zip::init_zip_for_package,
17};
18
19mod error;
20pub use error::{ArchiveError, IniError, RmSkinBuildError};
21
22mod logger;
23
24#[cfg(any(feature = "py-binding", feature = "bin"))]
25const DEBUG_ENV_TOGGLE: &str = "ACTIONS_STEP_DEBUG";
26
27const GH_OUT_VAR_NAME: &str = "arc-name";
28
29/// Helper function used in the `rmskin-build` binary executable.
30pub fn main(cli_args: CliArgs) -> Result<(), RmSkinBuildError> {
31    #[cfg(feature = "bin")]
32    {
33        use log::LevelFilter;
34
35        logger::logger_init();
36        let level = if env::var(DEBUG_ENV_TOGGLE).is_ok_and(|v| v == "true") {
37            LevelFilter::Debug
38        } else {
39            LevelFilter::Info
40        };
41        log::set_max_level(level);
42    }
43
44    let project_path = cli_args
45        .path
46        .clone()
47        .unwrap_or(PathBuf::from("./"))
48        .canonicalize()?;
49    {
50        // canonicalize() will ensure file_name() is not None, thus unwrap() safely.
51        let path_name = project_path.file_name().unwrap().to_string_lossy();
52        log::info!("Searching path: {path_name}");
53    }
54    let components = discover_components(&project_path)?;
55    if !components.is_valid() {
56        return Err(RmSkinBuildError::MalformedProject);
57    }
58
59    let build_dir = TempDir::new()?; // uses an absolute path
60    if components.rm_skin_bmp {
61        validate_header_image(&project_path, build_dir.path())?;
62    }
63    let (arc_name, version) = parse_rmskin_ini(&cli_args, &project_path, build_dir.path())?;
64    let archive_name = format!("{arc_name}_{version}.rmskin");
65    init_zip_for_package(&archive_name, &cli_args, &project_path, build_dir.path())?;
66    if let Ok(gh_out) = env::var("GITHUB_OUTPUT") {
67        if let Ok(mut gh_out_file) = OpenOptions::new().append(true).open(gh_out) {
68            writeln!(&mut gh_out_file, "{GH_OUT_VAR_NAME}={archive_name}")?;
69        }
70    } else {
71        log::info!("Archive name: {archive_name}");
72    }
73    Ok(())
74}
75
76#[cfg(feature = "py-binding")]
77use pyo3::prelude::*;
78
79#[cfg(feature = "py-binding")]
80#[cfg_attr(feature = "py-binding", pyfunction(name = "main"))]
81fn main_py(py: Python) -> PyResult<()> {
82    use clap::Parser;
83    use pyo3::{exceptions::PyOSError, types::PyDict};
84
85    let cli_args = CliArgs::parse_from(
86        py.import("sys")?
87            .getattr("argv")?
88            .extract::<Vec<String>>()?,
89    );
90
91    let logging = py.import("logging")?;
92    let format_str = "[%(levelname)5s]: %(message)s";
93    let key_word_args = PyDict::new(py);
94    key_word_args.set_item("format", format_str)?;
95    logging.call_method("basicConfig", (), Some(&key_word_args))?;
96    let level = logging.getattr(if env::var(DEBUG_ENV_TOGGLE).is_ok_and(|v| v == "true") {
97        "DEBUG"
98    } else {
99        "INFO"
100    })?;
101    logging
102        .call_method0("getLogger")?
103        .call_method1("setLevel", (level,))?;
104
105    main(cli_args).map_err(|e| PyOSError::new_err(e.to_string()))
106}
107
108#[cfg(feature = "py-binding")]
109#[cfg_attr(feature = "py-binding", pymodule)]
110fn rmskin_builder(m: &Bound<'_, PyModule>) -> PyResult<()> {
111    // enable log! passthrough to python logger
112    pyo3_log::init();
113
114    m.add_function(wrap_pyfunction!(discover::discover_components_py, m)?)?;
115    m.add_function(wrap_pyfunction!(
116        file_utils::parse_ini::parse_rmskin_ini_py,
117        m
118    )?)?;
119    m.add_function(wrap_pyfunction!(
120        file_utils::header_img::validate_header_image_py,
121        m
122    )?)?;
123    m.add_function(wrap_pyfunction!(file_utils::bitness::is_dll_32, m)?)?;
124    m.add_function(wrap_pyfunction!(
125        file_utils::bitness::get_dll_bitness_py,
126        m
127    )?)?;
128    m.add_function(wrap_pyfunction!(
129        file_utils::zip::init_zip_for_package_py,
130        m
131    )?)?;
132    m.add_function(wrap_pyfunction!(main_py, m)?)?;
133    m.add_class::<CliArgs>()?;
134    m.add_class::<Bitness>()?;
135    m.add_class::<HasComponents>()?;
136    Ok(())
137}
138
139#[cfg(test)]
140mod test {
141    use super::{CliArgs, DEBUG_ENV_TOGGLE, GH_OUT_VAR_NAME, main};
142    use ini::Ini;
143    use std::{env, fs, path::PathBuf, str::FromStr};
144    use tempfile::{NamedTempFile, TempDir};
145
146    const FOOTER_LEN: usize = 16;
147
148    fn run_main(with_gh_output: bool) {
149        let dir_out = TempDir::new().unwrap();
150        let gh_out_file = NamedTempFile::new_in(dir_out.path()).unwrap();
151        let test_assets = PathBuf::from_str("tests/demo_project").unwrap();
152        let mut cli_args = CliArgs::default();
153        cli_args.dir_out = Some(dir_out.path().to_path_buf());
154        cli_args.path = Some(test_assets);
155        unsafe {
156            env::set_var(DEBUG_ENV_TOGGLE, "true");
157            if with_gh_output {
158                env::set_var(
159                    "GITHUB_OUTPUT",
160                    gh_out_file.path().to_string_lossy().to_string(),
161                );
162            } else {
163                env::remove_var("GITHUB_OUTPUT");
164            }
165        }
166        assert!(main(cli_args).is_ok());
167        let artifact = if with_gh_output {
168            // check artifacts based on GITHUB_OUTPUT value
169            let outputs = Ini::load_from_file(gh_out_file.path()).unwrap();
170            let global_section: Option<String> = None;
171            let arc_name = outputs
172                .section(global_section)
173                .unwrap()
174                .get(GH_OUT_VAR_NAME)
175                .unwrap();
176            dir_out.path().to_path_buf().join(arc_name)
177        } else {
178            fs::read_dir(dir_out.path())
179                .unwrap()
180                .flatten()
181                .find(|entry| {
182                    let artifact = entry.path();
183                    artifact
184                        .extension()
185                        .is_some_and(|v| v.to_string_lossy() == "rmskin")
186                })
187                .unwrap()
188                .path()
189        };
190        let compressed_bytes = fs::read(&artifact).unwrap();
191        assert!(compressed_bytes.len() > FOOTER_LEN);
192        let (pkg, footer) = compressed_bytes.split_at(compressed_bytes.len() - FOOTER_LEN);
193        assert!(footer.ends_with(b"\x00RMSKIN\x00"));
194        let pkg_size = pkg.len() as u32;
195        let embedded_size = u32::from_le_bytes([footer[0], footer[1], footer[2], footer[3]]);
196        assert_eq!(pkg_size, embedded_size);
197    }
198
199    #[test]
200    fn main_no_gh_output() {
201        run_main(false);
202    }
203
204    #[test]
205    fn main_with_gh_output() {
206        run_main(true);
207    }
208}