Skip to main content

tinted_builder_rust/operations/build/
utils.rs

1use anyhow::{anyhow, Result};
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::fs::read_to_string;
5use std::path::{Path, PathBuf};
6use tinted_builder::{Scheme, SchemeSystem};
7use wax::{Glob, Program};
8
9/// Represents a path to a scheme file with a supported extension.
10#[derive(Debug, Clone)]
11pub enum SchemeFile {
12    Yaml(PathBuf),
13    Yml(PathBuf),
14}
15
16impl SchemeFile {
17    /// Creates a new [`SchemeFile`] from the given path.
18    ///
19    /// # Errors
20    ///
21    /// Returns an error if the provided file does not have a supported extension (`.yaml`/`.yml`).
22    pub fn new(path: impl AsRef<Path>) -> Result<Self> {
23        let extension = path
24            .as_ref()
25            .extension()
26            .unwrap_or_default()
27            .to_str()
28            .unwrap_or_default();
29
30        match extension {
31            "yaml" => Ok(Self::Yaml(path.as_ref().to_path_buf())),
32            "yml" => Ok(Self::Yml(path.as_ref().to_path_buf())),
33            _ => Err(anyhow!(
34                "E111: Invalid scheme file extension: {}",
35                path.as_ref().display()
36            )),
37        }
38    }
39
40    /// Reads and parses the YAML scheme file into a [`Scheme`].
41    ///
42    /// # Errors
43    ///
44    /// Returns an error if:
45    /// - The file cannot be read from disk
46    /// - The contents are not valid YAML
47    /// - The YAML structure does not match a supported scheme system
48    pub fn get_scheme(&self) -> Result<Scheme> {
49        match self {
50            Self::Yaml(path) | Self::Yml(path) => {
51                let scheme_str = read_to_string(path)?;
52                let scheme: serde_yaml::Value = serde_yaml::from_str(&scheme_str)?;
53
54                if let serde_yaml::Value::Mapping(map) = scheme {
55                    match map.get("system") {
56                        Some(serde_yaml::Value::String(system_str))
57                            if system_str == &SchemeSystem::Base24.to_string() =>
58                        {
59                            let scheme_inner =
60                                serde_yaml::from_value(serde_yaml::Value::Mapping(map))?;
61                            let scheme = Scheme::Base24(scheme_inner);
62
63                            Ok(scheme)
64                        }
65                        Some(_) => {
66                            let scheme_inner =
67                                serde_yaml::from_value(serde_yaml::Value::Mapping(map))?;
68                            let scheme = Scheme::Base16(scheme_inner);
69
70                            Ok(scheme)
71                        }
72                        None => {
73                            if let Some(scheme_meta) = map.get("scheme") {
74                                if let Some(system) = scheme_meta.get("system") {
75                                    if system == &SchemeSystem::Tinted8.to_string() {
76                                        let scheme_inner = serde_yaml::from_value(
77                                            serde_yaml::Value::Mapping(map),
78                                        )?;
79                                        let scheme = Scheme::Tinted8(scheme_inner);
80
81                                        Ok(scheme)
82                                    } else {
83                                        Err(anyhow!("E110: Unknown or unsupported scheme system"))
84                                    }
85                                } else {
86                                    Err(anyhow!("E111: Missing required field `scheme.system`"))
87                                }
88                            } else {
89                                Err(anyhow!("E111: Missing required field `system`"))
90                            }
91                        }
92                    }
93                } else {
94                    Err(anyhow!("E112: Unable to parse scheme file"))
95                }
96            }
97        }
98    }
99
100    /// Returns the underlying path to the scheme file.
101    #[must_use]
102    pub fn get_path(&self) -> PathBuf {
103        match self {
104            Self::Yaml(path) | Self::Yml(path) => path.clone(),
105        }
106    }
107}
108
109/// Template configuration for a single output target.
110#[derive(Debug, Deserialize)]
111pub struct TemplateConfig {
112    pub filename: Option<String>,
113
114    #[serde(rename = "supported-systems")]
115    pub supported_systems: Option<Vec<SchemeSystem>>,
116
117    pub supports: Option<HashMap<String, String>>,
118
119    pub options: Option<HashMap<String, String>>,
120
121    #[deprecated]
122    pub extension: Option<String>,
123
124    #[deprecated]
125    pub output: Option<String>,
126}
127
128/// Parsed components of a generated output filename.
129#[derive(Debug)]
130pub struct ParsedFilename {
131    pub directory: PathBuf,
132    pub filestem: String,
133    pub file_extension: Option<String>,
134}
135
136impl ParsedFilename {
137    /// Returns the full path for this parsed filename.
138    #[must_use]
139    pub fn get_path(&self) -> PathBuf {
140        let directory = &self.directory;
141        let filestem = &self.filestem;
142        let file_extension = &self
143            .file_extension
144            .as_ref()
145            .map(|ext| format!(".{ext}"))
146            .unwrap_or_default();
147
148        directory.join(format!("{filestem}{file_extension}"))
149    }
150}
151
152/// Recursively retrieves scheme file paths from a directory.
153///
154/// This function traverses the given directory recursively, gathering all valid scheme files.
155/// It skips hidden files and directories (those whose names start with a `.`).
156///
157/// # Arguments
158///
159/// * `dirpath` - A reference to a `Path` representing the directory to start the search from.
160///
161/// # Returns
162///
163/// Returns a `Result` containing a `Vec<SchemeFile>` if successful, where `SchemeFile`
164/// represents a valid scheme file. If any error occurs during directory traversal or file handling,
165/// an `Err` with the relevant error information is returned.
166///
167/// # Errors
168///
169/// This function can return an error in the following scenarios:
170///
171/// * If the directory cannot be read.
172/// * If there is an issue accessing the contents of the directory.
173/// * If there is an issue creating a `SchemeFile` from a file path.
174///   Recursively collects scheme files from a directory, skipping hidden files/dirs.
175pub fn get_scheme_files(
176    dirpath: impl AsRef<Path>,
177    ignores: &[String],
178    is_recursive: bool,
179) -> Result<Vec<SchemeFile>> {
180    let glob_ignores: Vec<Glob> = ignores
181        .iter()
182        .map(|s| Glob::new(s))
183        .collect::<Result<_, _>>()?;
184
185    let mut scheme_paths: Vec<SchemeFile> = vec![];
186
187    for item in dirpath.as_ref().read_dir()? {
188        let file_path = item?.path();
189        // Skip hidden files and directories
190        if glob_ignores.iter().any(|g| g.is_match(file_path.as_path())) {
191            continue;
192        }
193
194        if file_path.is_dir() && is_recursive {
195            let inner_scheme_paths_result = get_scheme_files(&file_path, ignores, true);
196
197            if let Ok(inner_scheme_paths) = inner_scheme_paths_result {
198                scheme_paths.extend(inner_scheme_paths);
199            }
200
201            continue;
202        }
203
204        // Only attempt to create a SchemeFile for regular files
205        if file_path.is_file() {
206            let scheme_file_type_result = SchemeFile::new(&file_path);
207
208            match scheme_file_type_result {
209                Ok(scheme_file_type) => scheme_paths.push(scheme_file_type),
210                Err(err) => {
211                    // Be strict: surface invalid scheme files as intake errors
212                    return Err(err);
213                }
214            }
215        }
216    }
217
218    scheme_paths.sort_by_key(SchemeFile::get_path);
219
220    Ok(scheme_paths)
221}
222
223/// Parses a given file path into its directory, filestem, and optional extension.
224///
225/// This function takes a `template_path` (which is used as the base path for relative directories)
226/// and a `filepath` (the path to parse). It returns a `ParsedFilename` struct, which contains:
227/// - `directory`: the directory of the file (relative to `template_path` or `.` if not present)
228/// - `filestem`: the filename without the extension
229/// - `file_extension`: the optional file extension
230pub fn parse_filename(template_path: impl AsRef<Path>, filepath: &str) -> ParsedFilename {
231    let p = Path::new(filepath);
232
233    let directory: PathBuf = p.parent().map_or_else(
234        || template_path.as_ref().to_path_buf(),
235        |dir| template_path.as_ref().join(dir),
236    );
237
238    // A filestem must exist and be non-empty.
239    let filestem = p
240        .file_stem()
241        .and_then(|s| s.to_str())
242        .filter(|s| !s.is_empty())
243        .map(String::from)
244        .unwrap_or_default();
245
246    let file_extension = p.extension().and_then(|e| e.to_str()).map(String::from);
247
248    ParsedFilename {
249        directory,
250        filestem,
251        file_extension,
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use std::path::Path;
259
260    #[test]
261    fn test_parse_filename_with_directory_and_extension() {
262        let template_path = Path::new("/home/user/templates");
263        let result = parse_filename(template_path, "some-directory/name/file.txt");
264
265        assert_eq!(result.directory, template_path.join("some-directory/name"));
266        assert_eq!(result.filestem, "file");
267        assert_eq!(result.file_extension, Some("txt".to_string()));
268    }
269
270    #[test]
271    fn test_parse_filename_with_filename_and_extension() {
272        let template_path = Path::new("/home/user/templates");
273        let result = parse_filename(template_path, "filename.ext");
274
275        assert_eq!(result.directory, template_path);
276        assert_eq!(result.filestem, "filename");
277        assert_eq!(result.file_extension, Some("ext".to_string()));
278    }
279
280    #[test]
281    fn test_parse_filename_with_only_filename() {
282        let template_path = Path::new("/home/user/templates");
283        let result = parse_filename(template_path, "file");
284
285        assert_eq!(result.directory, template_path);
286        assert_eq!(result.filestem, "file");
287        assert_eq!(result.file_extension, None);
288    }
289
290    #[test]
291    fn test_parse_filename_with_directory_and_no_extension() {
292        let template_path = Path::new("/home/user/templates");
293        let result = parse_filename(template_path, "some-directory/file");
294
295        assert_eq!(result.directory, template_path.join("some-directory"));
296        assert_eq!(result.filestem, "file");
297        assert_eq!(result.file_extension, None);
298    }
299}