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