1use 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
29pub 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 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()?; 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 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 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}