Skip to main content

rmskin_builder/
cli.rs

1use std::{env, path::PathBuf, process::Command, string::FromUtf8Error};
2use thiserror::Error;
3
4#[cfg(feature = "py-binding")]
5use pyo3::prelude::*;
6
7#[cfg(feature = "clap")]
8use clap::Parser;
9
10/// Errors emitted by various [`CliArgs`] functions.
11#[derive(Debug, Error)]
12pub enum CliError {
13    #[error("{0}")]
14    Io(#[from] std::io::Error),
15
16    #[error("{0}")]
17    Utf8Error(#[from] FromUtf8Error),
18
19    #[error("Unknown working directory name")]
20    UnknownWorkingDirectory,
21
22    #[error("Malformed repository name: {0}")]
23    MalformedRepoName(String),
24}
25
26/// A CLI tool to package Rainmeter Skins into a .rmskin file.
27///
28/// ## Ideal Repo Structure
29///
30/// The following files/folders are used if they exist in the project's root directory:
31///
32/// - `Skins/` (required): A folder to contain all necessary Rainmeter skins.
33/// - `RMSKIN.ini` (required): A list of options specific to installing the skin(s).
34/// - `RMSKIN.bmp` (optional): A header image to add brand recognition when installing
35/// - `Layouts/` (optional): A folder that contains Rainmeter layout files.
36/// - `Plugins/` (optional): A folder that contains Rainmeter plugins.
37/// - `@Vault/` (optional): A resources folder accessible by all installed skins.
38///   the generated rmskin file.
39///
40/// If the RMSKIN.ini file or Skins/ folder are not present, then an error will be thrown.
41///
42/// ### Repository template
43///
44/// A [cookiecutter repository](https://github.com/2bndy5/Rainmeter-Cookiecutter)
45/// has also been created to quickly facilitate development of Rainmeter skins on Github.
46#[cfg_attr(feature = "clap", derive(Parser))]
47#[derive(Debug, Default, Clone)]
48#[cfg_attr(
49    feature = "clap",
50    command(name = "rmskin-builder", about, long_about, verbatim_doc_comment)
51)]
52#[cfg_attr(
53    feature = "py-binding",
54    pyclass(module = "rmskin_builder", from_py_object)
55)]
56pub struct CliArgs {
57    /// The path pointing to the Rainmeter project.
58    ///
59    /// This path shall contain the RMSKIN.ini and Skins folder.
60    /// This defaults to the current working directory.
61    #[cfg_attr(feature = "clap", arg(short, long, default_value = "./"))]
62    pub path: Option<PathBuf>,
63
64    /// The version number of the release.
65    ///
66    /// This will default to the git tag or the last 7 hexadecimal digits of the commit's SHA.
67    #[cfg_attr(feature = "clap", arg(short = 'V', long))]
68    version: Option<String>,
69
70    /// The Author of the release.
71    ///
72    /// This will get its default value from (in order of precedence):
73    /// - the environment variable `GITHUB_ACTOR`
74    /// - the output from `git config get user.name`
75    /// - "Unknown" when all else fails
76    #[cfg_attr(feature = "clap", arg(short, long, verbatim_doc_comment))]
77    author: Option<String>,
78
79    /// Get the name of the Rainmeter project.
80    ///
81    /// This defaults to the repository name.
82    /// If the environment variable `GITHUB_REPOSITORY` is not set, then
83    /// this will just use the working directory's name.
84    #[cfg_attr(feature = "clap", arg(short, long, verbatim_doc_comment))]
85    title: Option<String>,
86
87    /// The directory to which the resulting rmskin file is stored.
88    ///
89    /// This defaults to the current working directory.
90    #[cfg_attr(
91        feature = "clap",
92        arg(short, long, alias = "dir_out", default_value = "./")
93    )]
94    pub dir_out: Option<PathBuf>,
95}
96
97const GH_REPO: &str = "GITHUB_REPOSITORY";
98const GH_REF: &str = "GITHUB_REF";
99const GH_SHA: &str = "GITHUB_SHA";
100const GH_ACTOR: &str = "GITHUB_ACTOR";
101
102impl CliArgs {
103    /// Get the version number of the release.
104    ///
105    /// This will default to the git tag or the last 7 hexadecimal digits of the commit's SHA.
106    pub fn get_version(&self) -> Result<String, CliError> {
107        if let Some(version) = &self.version {
108            return Ok(version.clone());
109        }
110        if let Ok(gh_ref) = env::var(GH_REF) {
111            if let Some(stripped) = gh_ref.strip_prefix("refs/tags/") {
112                Ok(stripped.to_string())
113            } else if let Ok(gh_sha) = env::var(GH_SHA) {
114                let len = gh_sha.len().saturating_sub(7);
115                Ok(gh_sha[len..].to_string())
116            } else {
117                Ok("x0x.y0y".to_string())
118            }
119        } else {
120            // In case this is not running in a GitHub Action workflow:
121            // Use `git` instead, but this assumes a non-shallow checkout.
122            if let Ok(result) = Command::new("git").args(["describe", "--tags"]).output() {
123                Ok(String::from_utf8(result.stdout.trim_ascii().to_vec())?)
124            } else {
125                let result = Command::new("git")
126                    .args(["log", "-1", "--format=\"%h\""])
127                    .output()?;
128                Ok(String::from_utf8(result.stdout.trim_ascii().to_vec())?)
129            }
130        }
131    }
132
133    /// Get the name of the author for the release.
134    ///
135    /// This will get a default value from (in order of precedence):
136    /// - the environment variable `GITHUB_ACTOR`
137    /// - the output from `git config get user.name`
138    /// - `"Unknown"` when all else fails
139    pub fn get_author(&self) -> Result<String, CliError> {
140        if let Some(author) = &self.author {
141            Ok(author.clone())
142        } else if let Ok(actor) = env::var(GH_ACTOR) {
143            Ok(actor)
144        } else {
145            let result = Command::new("git")
146                .args(["config", "get", "user.name"])
147                .output()?;
148            Ok(String::from_utf8(result.stdout.trim_ascii().to_vec())?)
149        }
150    }
151
152    /// Get the Rainmeter project's title.
153    ///
154    /// This defaults to the repository name.
155    /// If the environment variable `GITHUB_REPOSITORY` is not set, then
156    /// this will just use the working directory's name.
157    pub fn get_title(&self) -> Result<String, CliError> {
158        if let Some(title) = &self.title {
159            Ok(title.to_owned())
160        } else {
161            if let Ok(mut repo) = env::var(GH_REPO) {
162                let divider = repo
163                    .find('/')
164                    .ok_or(CliError::MalformedRepoName(repo.to_owned()))?
165                    + 1;
166                return Ok(repo.split_off(divider));
167            }
168            let curr_dir = env::current_dir()?;
169            Ok(curr_dir
170                .file_name()
171                .ok_or(CliError::UnknownWorkingDirectory)?
172                .to_string_lossy()
173                .to_string())
174        }
175    }
176}
177
178impl CliArgs {
179    /// Set the Rainmeter project's version number.
180    pub fn version(&mut self, value: Option<String>) {
181        self.version = value;
182    }
183
184    /// Set the name of the author for the release.
185    pub fn author(&mut self, value: Option<String>) {
186        self.author = value;
187    }
188
189    /// Set the Rainmeter project's title.
190    pub fn title(&mut self, value: Option<String>) {
191        self.title = value;
192    }
193}
194
195#[cfg(feature = "py-binding")]
196#[cfg_attr(feature = "py-binding", pymethods)]
197impl CliArgs {
198    #[getter("version")]
199    pub fn get_version_py(&self) -> PyResult<String> {
200        use pyo3::exceptions::{PyIOError, PyValueError};
201
202        self.get_version().map_err(|e| match e {
203            CliError::Io(err) => PyIOError::new_err(err.to_string()),
204            CliError::Utf8Error(err) => PyValueError::new_err(err.to_string()),
205            CliError::UnknownWorkingDirectory => PyValueError::new_err("Unknown working directory"),
206            CliError::MalformedRepoName(err) => {
207                PyValueError::new_err(format!("Repository named malformed: {err}"))
208            }
209        })
210    }
211
212    #[getter("author")]
213    pub fn get_author_py(&self) -> PyResult<String> {
214        use pyo3::exceptions::{PyIOError, PyValueError};
215
216        self.get_author().map_err(|e| match e {
217            CliError::Io(err) => PyIOError::new_err(err.to_string()),
218            CliError::Utf8Error(err) => PyValueError::new_err(err.to_string()),
219            CliError::UnknownWorkingDirectory => PyValueError::new_err("Unknown working directory"),
220            CliError::MalformedRepoName(err) => {
221                PyValueError::new_err(format!("Repository named malformed: {err}"))
222            }
223        })
224    }
225
226    #[getter("title")]
227    pub fn get_title_py(&self) -> PyResult<String> {
228        use pyo3::exceptions::{PyIOError, PyValueError};
229
230        self.get_title().map_err(|e| match e {
231            CliError::Io(err) => PyIOError::new_err(err.to_string()),
232            CliError::Utf8Error(err) => PyValueError::new_err(err.to_string()),
233            CliError::UnknownWorkingDirectory => PyValueError::new_err("Unknown working directory"),
234            CliError::MalformedRepoName(err) => {
235                PyValueError::new_err(format!("Repository named malformed: {err}"))
236            }
237        })
238    }
239
240    #[setter]
241    pub fn set_version(&mut self, value: Option<String>) {
242        self.version(value);
243    }
244
245    #[setter]
246    pub fn set_author(&mut self, value: Option<String>) {
247        self.author(value);
248    }
249
250    #[setter]
251    pub fn set_title(&mut self, value: Option<String>) {
252        self.title(value);
253    }
254
255    pub fn __repr__(&self) -> String {
256        format!("{self:?}")
257    }
258
259    #[new]
260    pub fn new() -> Self {
261        Self::default()
262    }
263}
264
265#[cfg(test)]
266mod test {
267    use super::{CliArgs, CliError, GH_ACTOR, GH_REF, GH_REPO, GH_SHA};
268    use std::env;
269
270    #[test]
271    fn version() {
272        unsafe {
273            env::remove_var(GH_REF);
274            env::remove_var(GH_SHA);
275        }
276        let mut args = CliArgs::default();
277        args.version(Some(args.get_version().unwrap()));
278        assert!(!args.get_version().unwrap().is_empty());
279    }
280
281    #[test]
282    fn version_ci_push() {
283        let sha = "DEADBEEF";
284        unsafe {
285            env::set_var(GH_REF, "");
286            env::set_var(GH_SHA, sha);
287        }
288        let args = CliArgs::default();
289        assert!(sha.ends_with(&args.get_version().unwrap()));
290    }
291
292    #[test]
293    fn version_ci_tag() {
294        let tag = "v1.2.3";
295        unsafe {
296            env::set_var(GH_REF, format!("refs/tags/{tag}").as_str());
297            env::remove_var(GH_SHA);
298        }
299        let args = CliArgs::default();
300        assert_eq!(args.get_version().unwrap().as_str(), tag);
301    }
302
303    #[test]
304    fn version_ci_default() {
305        let tag = "x0x.y0y";
306        unsafe {
307            env::set_var(GH_REF, "");
308            env::remove_var(GH_SHA);
309        }
310        let args = CliArgs::default();
311        assert_eq!(args.get_version().unwrap().as_str(), tag);
312    }
313
314    #[test]
315    fn author() {
316        unsafe {
317            env::remove_var(GH_ACTOR);
318        }
319        let mut args = CliArgs::default();
320        args.author(Some(args.get_author().unwrap()));
321        // `git config get user.name` can have various output.
322        // Just test the value is not empty.
323        assert!(!args.get_author().unwrap().is_empty());
324    }
325
326    #[test]
327    fn author_ci() {
328        let author = "2bndy5";
329        unsafe {
330            env::set_var(GH_ACTOR, author);
331        }
332        let args = CliArgs::default();
333        assert_eq!(args.get_author().unwrap().as_str(), author);
334    }
335
336    #[test]
337    fn title() {
338        unsafe {
339            env::remove_var(GH_REPO);
340        }
341        let mut args = CliArgs::default();
342        args.title(Some(args.get_title().unwrap()));
343        assert_eq!(
344            args.get_title().unwrap().as_str(),
345            env::current_dir()
346                .unwrap()
347                .file_name()
348                .unwrap()
349                .to_str()
350                .unwrap()
351        );
352    }
353
354    #[test]
355    fn title_ci() {
356        unsafe {
357            env::set_var(GH_REPO, "2bndy5/rmskin-action");
358        }
359        let args = CliArgs::default();
360        assert_eq!(args.get_title().unwrap(), "rmskin-action".to_string());
361    }
362
363    #[test]
364    fn title_ci_bad() {
365        let bad_repo_name = "2bndy5\\rmskin-action";
366        unsafe {
367            env::set_var(GH_REPO, bad_repo_name);
368        }
369        let args = CliArgs::default();
370        let title = args.get_title();
371        assert!(title.is_err());
372        if let Err(CliError::MalformedRepoName(bad_name)) = title {
373            assert_eq!(&bad_name, bad_repo_name);
374        }
375    }
376}