acorn_lib/util/
mod.rs

1//! # Common utilities
2//!
3//! Common utilities and structs used within the ACORN library
4use crate::constants::{APPLICATION, ORGANIZATION, QUALIFIER};
5use bat::PrettyPrinter;
6use bon::Builder;
7use comfy_table::modifiers::UTF8_ROUND_CORNERS;
8use comfy_table::presets::UTF8_FULL;
9use comfy_table::*;
10use console::Emoji;
11use data_encoding::HEXUPPER;
12use derive_more::Display;
13use directories::ProjectDirs;
14use duct::cmd;
15use fancy_regex::Regex;
16use glob::glob;
17use is_executable::IsExecutable;
18use nanoid::nanoid;
19use owo_colors::{OwoColorize, Style, Styled};
20use ring::digest::{Context, SHA256};
21use rust_embed::Embed;
22use serde::{Deserialize, Serialize};
23use similar::{
24    ChangeTag::{self, Delete, Equal, Insert},
25    TextDiff,
26};
27use std::fs::create_dir_all;
28use std::fs::File;
29use std::io::{copy, BufReader, Cursor, Read, Write};
30use std::path::{Path, PathBuf};
31use tracing::{debug, error, warn};
32use which::which;
33
34#[cfg(feature = "cli")]
35pub mod cli;
36
37/// MIME types
38///
39/// Supports an incomplete list of common MIME types
40///
41/// See <https://developer.mozilla.org/en-US/docs/Web/HTTP/MIME_types/Common_types>
42#[derive(Clone, Debug, Display, PartialEq)]
43pub enum MimeType {
44    /// Comma Separated Values (CSV)
45    #[display("text/csv")]
46    Csv,
47    /// Linked Data [JSON](https://www.json.org/json-en.html)
48    ///
49    /// See <https://json-ld.org/>
50    #[display("application/ld+json")]
51    LdJson,
52    /// Joint Photographic Experts Group (JPEG)
53    #[display("image/jpeg")]
54    Jpeg,
55    /// JavaScript Object Notation (JSON)
56    ///
57    /// See <https://www.json.org/json-en.html>
58    #[display("application/json")]
59    Json,
60    /// OpenType Font (OTF)
61    #[display("font/otf")]
62    Otf,
63    /// Portable Network Graphic (PNG)
64    #[display("image/png")]
65    Png,
66    /// Scalable Vector Graphic (SVG)
67    #[display("image/svg+xml")]
68    Svg,
69    /// Plain Text
70    ///
71    /// Just plain old text
72    #[display("text/plain")]
73    Text,
74}
75/// Programming languages
76///
77/// Provides a small subset of common programming languages available for syntax highlighting
78#[derive(Clone, Copy, Debug, Display)]
79pub enum ProgrammingLanguage {
80    /// HyperText Markup Language (HTML)
81    #[display("html")]
82    Html,
83    /// Markdown
84    ///
85    /// See <https://www.markdownguide.org/>
86    #[display("markdown")]
87    Markdown,
88    /// JavaScript Object Notation (JSON)
89    ///
90    /// See <https://www.json.org/json-en.html>
91    #[display("json")]
92    Json,
93    /// YAM Ain't Markup Language (YAML)
94    ///
95    /// See <https://yaml.org/>
96    #[display("yaml")]
97    Yaml,
98}
99/// Struct for using and sharing constants
100///
101/// See <https://git.sr.ht/~pyrossh/rust-embed>
102#[derive(Embed)]
103#[folder = "assets/constants/"]
104pub struct Constant;
105/// Struct for using and sharing colorized logging labels
106///
107/// ### Labels [^1]
108/// | Name    | Example Output |
109/// |---------|----------------|
110/// | Dry run | "=> DRY_RUN ■ Pretending to do a thing" |
111/// | Skip    | "=> ⚠️  Thing was skipped" |
112/// | Pass    | "=> ✅ Thing passed " |
113/// | Fail    | "=> ✗ Thing failed " |
114///
115/// [^1]: Incomplete list of examples without foreground/background coloring
116pub struct Label {}
117/// Semantic version
118///
119/// see <https://semver.org/>
120#[derive(Builder, Clone, Copy, Debug, Deserialize, Display, Serialize)]
121#[builder(start_fn = init)]
122#[display("{}.{}.{}", major, minor, patch)]
123pub struct SemanticVersion {
124    /// Version when you make incompatible API changes
125    #[builder(default = 0)]
126    pub major: u32,
127    /// Version when you add functionality in a backward compatible manner
128    #[builder(default = 0)]
129    pub minor: u32,
130    /// Version when you make backward compatible bug fixes
131    #[builder(default = 0)]
132    pub patch: u32,
133}
134impl Label {
135    /// Emoji for use when logging a warning, caution, etc.
136    pub const CAUTION: Emoji<'_, '_> = Emoji("⚠️ ", "!!! ");
137    /// Emoji for use when logging a success, pass, etc.
138    pub const CHECKMARK: Emoji<'_, '_> = Emoji("✅ ", "☑ ");
139    /// Template string to customize the progress bar
140    ///
141    /// See <https://docs.rs/indicatif/latest/indicatif/#templates>
142    pub const PROGRESS_BAR_TEMPLATE: &str = "  {spinner:.green}{pos:>5} of{len:^5}[{bar:40.green}] {msg}";
143    /// "Dry run" label
144    pub fn dry_run() -> Styled<&'static &'static str> {
145        let style = Style::new().black().on_yellow();
146        " DRY_RUN ■ ".style(style)
147    }
148    /// "Invalid" label
149    pub fn invalid() -> String {
150        Label::fmt_invalid("INVALID")
151    }
152    /// "Invalid" label formatting
153    pub fn fmt_invalid(value: &str) -> String {
154        let style = Style::new().red().on_default_color();
155        value.style(style).to_string()
156    }
157    /// "Fail" label
158    pub fn fail() -> String {
159        Label::fmt_fail("FAIL")
160    }
161    /// "Fail" label formatting
162    pub fn fmt_fail(value: &str) -> String {
163        let style = Style::new().white().on_red();
164        format!(" ✗ {value} ").style(style).to_string()
165    }
166    /// "Found" label
167    pub fn found() -> Styled<&'static &'static str> {
168        let style = Style::new().green().on_default_color();
169        "FOUND".style(style)
170    }
171    /// "Not found" label
172    pub fn not_found() -> String {
173        Label::fmt_not_found("NOT_FOUND")
174    }
175    /// "Not found" label formatting
176    pub fn fmt_not_found(value: &str) -> String {
177        let style = Style::new().red().on_default_color();
178        value.style(style).to_string()
179    }
180    /// "Output" label
181    pub fn output() -> String {
182        Label::fmt_output("OUTPUT")
183    }
184    /// "Output" label formatting
185    pub fn fmt_output(value: &str) -> String {
186        let style = Style::new().cyan().dimmed().on_default_color();
187        value.style(style).to_string()
188    }
189    /// "Pass" label
190    pub fn pass() -> String {
191        Label::fmt_pass("SUCCESS")
192    }
193    /// "Pass" label formatting
194    pub fn fmt_pass(value: &str) -> String {
195        let style = Style::new().green().bold().on_default_color();
196        format!("{}{}", Label::CHECKMARK, value).style(style).to_string()
197    }
198    /// "Read" label
199    pub fn read() -> Styled<&'static &'static str> {
200        let style = Style::new().green().on_default_color();
201        "READ".style(style)
202    }
203    /// "Skip" label
204    pub fn skip() -> String {
205        Label::fmt_skip("SKIP")
206    }
207    /// "Skip" label formatting
208    pub fn fmt_skip(value: &str) -> String {
209        let style = Style::new().yellow().on_default_color();
210        format!("{}{} ", Label::CAUTION, value).style(style).to_string()
211    }
212    /// "Using" label
213    pub fn using() -> String {
214        Label::fmt_using("USING")
215    }
216    /// "Using" label formatting
217    pub fn fmt_using(value: &str) -> String {
218        let style = Style::new().cyan();
219        value.style(style).to_string()
220    }
221}
222impl Constant {
223    /// Reads a file from the asset folder and returns its contents as a UTF-8 string.
224    ///
225    /// # Panics
226    ///
227    /// Panics if the file does not exist in the asset folder.
228    pub fn from_asset(file_name: &str) -> String {
229        match Constant::get(file_name) {
230            | Some(value) => String::from_utf8_lossy(value.data.as_ref()).into(),
231            | None => {
232                error!(file_name, "=> {} Import Constant asset", Label::fail());
233                panic!("Unable to import {file_name}")
234            }
235        }
236    }
237    /// Returns an iterator over the last values of each row in the given file.
238    ///
239    /// If a row is empty, an empty string is returned.
240    pub fn last_values(file_name: &str) -> impl Iterator<Item = String> {
241        Constant::csv(file_name)
242            .into_iter()
243            .map(|x| match x.last() {
244                | Some(value) => value.to_string(),
245                | None => "".to_string(),
246            })
247            .filter(|x| !x.is_empty())
248    }
249    /// Reads a file from the asset folder and returns its contents as an iterator over individual lines.
250    ///
251    /// # Panics
252    ///
253    /// Panics if the file does not exist in the asset folder.
254    pub fn read_lines(file_name: &str) -> Vec<String> {
255        let data = Constant::from_asset(file_name);
256        data.lines().map(String::from).collect()
257    }
258    /// Reads a CSV file from the asset folder and returns its contents as a `Vec` of `Vec<String>`,
259    /// where each inner vector represents a row and each string within the inner vector represents a cell value.
260    ///
261    /// # Arguments
262    ///
263    /// * `file_name` - A string slice representing the name of the CSV file (without extension).
264    ///
265    /// # Panics
266    ///
267    /// Panics if the file does not exist in the asset folder.
268    pub fn csv(file_name: &str) -> Vec<Vec<String>> {
269        Constant::read_lines(format!("{file_name}.csv").as_str())
270            .into_iter()
271            .map(|x| x.split(",").map(String::from).collect())
272            .collect()
273    }
274}
275impl MimeType {
276    /// Returns a `MimeType` value based on the file extension of the given file name.
277    ///
278    /// # Supported MIME types
279    ///
280    /// * `csv` - `text/csv`
281    /// * `jpg` - `image/jpeg`
282    /// * `jpeg` - `image/jpeg`
283    /// * `json` - `application/json`
284    /// * `jsonld` - `application/ld+json`
285    /// * `otf` - `font/otf`
286    /// * `png` - `image/png`
287    /// * `svg` - `image/svg+xml`
288    /// * `txt` - `text/plain`
289    ///
290    /// # Panics
291    ///
292    /// Panics if the file extension is not supported.
293    pub fn from_path<S>(file_name: S) -> MimeType
294    where
295        S: Into<String>,
296    {
297        let name = &file_name.into().to_lowercase();
298        match extension(Path::new(name)).as_str() {
299            | "csv" => MimeType::Csv,
300            | "jpg" | "jpeg" => MimeType::Jpeg,
301            | "json" => MimeType::Json,
302            | "jsonld" => MimeType::LdJson,
303            | "otf" => MimeType::Otf,
304            | "png" => MimeType::Png,
305            | "svg" => MimeType::Svg,
306            | "txt" => MimeType::Text,
307            | _ => unimplemented!("Unsupported MIME type"),
308        }
309    }
310}
311impl SemanticVersion {
312    /// Returns a `SemanticVersion` value based on the output of the `--version` command-line flag
313    /// of the given executable name.
314    ///
315    /// <div class="warning">this function only supports commands that provide a `--version` flag</div>
316    pub fn from_command<S>(name: S) -> Option<SemanticVersion>
317    where
318        S: Into<String> + duct::IntoExecutablePath + std::marker::Copy,
319    {
320        if command_exists(name.into()) {
321            let result = cmd(name, vec!["--version"]).read();
322            match result {
323                | Ok(value) => {
324                    let first_line = value.lines().collect::<Vec<_>>().first().cloned();
325                    match first_line {
326                        | Some(line) => Some(SemanticVersion::from_string(line)),
327                        | None => None,
328                    }
329                }
330                | Err(_) => None,
331            }
332        } else {
333            None
334        }
335    }
336    /// Parses a string into a `SemanticVersion` value
337    pub fn from_string<S>(value: S) -> SemanticVersion
338    where
339        S: Into<String>,
340    {
341        let value = match Regex::new(r"\d*[.]\d*[.]\d*") {
342            | Ok(re) => match re.find(&value.into()) {
343                | Ok(value) => match value {
344                    | Some(value) => value.as_str().to_string(),
345                    | None => unreachable!(),
346                },
347                | Err(_) => unreachable!(),
348            },
349            | Err(_) => unreachable!(),
350        };
351        let mut parts = value.split('.');
352        let major = parts.next().unwrap().parse::<u32>().unwrap();
353        let minor = parts.next().unwrap().parse::<u32>().unwrap();
354        let patch = parts.next().unwrap().parse::<u32>().unwrap();
355        SemanticVersion { major, minor, patch }
356    }
357}
358impl Default for SemanticVersion {
359    fn default() -> Self {
360        SemanticVersion::init().build()
361    }
362}
363/// Get SHA256 hash of a file
364///
365/// See <https://rust-lang-nursery.github.io/rust-cookbook/cryptography/hashing.html>
366pub fn checksum<P>(path: P) -> String
367where
368    P: Into<PathBuf>,
369{
370    let value = path.into();
371    match File::open(value.clone()) {
372        | Ok(file) => {
373            let mut buffer = [0; 1024];
374            let mut context = Context::new(&SHA256);
375            let mut reader = BufReader::new(file);
376            loop {
377                let count = reader.read(&mut buffer).unwrap();
378                if count == 0 {
379                    break;
380                }
381                context.update(&buffer[..count]);
382            }
383            let digest = context.finish();
384            let result = HEXUPPER.encode(digest.as_ref());
385            result.to_lowercase()
386        }
387        | Err(err) => {
388            error!(error = err.to_string(), path = path_to_string(value), "=> {} Read file", Label::fail());
389            "".to_string()
390        }
391    }
392}
393/// Checks if a given command exists in current terminal context.
394///
395/// # Arguments
396///
397/// * `name` - A string slice or `String` containing the name of the command to be checked.
398///
399/// # Return
400///
401/// A boolean indicating whether the command exists or not.
402pub fn command_exists<S>(name: S) -> bool
403where
404    S: Into<String> + AsRef<std::ffi::OsStr> + tracing::Value,
405{
406    match which(&name) {
407        | Ok(value) => {
408            let path = path_to_string(value.clone());
409            match value.try_exists() {
410                | Ok(true) => {
411                    debug!(path, "=> {} Command", Label::found());
412                    true
413                }
414                | _ => {
415                    debug!(path, "=> {} Command", Label::not_found());
416                    false
417                }
418            }
419        }
420        | Err(_) => {
421            warn!(name, "=> {} Command", Label::not_found());
422            false
423        }
424    }
425}
426/// Downloads a binary file from the given URL to the destination path.
427///
428/// # Arguments
429///
430/// * `url` - A string slice representing the URL of the binary to download.
431/// * `destination` - A path to the root directory where the file should be saved.
432///
433/// # Returns
434///
435/// A `Result` containing a `PathBuf` to the downloaded file on success, or a string error message on failure.
436pub fn download_binary<S, P>(url: S, destination: P) -> Result<PathBuf, String>
437where
438    S: Into<String> + Clone + std::marker::Copy,
439    P: Into<PathBuf> + Clone,
440{
441    async fn download<P>(url: String, destination: P) -> Result<(), String>
442    where
443        P: Into<PathBuf>,
444    {
445        let client = reqwest::Client::new();
446        let response = client.get(url.clone()).send();
447        let filename = PathBuf::from(url.clone()).file_name().unwrap().to_str().unwrap().to_string();
448        match response.await {
449            | Ok(data) => match data.bytes().await {
450                | Ok(content) => {
451                    let mut output = File::create(destination.into().join(filename.clone())).unwrap();
452                    let _ = copy(&mut Cursor::new(content.clone()), &mut output);
453                    debug!(filename = filename, "=> {} Downloaded", Label::output());
454                    Ok(())
455                }
456                | Err(_) => Err(format!("No content downloaded from {url}")),
457            },
458            | Err(_) => Err(format!("Failed to download {url}")),
459        }
460    }
461    let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
462    let _ = runtime.block_on(download(url.into(), destination.clone()));
463    let filename = PathBuf::from(url.into()).file_name().unwrap().to_str().unwrap().to_string();
464    Ok(destination.into().join(filename))
465}
466/// Get file extension
467///
468/// # Examples
469/// ```
470/// use std::path::Path;
471/// use acorn_lib::util::extension;
472///
473/// assert_eq!("txt", extension(Path::new("hello.txt")));
474/// assert_eq!("md", extension(Path::new("README.md")));
475/// assert_eq!("", extension(Path::new(".dotfile")));
476/// assert_eq!("", extension(Path::new("/path/to/folder")));
477/// ```
478pub fn extension(path: &Path) -> String {
479    path.extension().unwrap_or_default().to_str().unwrap_or_default().to_string()
480}
481/// Returns a vector of `PathBuf` containing all files in a directory that match the given extension and do not match the ignore pattern.
482///
483/// # Arguments
484///
485/// * `path` - A `PathBuf` to the directory to search.
486/// * `extension` - An `Option` containing a string slice representing the file extension to search for.
487/// * `ignore` - An `Option` containing a string representing a regex pattern to ignore files matching.
488///
489/// # Returns
490///
491/// A `Vec` containing `PathBuf` values of all files in the given directory that match the given extension and do not match the ignore pattern.
492pub fn files_all(path: PathBuf, extension: Option<&str>, ignore: Option<String>) -> Vec<PathBuf> {
493    fn paths_to_vec(paths: glob::Paths) -> Vec<PathBuf> {
494        paths.collect::<Vec<_>>().into_iter().filter_map(|x| x.ok()).collect::<Vec<_>>()
495    }
496    if path.is_dir() {
497        let pattern = match extension {
498            | Some(value) => {
499                let ext = &value.to_lowercase();
500                format!("{}/**/*.{}", path_to_string(path), ext)
501            }
502            | None => {
503                format!("{}/**/*", path_to_string(path))
504            }
505        };
506        debug!(pattern, "=> {}", Label::using());
507        let paths = glob(&pattern);
508        match ignore {
509            | Some(ignore_pattern) => match Regex::new(&ignore_pattern) {
510                | Ok(re) => match paths {
511                    | Ok(values) => paths_to_vec(values)
512                        .into_iter()
513                        .filter(|path| !re.is_match(&path_to_string(path.to_path_buf())).unwrap())
514                        .collect(),
515                    | Err(why) => {
516                        error!("=> {} Get all files - {why}", Label::fail());
517                        vec![]
518                    }
519                },
520                | Err(why) => {
521                    error!("=> {} Get all files (Regex) - {why}", Label::fail());
522                    vec![]
523                }
524            },
525            | None => match paths {
526                | Ok(values) => paths_to_vec(values),
527                | Err(why) => {
528                    error!("=> {} Get all files - {why}", Label::fail());
529                    vec![]
530                }
531            },
532        }
533    } else {
534        if extension.is_some() {
535            warn!(
536                path = path_to_string(path.clone()),
537                "=> {} Extension passed with single file to files_all() - please make sure this is desired",
538                Label::using()
539            );
540        }
541        vec![path]
542    }
543}
544/// Returns a vector of `PathBuf` containing all files changed in the given Git branch relative to the default branch.
545///
546/// # Arguments
547///
548/// * `value` - A string slice representing the name of the Git branch to check.
549/// * `extension` - An `Option` containing a string slice representing the file extension to filter results by.
550pub fn files_from_git_branch(value: &str, extension: Option<&str>) -> Vec<PathBuf> {
551    if command_exists("git".to_owned()) {
552        let default_branch = match git_default_branch_name() {
553            | Some(value) => value,
554            | None => "main".to_string(),
555        };
556        let args = vec!["diff", "--name-only", &default_branch, "--merge-base", value];
557        let result = cmd("git", args).read();
558        filter_git_command_result(result, extension)
559    } else {
560        vec![]
561    }
562}
563/// Returns a vector of `PathBuf` containing all files changed in the given Git commit.
564///
565/// # Arguments
566///
567/// * `value` - A string slice representing the Git commit hash to check.
568/// * `extension` - An `Option` containing a string slice representing the file extension to filter results by.
569pub fn files_from_git_commit(value: &str, extension: Option<&str>) -> Vec<PathBuf> {
570    if command_exists("git".to_owned()) {
571        let args = vec!["diff-tree", "--no-commit-id", "--name-only", "-r", value];
572        let result = cmd("git", args).read();
573        filter_git_command_result(result, extension)
574    } else {
575        vec![]
576    }
577}
578fn filter_git_command_result(result: Result<String, std::io::Error>, file_extension: Option<&str>) -> Vec<PathBuf> {
579    match result {
580        | Ok(value) => match file_extension {
581            | Some(ext) => value
582                .to_lowercase()
583                .split("\n")
584                .map(PathBuf::from)
585                .filter(|x| extension(Path::new(x)) == ext)
586                .collect::<Vec<_>>(),
587            | None => value.to_lowercase().split("\n").map(PathBuf::from).collect::<Vec<_>>(),
588        },
589        | Err(_) => vec![],
590    }
591}
592/// Generates a random GUID using a custom alphabet.
593///
594/// The generated GUID is a 10-character string composed of a mix of uppercase
595/// letters, lowercase letters, digits, and a hyphen. The function uses the
596/// [nanoid](https://github.com/ai/nanoid) library to ensure randomness and uniqueness of the GUID.
597///
598/// # Returns
599///
600/// A `String` representing a randomly generated GUID.
601pub fn generate_guid() -> String {
602    let alphabet = [
603        '-', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'T', 'U', 'V', 'W', 'X', 'Y', 'a', 'b', 'c', 'd',
604        'e', 'f', 'g', 'h', 'i', 'j', 'k', 'm', 'n', 'p', 'q', 'r', 't', 'w', 'x', 'y', 'z', '3', '4', '6', '7', '8', '9',
605    ];
606    let id = nanoid!(10, &alphabet);
607    debug!(id, "=> {}", Label::using());
608    id
609}
610/// Returns the current Git branch name if the `git` command is available and executed successfully.
611///
612/// This function executes the `git symbolic-ref --short HEAD` command to retrieve the name of
613/// the current Git branch. If the command is successful, the branch name is extracted and returned
614/// as a `String`. If the command fails or if `git` is not available, the function returns `None`.
615pub fn git_branch_name() -> Option<String> {
616    if command_exists("git".to_owned()) {
617        let args = vec!["symbolic-ref", "--short", "HEAD"];
618        let result = cmd("git", args).read();
619        match result {
620            | Ok(ref value) => {
621                let name = match value.clone().split("/").last() {
622                    | Some(x) => Some(x.to_string()),
623                    | None => None,
624                };
625                name
626            }
627            | Err(_) => None,
628        }
629    } else {
630        None
631    }
632}
633/// Returns the default Git branch name if the `git` command is available and executed successfully.
634///
635/// This function executes the `git symbolic-ref refs/remotes/origin/HEAD --short` command to retrieve
636/// the default Git branch name. If the command is successful, the branch name is extracted and returned
637/// as a `String`. If the command fails or if `git` is not available, the function returns `None`.
638pub fn git_default_branch_name() -> Option<String> {
639    if command_exists("git".to_owned()) {
640        let args = vec!["symbolic-ref", "refs/remotes/origin/HEAD", "--short"];
641        let result = cmd("git", args).read();
642        match result {
643            | Ok(ref value) => {
644                let name = match value.clone().split("/").last() {
645                    | Some(x) => Some(x.to_string()),
646                    | None => None,
647                };
648                name
649            }
650            | Err(_) => None,
651        }
652    } else {
653        None
654    }
655}
656/// Returns a vector of `PathBuf` representing paths to all images found in the given
657/// directory and all of its subdirectories.
658///
659/// # Arguments
660///
661/// * `root` - A value that can be converted into a `PathBuf` and implements the `Clone` trait. This is the directory in which the search for images is performed.
662///
663/// # Returns
664///
665/// A vector of `PathBuf` representing paths to all images found in the given directory and
666/// all of its subdirectories. The paths are sorted alphabetically.
667pub fn image_paths<P>(root: P) -> Vec<PathBuf>
668where
669    P: Into<PathBuf> + Clone,
670{
671    let extensions = ["jpg", "jpeg", "png", "svg"];
672    let mut files = extensions
673        .iter()
674        .flat_map(|ext| glob(&format!("{}/**/*.{}", root.clone().into().display(), ext)))
675        .flat_map(|paths| paths.collect::<Vec<_>>())
676        .flatten()
677        .collect::<Vec<PathBuf>>();
678    files.sort();
679    files
680}
681/// Makes the given file executable.
682///
683/// # Platform support
684///
685/// Platforms that support this function are:
686///
687/// * Unix
688/// * WASI
689/// * Redox
690///
691/// # Parameters
692///
693/// * `path` - A `PathBuf` containing the path to the file to be made executable.
694///
695/// # Return
696///
697/// A boolean indicating whether the file is executable after calling this function.
698#[cfg(any(unix, target_os = "wasi", target_os = "redox"))]
699pub fn make_executable<P>(path: P) -> bool
700where
701    P: Into<PathBuf> + Clone,
702{
703    use std::os::unix::fs::PermissionsExt;
704    std::fs::set_permissions(path.clone().into(), std::fs::Permissions::from_mode(0o755)).unwrap();
705    path.into().is_executable()
706}
707/// Makes the given file executable.
708///
709/// # Platform support
710///
711/// Platforms that support this function are:
712///
713/// * Windows
714///
715/// # Parameters
716///
717/// * `path` - A `PathBuf` containing the path to the file to be made executable.
718///
719/// # Return
720///
721/// A boolean indicating whether the file is executable after calling this function.
722#[cfg(windows)]
723pub fn make_executable<P>(path: P) -> bool
724where
725    P: Into<PathBuf> + Clone,
726{
727    // TODO: Add windows support...pass through?
728    path.into().is_executable()
729}
730/// Returns the parent directory of the given path.
731pub fn parent<P>(path: P) -> String
732where
733    P: Into<PathBuf>,
734{
735    PathBuf::from(path.into().parent().unwrap()).display().to_string()
736}
737/// Converts a `PathBuf` into a `String` representation of the absolute path.
738///
739/// This function attempts to canonicalize the provided path, which resolves any symbolic links
740/// and returns an absolute path. If canonicalization fails, the original path is returned as a string.
741///
742/// # Arguments
743///
744/// * `path` - A `PathBuf` representing the file system path to be converted.
745///
746/// # Returns
747///
748/// A `String` containing the absolute path if canonicalization succeeds, or the original path as a string otherwise.
749pub fn path_to_string(path: PathBuf) -> String {
750    // NOTE: fs::canonicalize might cause problems on Windows
751    let result = match std::fs::canonicalize(path.as_path()) {
752        | Ok(value) => value,
753        | Err(_) => path,
754    };
755    result.to_str().unwrap().to_string()
756}
757/// Prints `text` to stdout using syntax highlighting for the specified `syntax`.
758///
759/// `highlight` is an iterator of line numbers to highlight in the output.
760pub fn pretty_print<I: IntoIterator<Item = usize>>(text: &str, syntax: ProgrammingLanguage, highlight: I) {
761    let input = format!("{text}\n");
762    let language = syntax.to_string();
763    let mut printer = PrettyPrinter::new();
764    printer
765        .input_from_bytes(input.as_bytes())
766        .theme("zenburn")
767        .language(&language)
768        .line_numbers(true);
769    for line in highlight {
770        printer.highlight(line);
771    }
772    printer.print().unwrap();
773}
774/// Prints a diff of changes between two strings.
775///
776/// If there are no changes between `old` and `new`, prints a debug message indicating so.
777/// Otherwise, prints a unified diff of the changes, with `+` indicating lines that are
778/// present in `new` but not `old`, `-` indicating lines that are present in `old` but
779/// not `new`, and lines that are the same in both are prefixed with a space.
780pub fn print_changes(old: &str, new: &str) {
781    let changes = text_diff_changes(old, new);
782    let has_no_changes = changes.clone().into_iter().all(|(tag, _)| tag == Equal);
783    if has_no_changes {
784        debug!("=> {}No format changes", Label::skip());
785    } else {
786        for change in changes {
787            print!("{}", change.1);
788        }
789    }
790}
791// TODO: Improve flexibility (see https://rust-lang.github.io/api-guidelines/flexibility.html#c-generic)
792/// Prints the given values as a table.
793///
794/// # Arguments
795///
796/// * `title` - The title of the table.
797/// * `headers` - The headers of the table.
798/// * `rows` - The rows of the table as a vector of vectors of strings.
799pub fn print_values_as_table(title: &str, headers: Vec<&str>, rows: Vec<Vec<String>>) {
800    let mut table = Table::new();
801    table
802        .load_preset(UTF8_FULL)
803        .apply_modifier(UTF8_ROUND_CORNERS)
804        .set_content_arrangement(ContentArrangement::Dynamic)
805        .set_header(headers);
806    rows.into_iter().for_each(|row| {
807        table.add_row(row);
808    });
809    println!("=> {} \n{table}", title.green().bold());
810}
811/// Reads the given file and returns its contents as a string.
812///
813/// # Parameters
814///
815/// * `path` - A `PathBuf` or string slice containing the path to the file to be read.
816///
817/// # Return
818///
819/// A `Result` containing the contents of the file as a string if the file is readable, or an
820/// `std::io::Error` otherwise.
821pub fn read_file<P>(path: P) -> Result<String, std::io::Error>
822where
823    P: Into<PathBuf> + Clone,
824{
825    let mut content = String::new();
826    let _ = match File::open(path.clone().into()) {
827        | Ok(mut file) => {
828            debug!(path = path_to_string(path.into()), "=> {}", Label::read());
829            file.read_to_string(&mut content)
830        }
831        | Err(why) => {
832            error!(path = path_to_string(path.into()), "=> {} Cannot read file", Label::fail());
833            Err(why)
834        }
835    };
836    Ok(content)
837}
838/// Returns path to a folder in the operating system's cache directory that is unique to the given
839/// `namespace` with a random UUID as the name of the final folder.
840///
841/// The folder is ***not*** created.
842///
843/// Used primarily by ACORN CLI where `namespace` is of a subcommand task. e.g. "check", "extract", etc.
844///
845/// # Arguments
846///
847/// * `namespace` - A string slice representing the name of the namespace.
848/// * `default` - An optional `PathBuf` to use as the root directory instead of the cache directory.
849///
850/// # Returns
851///
852/// A `PathBuf` to the folder.
853pub fn standard_project_folder(namespace: &str, default: Option<PathBuf>) -> PathBuf {
854    let root = match default {
855        | Some(value) => value,
856        | None => match ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION) {
857            | Some(dirs) => dirs.cache_dir().join(namespace).to_path_buf(),
858            | None => PathBuf::from(format!("./{namespace}")),
859        },
860    };
861    match create_dir_all(root.clone()) {
862        | Ok(_) => {}
863        | Err(why) => error!(directory = path_to_string(root.clone()), "=> {} Create - {}", Label::fail(), why),
864    };
865    root.join(generate_guid())
866}
867/// Returns "s" if the given value is not 1, otherwise returns an empty string.
868pub fn suffix(value: usize) -> String {
869    (if value == 1 { "" } else { "s" }).to_string()
870}
871/// Computes the differences between two strings line by line and returns a vector of changes.
872///
873/// Each change is represented as a tuple containing a `ChangeTag` indicating the type of change
874/// (deletion, insertion, or equality) and a `String` with the formatted line prefixed with a
875/// symbol indicating the type of change (`-` for deletions, `+` for insertions, and a space for equal lines).
876///
877/// The formatted string is also colored: red for deletions, green for insertions, and dimmed for equal lines.
878///
879/// # Arguments
880///
881/// * `old` - A string slice representing the original text.
882/// * `new` - A string slice representing the modified text.
883///
884/// # Returns
885///
886/// A vector of tuples, each containing a `ChangeTag` and a formatted `String` representing the changes.
887pub fn text_diff_changes(old: &str, new: &str) -> Vec<(ChangeTag, String)> {
888    TextDiff::from_lines(old, new)
889        .iter_all_changes()
890        .map(|line| {
891            let tag = line.tag();
892            let text = match tag {
893                | Delete => format!("- {line}").red().to_string(),
894                | Insert => format!("+ {line}").green().to_string(),
895                | Equal => format!("  {line}").dimmed().to_string(),
896            };
897            (tag, text)
898        })
899        .collect::<Vec<_>>()
900}
901/// Create a new [Tokio](https://tokio.rs/) runtime
902pub fn tokio_runtime() -> tokio::runtime::Runtime {
903    debug!("=> {} Tokio runtime", Label::using());
904    tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap()
905}
906/// Convert a vector of string slices to a vector of strings
907pub fn to_string(values: Vec<&str>) -> Vec<String> {
908    values.iter().map(|s| s.to_string()).collect()
909}
910/// Writes the given content to a file at the given path.
911///
912/// # Arguments
913///
914/// * `path` - A `PathBuf` or string slice containing the path to the file to be written.
915/// * `content` - A `String` containing the content to be written to the file.
916///
917/// # Return
918///
919/// A `Result` containing a unit value if the file is written successfully, or an
920/// `std::io::Error` otherwise.
921pub fn write_file<P>(path: P, content: String) -> Result<(), std::io::Error>
922where
923    P: Into<PathBuf>,
924{
925    match File::create(path.into().clone()) {
926        | Ok(mut file) => {
927            file.write_all(content.as_bytes()).unwrap();
928            file.flush()
929        }
930        | Err(why) => {
931            error!("=> {} Cannot create file - {why}", Label::fail());
932            Err(why)
933        }
934    }
935}
936
937#[cfg(test)]
938mod tests;