acorn_lib/util/
mod.rs

1//! # Common utilities
2//!
3//! This module contains common functions and data structures used to build the ACORN command line interface as well support open science endeavors.
4//!
5use crate::constants::{APPLICATION, ORGANIZATION, QUALIFIER};
6use bat::PrettyPrinter;
7use bon::Builder;
8use comfy_table::modifiers::UTF8_ROUND_CORNERS;
9use comfy_table::presets::UTF8_FULL;
10use comfy_table::*;
11use console::Emoji;
12use data_encoding::HEXUPPER;
13use derive_more::Display;
14use directories::ProjectDirs;
15use duct::cmd;
16use fancy_regex::Regex;
17use glob::glob;
18use is_executable::IsExecutable;
19use itertools::Itertools;
20use lychee_lib::{CacheStatus, Response, Status};
21use nanoid::nanoid;
22use owo_colors::{OwoColorize, Style, Styled};
23use ring::digest::{Context, SHA256};
24use rust_embed::Embed;
25use schemars::JsonSchema;
26use serde::{Deserialize, Serialize};
27use similar::{
28    ChangeTag::{self, Delete, Equal, Insert},
29    TextDiff,
30};
31use std::fs::create_dir_all;
32use std::fs::File;
33use std::io::{copy, BufReader, Cursor, Read, Write};
34use std::path::{Path, PathBuf};
35use titlecase::titlecase;
36use tracing::{debug, error, info, warn};
37use validator::ValidationErrorsKind;
38use which::which;
39
40pub mod citeas;
41#[cfg(feature = "cli")]
42pub mod cli;
43
44/// SPDX compliant license identifier
45///
46/// See <https://spdx.org/licenses/>
47#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
48pub enum License {
49    /// MIT License
50    Mit,
51    /// Creative Commons
52    CreativeCommons,
53    /// Unknown license
54    Unknown,
55}
56/// MIME types
57///
58/// Supports an incomplete list of common MIME types
59///
60/// See <https://developer.mozilla.org/en-US/docs/Web/HTTP/MIME_types/Common_types> and <https://mimetype.io/all-types> for more information
61#[derive(Clone, Debug, Display, PartialEq)]
62pub enum MimeType {
63    /// Citation File Format (CFF)
64    /// ### Note
65    /// > CFF does not have a standard MIME type, but is valid YAML
66    ///
67    /// See <https://citation-file-format.github.io/> for more information
68    #[display("application/yaml")]
69    Cff,
70    /// Comma Separated Values (CSV)
71    #[display("text/csv")]
72    Csv,
73    /// Linked Data [JSON](https://www.json.org/json-en.html)
74    ///
75    /// See <https://json-ld.org/>
76    #[display("application/ld+json")]
77    LdJson,
78    /// Joint Photographic Experts Group (JPEG)
79    #[display("image/jpeg")]
80    Jpeg,
81    /// JavaScript Object Notation (JSON)
82    ///
83    /// See <https://www.json.org/json-en.html>
84    #[display("application/json")]
85    Json,
86    /// Markdown
87    #[display("text/markdown")]
88    Markdown,
89    /// OpenType Font (OTF)
90    #[display("font/otf")]
91    Otf,
92    /// Portable Network Graphic (PNG)
93    #[display("image/png")]
94    Png,
95    /// Rust Source Code (RS)
96    #[display("text/rust")]
97    Rust,
98    /// Scalable Vector Graphic (SVG)
99    #[display("image/svg+xml")]
100    Svg,
101    /// Plain Text
102    ///
103    /// Just plain old text
104    #[display("text/plain")]
105    Text,
106    /// Tom's Obvious Minimal Language (TOML)
107    ///
108    /// See <https://toml.io/>
109    #[display("application/toml")]
110    Toml,
111    /// YAML Ain't Markup Language (YAML)
112    ///
113    /// See <https://yaml.org/>
114    #[display("application/yaml")]
115    Yaml,
116    /// Unknown MIME type
117    #[display("application/unknown")]
118    Unknown,
119}
120/// Programming languages
121///
122/// Provides a small subset of common programming languages available for syntax highlighting
123#[derive(Clone, Copy, Debug, Display)]
124pub enum ProgrammingLanguage {
125    /// HyperText Markup Language (HTML)
126    #[display("html")]
127    Html,
128    /// Markdown
129    ///
130    /// See <https://www.markdownguide.org/>
131    #[display("markdown")]
132    Markdown,
133    /// JavaScript Object Notation (JSON)
134    ///
135    /// See <https://www.json.org/json-en.html>
136    #[display("json")]
137    Json,
138    /// YAM Ain't Markup Language (YAML)
139    ///
140    /// See <https://yaml.org/>
141    #[display("yaml")]
142    Yaml,
143}
144/// Struct for using and sharing constants
145///
146/// See <https://git.sr.ht/~pyrossh/rust-embed>
147#[derive(Embed)]
148#[folder = "assets/constants/"]
149pub struct Constant;
150/// Struct for using and sharing colorized logging labels
151///
152/// ### Labels [^1]
153/// | Name    | Example Output |
154/// |---------|----------------|
155/// | Dry run | "=> DRY_RUN ■ Pretending to do a thing" |
156/// | Skip    | "=> ⚠️  Thing was skipped" |
157/// | Pass    | "=> ✅ Thing passed " |
158/// | Fail    | "=> ✗ Thing failed " |
159///
160/// [^1]: Incomplete list of examples without foreground/background coloring
161pub struct Label {}
162/// Data structure for holding the result of a link check
163#[derive(Builder, Clone, Debug, Display)]
164#[builder(start_fn = init)]
165#[display("{message}")]
166pub struct LinkCheck {
167    /// Whether or not the check was successful
168    #[builder(default = false)]
169    pub success: bool,
170    /// HTTP status code
171    pub code: Option<String>,
172    /// URL
173    // TODO: Normalize URL as URI
174    pub url: Option<String>,
175    /// Message describing the HTTP status code
176    pub message: String,
177}
178/// Data structure for holding the result of a schema validation check
179#[derive(Builder, Clone, Debug)]
180#[builder(start_fn = init)]
181pub struct SchemaCheck {
182    /// Whether or not the check was successful
183    #[builder(default = false)]
184    pub success: bool,
185    /// Errors found during validation
186    pub errors: Option<ValidationErrorsKind>,
187    /// Path of file being validated
188    // TODO: Normalize path as URI (file://)
189    pub path: Option<PathBuf>,
190    /// Message related to or description of validation issue (e.g., key name of invalid value, result of validation, etc.)
191    pub message: String,
192}
193/// Semantic version
194///
195/// see <https://semver.org/>
196#[derive(Builder, Clone, Copy, Debug, Deserialize, Display, Serialize, JsonSchema)]
197#[builder(start_fn = init)]
198#[display("{}.{}.{}", major, minor, patch)]
199pub struct SemanticVersion {
200    /// Version when you make incompatible API changes
201    #[builder(default = 0)]
202    pub major: u32,
203    /// Version when you add functionality in a backward compatible manner
204    #[builder(default = 0)]
205    pub minor: u32,
206    /// Version when you make backward compatible bug fixes
207    #[builder(default = 0)]
208    pub patch: u32,
209}
210impl Constant {
211    /// Reads a file from the asset folder and returns its contents as a UTF-8 string.
212    ///
213    /// # Panics
214    ///
215    /// Panics if the file does not exist in the asset folder.
216    pub fn from_asset(file_name: &str) -> String {
217        match Constant::get(file_name) {
218            | Some(value) => String::from_utf8_lossy(value.data.as_ref()).into(),
219            | None => {
220                error!(file_name, "=> {} Import Constant asset", Label::fail());
221                panic!("Unable to import {file_name}")
222            }
223        }
224    }
225    /// Returns an iterator over the last values of each row in the given file.
226    ///
227    /// If a row is empty, an empty string is returned.
228    pub fn last_values(file_name: &str) -> impl Iterator<Item = String> {
229        Constant::csv(file_name)
230            .into_iter()
231            .map(|x| match x.last() {
232                | Some(value) => value.to_string(),
233                | None => "".to_string(),
234            })
235            .filter(|x| !x.is_empty())
236    }
237    /// Reads a file from the asset folder and returns its contents as an iterator over individual lines.
238    ///
239    /// # Panics
240    ///
241    /// Panics if the file does not exist in the asset folder.
242    pub fn read_lines(file_name: &str) -> Vec<String> {
243        let data = Constant::from_asset(file_name);
244        data.lines().map(String::from).collect()
245    }
246    /// Reads a CSV file from the asset folder and returns its contents as a `Vec` of `Vec<String>`,
247    /// where each inner vector represents a row and each string within the inner vector represents a cell value.
248    ///
249    /// # Arguments
250    ///
251    /// * `file_name` - A string slice representing the name of the CSV file (without extension).
252    ///
253    /// # Panics
254    ///
255    /// Panics if the file does not exist in the asset folder.
256    pub fn csv(file_name: &str) -> Vec<Vec<String>> {
257        Constant::read_lines(format!("{file_name}.csv").as_str())
258            .into_iter()
259            .map(|x| x.split(",").map(String::from).collect())
260            .collect()
261    }
262}
263impl Label {
264    /// Emoji for use when logging a warning, caution, etc.
265    pub const CAUTION: Emoji<'_, '_> = Emoji("⚠️ ", "!!! ");
266    /// Emoji for use when logging a success, pass, etc.
267    pub const CHECKMARK: Emoji<'_, '_> = Emoji("✅ ", "☑ ");
268    /// Template string to customize the progress bar
269    ///
270    /// See <https://docs.rs/indicatif/latest/indicatif/#templates>
271    pub const PROGRESS_BAR_TEMPLATE: &str = "  {spinner:.green}{pos:>5} of{len:^5}[{bar:40.green}] {msg}";
272    /// "Dry run" label
273    pub fn dry_run() -> Styled<&'static &'static str> {
274        let style = Style::new().black().on_yellow();
275        " DRY_RUN ■ ".style(style)
276    }
277    /// "Invalid" label
278    pub fn invalid() -> String {
279        Label::fmt_invalid(" ✗ INVALID")
280    }
281    /// "Invalid" label formatting
282    pub fn fmt_invalid(value: &str) -> String {
283        let style = Style::new().red().on_default_color();
284        value.style(style).to_string()
285    }
286    /// "Valid" label
287    pub fn valid() -> String {
288        Label::fmt_valid(" ✓ VALID  ")
289    }
290    /// "Invalid" label formatting
291    pub fn fmt_valid(value: &str) -> String {
292        let style = Style::new().green().on_default_color();
293        value.style(style).to_string()
294    }
295    /// "Fail" label
296    pub fn fail() -> String {
297        Label::fmt_fail("FAIL")
298    }
299    /// "Fail" label formatting
300    pub fn fmt_fail(value: &str) -> String {
301        let style = Style::new().white().on_red();
302        format!(" ✗ {value} ").style(style).to_string()
303    }
304    /// "Found" label
305    pub fn found() -> Styled<&'static &'static str> {
306        let style = Style::new().green().on_default_color();
307        "FOUND".style(style)
308    }
309    /// "Not found" label
310    pub fn not_found() -> String {
311        Label::fmt_not_found("NOT_FOUND")
312    }
313    /// "Not found" label formatting
314    pub fn fmt_not_found(value: &str) -> String {
315        let style = Style::new().red().on_default_color();
316        value.style(style).to_string()
317    }
318    /// "Output" label
319    pub fn output() -> String {
320        Label::fmt_output("OUTPUT")
321    }
322    /// "Output" label formatting
323    pub fn fmt_output(value: &str) -> String {
324        let style = Style::new().cyan().dimmed().on_default_color();
325        value.style(style).to_string()
326    }
327    /// "Pass" label
328    pub fn pass() -> String {
329        Label::fmt_pass("SUCCESS")
330    }
331    /// "Pass" label formatting
332    pub fn fmt_pass(value: &str) -> String {
333        let style = Style::new().green().bold().on_default_color();
334        format!("{}{}", Label::CHECKMARK, value).style(style).to_string()
335    }
336    /// "Read" label
337    pub fn read() -> Styled<&'static &'static str> {
338        let style = Style::new().green().on_default_color();
339        "READ".style(style)
340    }
341    /// "Skip" label
342    pub fn skip() -> String {
343        Label::fmt_skip("SKIP")
344    }
345    /// "Skip" label formatting
346    pub fn fmt_skip(value: &str) -> String {
347        let style = Style::new().yellow().on_default_color();
348        format!("{}{} ", Label::CAUTION, value).style(style).to_string()
349    }
350    /// "Using" label
351    pub fn using() -> String {
352        Label::fmt_using("USING")
353    }
354    /// "Using" label formatting
355    pub fn fmt_using(value: &str) -> String {
356        let style = Style::new().cyan();
357        value.style(style).to_string()
358    }
359}
360impl LinkCheck {
361    /// Converts a Lychee response into a LinkCheckResult
362    pub fn from_lychee(response: Response) -> Self {
363        match response.status() {
364            | Status::Ok(code) | Status::Redirected(code) => LinkCheck::init()
365                .success(true)
366                .code(code.to_string())
367                .message("has no HTTP errors".to_string())
368                .build(),
369            | Status::Cached(status) => match status {
370                | CacheStatus::Ok(code) => LinkCheck::init()
371                    .success(true)
372                    .code(code.to_string())
373                    .message("has no HTTP errors".to_string())
374                    .build(),
375                | CacheStatus::Error(Some(code)) => LinkCheck::init()
376                    .success(false)
377                    .code(code.to_string())
378                    .message("has cached HTTP errors".to_string())
379                    .build(),
380                | CacheStatus::Unsupported => LinkCheck::init()
381                    .success(false)
382                    .message("unsupported cached response".to_string())
383                    .build(),
384                | _ => LinkCheck::init()
385                    .success(true)
386                    .message("ignored or otherwise successful (cached response)".to_string())
387                    .build(),
388            },
389            | Status::Error(code) => LinkCheck::init()
390                .success(false)
391                .code(code.to_string())
392                .message("has HTTP errors".to_string())
393                .build(),
394            | Status::Unsupported(why) => LinkCheck::init()
395                .success(false)
396                .message(format!("unsupported HTTP response - {why}"))
397                .build(),
398            | Status::UnknownStatusCode(code) => LinkCheck::init()
399                .success(false)
400                .code(code.to_string())
401                .message("unknown HTTP response".to_string())
402                .build(),
403            | Status::Timeout(_) => LinkCheck::init().success(false).message("HTTP timeout".to_string()).build(),
404            | _ => LinkCheck::init()
405                .success(true)
406                .message("ignored or otherwise successful".to_string())
407                .build(),
408        }
409    }
410    /// Perform link check on given URL
411    pub async fn run(url: Option<String>) -> LinkCheck {
412        match url {
413            | Some(url) => {
414                let response = lychee_lib::check(url.as_str()).await;
415                match response {
416                    | Ok(response) => LinkCheck::from_lychee(response).with_url(url),
417                    | Err(_) => LinkCheck::init().success(false).url(url).message("unreachable".to_string()).build(),
418                }
419            }
420            | None => LinkCheck::init().success(false).message("missing URL".to_string()).build(),
421        }
422    }
423    /// Print the link check results
424    pub fn print(self) {
425        let code = match self.code {
426            | Some(code) => format!(" ({code})").dimmed().to_string(),
427            | None => "".to_string(),
428        };
429        let url = match self.url {
430            | Some(url) => url.underline().italic().to_string(),
431            | None => "Missing".italic().to_string(),
432        };
433        if self.success {
434            let message = titlecase(&self.message).green().bold().to_string();
435            info!("=> {} \"{url}\" {message}{code}", Label::valid());
436        } else {
437            let message = titlecase(&self.message).red().bold().to_string();
438            error!("=> {} \"{url}\" {message}{code}", Label::invalid());
439        }
440    }
441    /// Returns a new LinkCheckResult with the given URL
442    pub fn with_url(self, value: String) -> Self {
443        LinkCheck::init()
444            .success(self.success)
445            .url(value)
446            .maybe_code(self.code)
447            .message(self.message)
448            .build()
449    }
450}
451impl MimeType {
452    /// Returns the file type as a string
453    pub fn file_type(self) -> String {
454        match self {
455            | MimeType::Cff => "cff",
456            | MimeType::Csv => "csv",
457            | MimeType::Jpeg => "jpeg",
458            | MimeType::Json => "json",
459            | MimeType::LdJson => "jsonld",
460            | MimeType::Markdown => "md",
461            | MimeType::Otf => "otf",
462            | MimeType::Png => "png",
463            | MimeType::Rust => "rs",
464            | MimeType::Svg => "svg",
465            | MimeType::Text => "txt",
466            | MimeType::Toml => "toml",
467            | MimeType::Yaml => "yaml",
468            | _ => "unknown-file-type",
469        }
470        .to_string()
471    }
472    /// Returns a `MimeType` value based on the file extension of the given file name.
473    ///
474    /// Uses [`MimeType::from_string`].
475    pub fn from_path<P>(value: P) -> MimeType
476    where
477        P: Into<PathBuf>,
478    {
479        MimeType::from_string(path_to_string(value.into()))
480    }
481    /// Returns a `MimeType` value based on the file extension of the given file name.
482    ///
483    /// # Supported MIME types
484    ///
485    /// | File Extension | MIME Type |
486    /// | --- | --- |
487    /// | cff | application/yaml |
488    /// | csv | text/csv |
489    /// | jpg | image/jpeg |
490    /// | jpeg | image/jpeg |
491    /// | json | application/json |
492    /// | jsonld | application/ld+json |
493    /// | md | text/markdown |
494    /// | otf | font/otf |
495    /// | png | image/png |
496    /// | rs | text/rust |
497    /// | svg | image/svg+xml |
498    /// | toml | application/toml |
499    /// | txt | text/plain |
500    /// | yaml | application/yaml |
501    pub fn from_string<S>(value: S) -> MimeType
502    where
503        S: Into<String>,
504    {
505        let name = &value.into().to_lowercase();
506        match extension(Path::new(name)).as_str() {
507            | "csv" => MimeType::Csv,
508            | "jpg" | "jpeg" => MimeType::Jpeg,
509            | "json" => MimeType::Json,
510            | "jsonld" | "json-ld" => MimeType::LdJson,
511            | "md" | "markdown" => MimeType::Markdown,
512            | "otf" => MimeType::Otf,
513            | "png" => MimeType::Png,
514            | "rs" => MimeType::Rust,
515            | "svg" => MimeType::Svg,
516            | "toml" => MimeType::Toml,
517            | "txt" => MimeType::Text,
518            | "yml" | "yaml" | "cff" => MimeType::Yaml,
519            | _ => MimeType::Unknown,
520        }
521    }
522}
523impl SchemaCheck {
524    /// Returns the number of errors
525    pub fn issue_count(&self) -> usize {
526        if let Some(errors) = &self.errors {
527            match errors.clone() {
528                | ValidationErrorsKind::Field(_) => 1,
529                | ValidationErrorsKind::Struct(errors) => errors.into_errors().len(),
530                | ValidationErrorsKind::List(_) => 0,
531            }
532        } else {
533            0
534        }
535    }
536    /// Print the schema check results
537    pub fn print(self) {
538        let path = self.clone().path.unwrap().display().to_string();
539        if self.success {
540            info!("=> {} {} has {}", Label::pass(), path, "no schema validation issues".green().bold());
541        } else {
542            let count = self.issue_count();
543            error!(
544                "=> {} Found {} schema validation issue{} in {}: \n{:#?}",
545                Label::fail(),
546                count.red(),
547                suffix(count),
548                path.italic().underline(),
549                self.errors.unwrap()
550            );
551        }
552    }
553    /// Returns a new LinkCheckResult with the given URL
554    pub fn with_path(self, value: PathBuf) -> Self {
555        SchemaCheck::init()
556            .success(self.success)
557            .maybe_errors(self.errors)
558            .path(value)
559            .message(self.message)
560            .build()
561    }
562}
563impl SemanticVersion {
564    /// Returns a `SemanticVersion` value based on the output of the `--version` command-line flag
565    /// of the given executable name.
566    ///
567    /// <div class="warning">this function only supports commands that provide a `--version` flag</div>
568    pub fn from_command<S>(name: S) -> Option<SemanticVersion>
569    where
570        S: Into<String> + duct::IntoExecutablePath + std::marker::Copy,
571    {
572        if command_exists(name.into()) {
573            let result = cmd(name, vec!["--version"]).read();
574            match result {
575                | Ok(value) => {
576                    let first_line = value.lines().collect::<Vec<_>>().first().cloned();
577                    match first_line {
578                        | Some(line) => Some(SemanticVersion::from_string(line)),
579                        | None => None,
580                    }
581                }
582                | Err(_) => None,
583            }
584        } else {
585            None
586        }
587    }
588    /// Parses a string into a `SemanticVersion` value
589    pub fn from_string<S>(value: S) -> SemanticVersion
590    where
591        S: Into<String>,
592    {
593        let value = match Regex::new(r"\d*[.]\d*[.]\d*") {
594            | Ok(re) => match re.find(&value.into()) {
595                | Ok(value) => match value {
596                    | Some(value) => value.as_str().to_string(),
597                    | None => unreachable!(),
598                },
599                | Err(_) => unreachable!(),
600            },
601            | Err(_) => unreachable!(),
602        };
603        let mut parts = value.split('.');
604        let major = parts.next().unwrap().parse::<u32>().unwrap();
605        let minor = parts.next().unwrap().parse::<u32>().unwrap();
606        let patch = parts.next().unwrap().parse::<u32>().unwrap();
607        SemanticVersion { major, minor, patch }
608    }
609}
610impl Default for SemanticVersion {
611    fn default() -> Self {
612        SemanticVersion::init().build()
613    }
614}
615/// Get SHA256 hash of a file
616///
617/// See <https://rust-lang-nursery.github.io/rust-cookbook/cryptography/hashing.html>
618pub fn checksum<P>(path: P) -> String
619where
620    P: Into<PathBuf>,
621{
622    let value = path.into();
623    match File::open(value.clone()) {
624        | Ok(file) => {
625            let mut buffer = [0; 1024];
626            let mut context = Context::new(&SHA256);
627            let mut reader = BufReader::new(file);
628            loop {
629                let count = reader.read(&mut buffer).unwrap();
630                if count == 0 {
631                    break;
632                }
633                context.update(&buffer[..count]);
634            }
635            let digest = context.finish();
636            let result = HEXUPPER.encode(digest.as_ref());
637            result.to_lowercase()
638        }
639        | Err(err) => {
640            error!(error = err.to_string(), path = path_to_string(value), "=> {} Read file", Label::fail());
641            "".to_string()
642        }
643    }
644}
645/// Checks if a given command exists in current terminal context.
646///
647/// # Arguments
648///
649/// * `name` - A string slice or `String` containing the name of the command to be checked.
650///
651/// # Return
652///
653/// A boolean indicating whether the command exists or not.
654pub fn command_exists<S>(name: S) -> bool
655where
656    S: Into<String> + AsRef<std::ffi::OsStr> + tracing::Value,
657{
658    match which(&name) {
659        | Ok(value) => {
660            let path = path_to_string(value.clone());
661            match value.try_exists() {
662                | Ok(true) => {
663                    debug!(path, "=> {} Command", Label::found());
664                    true
665                }
666                | _ => {
667                    debug!(path, "=> {} Command", Label::not_found());
668                    false
669                }
670            }
671        }
672        | Err(_) => {
673            warn!(name, "=> {} Command", Label::not_found());
674            false
675        }
676    }
677}
678/// Downloads a binary file from the given URL to the destination path.
679///
680/// # Arguments
681///
682/// * `url` - A string slice representing the URL of the binary to download.
683/// * `destination` - A path to the root directory where the file should be saved.
684///
685/// # Returns
686///
687/// A `Result` containing a `PathBuf` to the downloaded file on success, or a string error message on failure.
688pub fn download_binary<S, P>(url: S, destination: P) -> Result<PathBuf, String>
689where
690    S: Into<String> + Clone + std::marker::Copy,
691    P: Into<PathBuf> + Clone,
692{
693    async fn download<P>(url: String, destination: P) -> Result<(), String>
694    where
695        P: Into<PathBuf>,
696    {
697        let client = reqwest::Client::new();
698        let response = client.get(url.clone()).send();
699        let filename = PathBuf::from(url.clone()).file_name().unwrap().to_str().unwrap().to_string();
700        match response.await {
701            | Ok(data) => match data.bytes().await {
702                | Ok(content) => {
703                    let mut output = File::create(destination.into().join(filename.clone())).unwrap();
704                    let _ = copy(&mut Cursor::new(content.clone()), &mut output);
705                    debug!(filename = filename, "=> {} Downloaded", Label::output());
706                    Ok(())
707                }
708                | Err(_) => Err(format!("No content downloaded from {url}")),
709            },
710            | Err(_) => Err(format!("Failed to download {url}")),
711        }
712    }
713    let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
714    let _ = runtime.block_on(download(url.into(), destination.clone()));
715    let filename = PathBuf::from(url.into()).file_name().unwrap().to_str().unwrap().to_string();
716    Ok(destination.into().join(filename))
717}
718/// Get file extension
719///
720/// # Examples
721/// ```
722/// use std::path::Path;
723/// use acorn_lib::util::extension;
724///
725/// assert_eq!("txt", extension(Path::new("hello.txt")));
726/// assert_eq!("md", extension(Path::new("README.md")));
727/// assert_eq!("", extension(Path::new(".dotfile")));
728/// assert_eq!("", extension(Path::new("/path/to/folder")));
729/// ```
730pub fn extension(path: &Path) -> String {
731    path.extension().unwrap_or_default().to_str().unwrap_or_default().to_string()
732}
733/// Returns a vector of `PathBuf` containing all files in a directory that match at least one of the given extensions.
734///
735/// # Arguments
736///
737/// * `path` - A `PathBuf` to the directory to search.
738/// * `extensions` - An `Option` containing a list of string slice(s) representing the file extension(s) to search for.
739///
740/// # Returns
741///
742/// A `Vec` containing `PathBuf` values of all files in the given directory that match at least one of the given extensions.
743pub fn files_all(path: PathBuf, extensions: Option<Vec<&str>>) -> Vec<PathBuf> {
744    fn paths_to_vec(paths: glob::Paths) -> Vec<PathBuf> {
745        paths.collect::<Vec<_>>().into_iter().filter_map(|x| x.ok()).collect::<Vec<_>>()
746    }
747    fn pattern(path: PathBuf, extension: &str) -> String {
748        let ext = &extension.to_lowercase();
749        let result = format!("{}/**/*.{}", path_to_string(path), ext);
750        debug!("=> {} {result}", Label::using());
751        result
752    }
753    if path.is_dir() {
754        match extensions {
755            | Some(values) => values
756                .into_iter()
757                .map(|extension| {
758                    let glob_pattern = pattern(path.clone(), extension);
759                    glob(&glob_pattern)
760                })
761                .filter(|x| x.is_ok())
762                .flat_map(|x| paths_to_vec(x.unwrap()))
763                .unique()
764                .collect::<Vec<PathBuf>>(),
765            | None => match glob(&format!("{}/**/*", path_to_string(path))) {
766                | Ok(paths) => paths_to_vec(paths),
767                | Err(why) => {
768                    error!("=> {} Get all files (Glob) - {why}", Label::fail());
769                    vec![]
770                }
771            },
772        }
773    } else {
774        if extensions.is_some() {
775            warn!(
776                path = path_to_string(path.clone()),
777                "=> {} Extension passed with single file to files_all() - please make sure this is desired",
778                Label::using()
779            );
780        }
781        vec![path]
782    }
783}
784/// Returns a vector of `PathBuf` containing all files changed in the given Git branch relative to the default branch.
785///
786/// # Arguments
787///
788/// * `value` - A string slice representing the name of the Git branch to check.
789/// * `extension` - An `Option` containing a string slice representing the file extension to filter results by.
790pub fn files_from_git_branch(value: &str, extensions: Option<Vec<&str>>) -> Vec<PathBuf> {
791    if command_exists("git".to_owned()) {
792        let default_branch = match git_default_branch_name() {
793            | Some(value) => value,
794            | None => "main".to_string(),
795        };
796        let args = vec!["diff", "--name-only", &default_branch, "--merge-base", value];
797        let result = cmd("git", args).read();
798        filter_git_command_result(result, extensions)
799    } else {
800        vec![]
801    }
802}
803/// Returns a vector of `PathBuf` containing all files changed in the given Git commit.
804///
805/// # Arguments
806///
807/// * `value` - A string slice representing the Git commit hash to check.
808/// * `extension` - An `Option` containing a string slice representing the file extension to filter results by.
809pub fn files_from_git_commit(value: &str, extensions: Option<Vec<&str>>) -> Vec<PathBuf> {
810    if command_exists("git".to_owned()) {
811        let args = vec!["diff-tree", "--no-commit-id", "--name-only", "-r", value];
812        let result = cmd("git", args).read();
813        debug!("=> {} Git command response - {result:?}", Label::using());
814        let files = filter_git_command_result(result, extensions);
815        debug!(
816            "=> {} Found {} file{} from Git commit - {files:?}",
817            Label::using(),
818            files.len(),
819            suffix(files.len())
820        );
821        files
822    } else {
823        vec![]
824    }
825}
826fn filter_git_command_result(result: Result<String, std::io::Error>, extensions: Option<Vec<&str>>) -> Vec<PathBuf> {
827    match result {
828        | Ok(value) => match extensions {
829            | Some(values) => value
830                .to_lowercase()
831                .split("\n")
832                .map(PathBuf::from)
833                .filter(|path| values.iter().any(|ext| MimeType::from_path(path).file_type() == *ext.to_lowercase()))
834                .collect::<Vec<_>>(),
835            | None => value.to_lowercase().split("\n").map(PathBuf::from).collect::<Vec<_>>(),
836        },
837        | Err(_) => vec![],
838    }
839}
840/// Return file paths in a vector that don't match the ignore pattern
841pub fn filter_ignored(paths: Vec<PathBuf>, ignore: Option<String>) -> Vec<PathBuf> {
842    match ignore {
843        | Some(ignore_pattern) => match Regex::new(&ignore_pattern) {
844            | Ok(re) => paths
845                .into_iter()
846                .map(path_to_string)
847                .filter(|x| !re.is_match(x).unwrap())
848                .map(PathBuf::from)
849                .collect(),
850            | Err(why) => {
851                error!("=> {} Filter ignored - {why}", Label::fail());
852                vec![]
853            }
854        },
855        | None => paths,
856    }
857}
858/// Return fisrt key/value pair with key that matches pattern
859pub fn find_first(values: Vec<(String, String)>, pattern: &str) -> Option<(String, String)> {
860    let results = values
861        .clone()
862        .into_iter()
863        .filter(|x| !x.1.is_empty())
864        .find(|(key, _)| key.starts_with(pattern));
865    match results {
866        | Some(value) => Some(value),
867        | None => None,
868    }
869}
870/// Generates a random GUID using a custom alphabet.
871///
872/// The generated GUID is a 10-character string composed of a mix of uppercase
873/// letters, lowercase letters, digits, and a hyphen. The function uses the
874/// [nanoid](https://github.com/ai/nanoid) library to ensure randomness and uniqueness of the GUID.
875///
876/// # Returns
877///
878/// A `String` representing a randomly generated GUID.
879pub fn generate_guid() -> String {
880    let alphabet = [
881        '-', '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',
882        'e', 'f', 'g', 'h', 'i', 'j', 'k', 'm', 'n', 'p', 'q', 'r', 't', 'w', 'x', 'y', 'z', '3', '4', '6', '7', '8', '9',
883    ];
884    let id = nanoid!(10, &alphabet);
885    debug!(id, "=> {}", Label::using());
886    id
887}
888/// Returns the current Git branch name if the `git` command is available and executed successfully.
889///
890/// This function executes the `git symbolic-ref --short HEAD` command to retrieve the name of
891/// the current Git branch. If the command is successful, the branch name is extracted and returned
892/// as a `String`. If the command fails or if `git` is not available, the function returns `None`.
893pub fn git_branch_name() -> Option<String> {
894    if command_exists("git".to_owned()) {
895        let args = vec!["symbolic-ref", "--short", "HEAD"];
896        let result = cmd("git", args).read();
897        match result {
898            | Ok(ref value) => {
899                let name = match value.clone().split("/").last() {
900                    | Some(x) => Some(x.to_string()),
901                    | None => None,
902                };
903                name
904            }
905            | Err(_) => None,
906        }
907    } else {
908        None
909    }
910}
911/// Returns the default Git branch name if the `git` command is available and executed successfully.
912///
913/// This function executes the `git symbolic-ref refs/remotes/origin/HEAD --short` command to retrieve
914/// the default Git branch name. If the command is successful, the branch name is extracted and returned
915/// as a `String`. If the command fails or if `git` is not available, the function returns `None`.
916pub fn git_default_branch_name() -> Option<String> {
917    if command_exists("git".to_owned()) {
918        let args = vec!["symbolic-ref", "refs/remotes/origin/HEAD", "--short"];
919        let result = cmd("git", args).read();
920        match result {
921            | Ok(ref value) => {
922                let name = match value.clone().split("/").last() {
923                    | Some(x) => Some(x.to_string()),
924                    | None => None,
925                };
926                name
927            }
928            | Err(_) => None,
929        }
930    } else {
931        None
932    }
933}
934/// Returns a vector of `PathBuf` representing paths to all images found in the given
935/// directory and all of its subdirectories.
936///
937/// # Arguments
938///
939/// * `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.
940///
941/// # Returns
942///
943/// A vector of `PathBuf` representing paths to all images found in the given directory and
944/// all of its subdirectories. The paths are sorted alphabetically.
945pub fn image_paths<P>(root: P) -> Vec<PathBuf>
946where
947    P: Into<PathBuf> + Clone,
948{
949    let extensions = ["jpg", "jpeg", "png", "svg", "gif"];
950    let mut files = extensions
951        .iter()
952        .flat_map(|ext| glob(&format!("{}/**/*.{}", root.clone().into().display(), ext)))
953        .flat_map(|paths| paths.collect::<Vec<_>>())
954        .flatten()
955        .collect::<Vec<PathBuf>>();
956    files.sort();
957    files
958}
959/// Makes the given file executable.
960///
961/// # Platform support
962///
963/// Platforms that support this function are:
964///
965/// * Unix
966/// * WASI
967/// * Redox
968///
969/// # Parameters
970///
971/// * `path` - A `PathBuf` containing the path to the file to be made executable.
972///
973/// # Return
974///
975/// A boolean indicating whether the file is executable after calling this function.
976#[cfg(any(unix, target_os = "wasi", target_os = "redox"))]
977pub fn make_executable<P>(path: P) -> bool
978where
979    P: Into<PathBuf> + Clone,
980{
981    use std::os::unix::fs::PermissionsExt;
982    std::fs::set_permissions(path.clone().into(), std::fs::Permissions::from_mode(0o755)).unwrap();
983    path.into().is_executable()
984}
985/// Makes the given file executable.
986///
987/// # Platform support
988///
989/// Platforms that support this function are:
990///
991/// * Windows
992///
993/// # Parameters
994///
995/// * `path` - A `PathBuf` containing the path to the file to be made executable.
996///
997/// # Return
998///
999/// A boolean indicating whether the file is executable after calling this function.
1000#[cfg(windows)]
1001pub fn make_executable<P>(path: P) -> bool
1002where
1003    P: Into<PathBuf> + Clone,
1004{
1005    // TODO: Add windows support...pass through?
1006    path.into().is_executable()
1007}
1008/// Returns the absolute path of the parent directory for the given path.
1009pub fn parent<P>(path: P) -> PathBuf
1010where
1011    P: Into<PathBuf> + Clone,
1012{
1013    let default = PathBuf::from(".");
1014    match path.clone().into().canonicalize() {
1015        | Ok(value) => match value.parent() {
1016            | Some(value) => value.to_path_buf(),
1017            | None => {
1018                warn!("=> {} Resolve parent path", Label::fail());
1019                default
1020            }
1021        },
1022        | Err(why) => {
1023            debug!("=> {} Resolve absolute path - {why}", Label::fail());
1024            match path.into().parent() {
1025                | Some(value) if !path_to_string(value.to_path_buf()).is_empty() => value.to_path_buf(),
1026                | Some(_) | None => {
1027                    warn!("=> {} Parent path was empty or could not be resolved", Label::fail());
1028                    default
1029                }
1030            }
1031        }
1032    }
1033}
1034/// Converts a `PathBuf` into a `String` representation of the absolute path.
1035///
1036/// This function attempts to canonicalize the provided path, which resolves any symbolic links
1037/// and returns an absolute path. If canonicalization fails, the original path is returned as a string.
1038///
1039/// # Arguments
1040///
1041/// * `path` - A `PathBuf` representing the file system path to be converted.
1042///
1043/// # Returns
1044///
1045/// A `String` containing the absolute path if canonicalization succeeds, or the original path as a string otherwise.
1046pub fn path_to_string(path: PathBuf) -> String {
1047    // NOTE: fs::canonicalize might cause problems on Windows
1048    let result = match std::fs::canonicalize(path.as_path()) {
1049        | Ok(value) => value,
1050        | Err(_) => path,
1051    };
1052    result.to_str().unwrap().to_string()
1053}
1054/// Prints `text` to stdout using syntax highlighting for the specified `syntax`.
1055///
1056/// `highlight` is an iterator of line numbers to highlight in the output.
1057pub fn pretty_print<I: IntoIterator<Item = usize>>(text: &str, syntax: ProgrammingLanguage, highlight: I) {
1058    let input = format!("{text}\n");
1059    let language = syntax.to_string();
1060    let mut printer = PrettyPrinter::new();
1061    printer
1062        .input_from_bytes(input.as_bytes())
1063        .theme("zenburn")
1064        .language(&language)
1065        .line_numbers(true);
1066    for line in highlight {
1067        printer.highlight(line);
1068    }
1069    printer.print().unwrap();
1070}
1071/// Prints a diff of changes between two strings.
1072///
1073/// If there are no changes between `old` and `new`, prints a debug message indicating so.
1074/// Otherwise, prints a unified diff of the changes, with `+` indicating lines that are
1075/// present in `new` but not `old`, `-` indicating lines that are present in `old` but
1076/// not `new`, and lines that are the same in both are prefixed with a space.
1077pub fn print_changes(old: &str, new: &str) {
1078    let changes = text_diff_changes(old, new);
1079    let has_no_changes = changes.clone().into_iter().all(|(tag, _)| tag == Equal);
1080    if has_no_changes {
1081        debug!("=> {}No format changes", Label::skip());
1082    } else {
1083        for change in changes {
1084            print!("{}", change.1);
1085        }
1086    }
1087}
1088// TODO: Improve flexibility (see https://rust-lang.github.io/api-guidelines/flexibility.html#c-generic)
1089/// Prints the given values as a table.
1090///
1091/// # Arguments
1092///
1093/// * `title` - The title of the table.
1094/// * `headers` - The headers of the table.
1095/// * `rows` - The rows of the table as a vector of vectors of strings.
1096pub fn print_values_as_table(title: &str, headers: Vec<&str>, rows: Vec<Vec<String>>) {
1097    let mut table = Table::new();
1098    table
1099        .load_preset(UTF8_FULL)
1100        .apply_modifier(UTF8_ROUND_CORNERS)
1101        .set_content_arrangement(ContentArrangement::Dynamic)
1102        .set_header(headers);
1103    rows.into_iter().for_each(|row| {
1104        table.add_row(row);
1105    });
1106    println!("=> {} \n{table}", title.green().bold());
1107}
1108/// Reads the given file and returns its contents as a string.
1109///
1110/// # Parameters
1111///
1112/// * `path` - A `PathBuf` or string slice containing the path to the file to be read.
1113///
1114/// # Return
1115///
1116/// A `Result` containing the contents of the file as a string if the file is readable, or an
1117/// `std::io::Error` otherwise.
1118pub fn read_file<P>(path: P) -> Result<String, std::io::Error>
1119where
1120    P: Into<PathBuf> + Clone,
1121{
1122    let mut content = String::new();
1123    let _ = match File::open(path.clone().into()) {
1124        | Ok(mut file) => {
1125            debug!(path = path_to_string(path.into()), "=> {}", Label::read());
1126            file.read_to_string(&mut content)
1127        }
1128        | Err(why) => {
1129            error!(path = path_to_string(path.into()), "=> {} Read file", Label::fail());
1130            Err(why)
1131        }
1132    };
1133    Ok(content)
1134}
1135/// Returns path to a folder in the operating system's cache directory that is unique to the given
1136/// `namespace` with a random UUID as the name of the final folder.
1137///
1138/// The folder is ***not*** created.
1139///
1140/// Used primarily by ACORN CLI where `namespace` is of a subcommand task. e.g. "check", "extract", etc.
1141///
1142/// # Arguments
1143///
1144/// * `namespace` - A string slice representing the name of the namespace.
1145/// * `default` - An optional `PathBuf` to use as the root directory instead of the cache directory.
1146///
1147/// # Returns
1148///
1149/// A `PathBuf` to the folder.
1150pub fn standard_project_folder(namespace: &str, default: Option<PathBuf>) -> PathBuf {
1151    let root = match default {
1152        | Some(value) => value,
1153        | None => match ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION) {
1154            | Some(dirs) => dirs.cache_dir().join(namespace).to_path_buf(),
1155            | None => PathBuf::from(format!("./{namespace}")),
1156        },
1157    };
1158    match create_dir_all(root.clone()) {
1159        | Ok(_) => {}
1160        | Err(why) => error!(directory = path_to_string(root.clone()), "=> {} Create - {}", Label::fail(), why),
1161    };
1162    root.join(generate_guid())
1163}
1164/// Returns "s" if the given value is not 1, otherwise returns an empty string.
1165pub fn suffix(value: usize) -> String {
1166    (if value == 1 { "" } else { "s" }).to_string()
1167}
1168/// Computes the differences between two strings line by line and returns a vector of changes.
1169///
1170/// Each change is represented as a tuple containing a `ChangeTag` indicating the type of change
1171/// (deletion, insertion, or equality) and a `String` with the formatted line prefixed with a
1172/// symbol indicating the type of change (`-` for deletions, `+` for insertions, and a space for equal lines).
1173///
1174/// The formatted string is also colored: red for deletions, green for insertions, and dimmed for equal lines.
1175///
1176/// # Arguments
1177///
1178/// * `old` - A string slice representing the original text.
1179/// * `new` - A string slice representing the modified text.
1180///
1181/// # Returns
1182///
1183/// A vector of tuples, each containing a `ChangeTag` and a formatted `String` representing the changes.
1184pub fn text_diff_changes(old: &str, new: &str) -> Vec<(ChangeTag, String)> {
1185    TextDiff::from_lines(old, new)
1186        .iter_all_changes()
1187        .map(|line| {
1188            let tag = line.tag();
1189            let text = match tag {
1190                | Delete => format!("- {line}").red().to_string(),
1191                | Insert => format!("+ {line}").green().to_string(),
1192                | Equal => format!("  {line}").dimmed().to_string(),
1193            };
1194            (tag, text)
1195        })
1196        .collect::<Vec<_>>()
1197}
1198/// Create a new [Tokio](https://tokio.rs/) runtime
1199/// ### Example
1200/// ```ignore
1201/// tokio_runtime().block_on(async {
1202///     ...async stuff
1203/// });
1204/// ```
1205pub fn tokio_runtime() -> tokio::runtime::Runtime {
1206    debug!("=> {} Tokio runtime", Label::using());
1207    tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap()
1208}
1209/// Convert a vector of string slices to a vector of strings
1210pub fn to_string(values: Vec<&str>) -> Vec<String> {
1211    values.iter().map(|s| s.to_string()).collect()
1212}
1213/// Writes the given content to a file at the given path.
1214///
1215/// # Arguments
1216///
1217/// * `path` - A `PathBuf` or string slice containing the path to the file to be written.
1218/// * `content` - A `String` containing the content to be written to the file.
1219///
1220/// # Return
1221///
1222/// A `Result` containing a unit value if the file is written successfully, or an
1223/// `std::io::Error` otherwise.
1224pub fn write_file<P>(path: P, content: String) -> Result<(), std::io::Error>
1225where
1226    P: Into<PathBuf>,
1227{
1228    match File::create(path.into().clone()) {
1229        | Ok(mut file) => {
1230            file.write_all(content.as_bytes()).unwrap();
1231            file.flush()
1232        }
1233        | Err(why) => {
1234            error!("=> {} Cannot create file - {why}", Label::fail());
1235            Err(why)
1236        }
1237    }
1238}
1239
1240#[cfg(test)]
1241mod tests;