acorn_lib/util/
mod.rs

1use crate::constants::{APPLICATION, ORGANIZATION, QUALIFIER};
2use bat::PrettyPrinter;
3use bon::Builder;
4use comfy_table::modifiers::UTF8_ROUND_CORNERS;
5use comfy_table::presets::UTF8_FULL;
6use comfy_table::*;
7use console::Emoji;
8use data_encoding::HEXUPPER;
9use derive_more::Display;
10use directories::ProjectDirs;
11use duct::cmd;
12use fancy_regex::Regex;
13use glob::glob;
14use is_executable::IsExecutable;
15use nanoid::nanoid;
16use owo_colors::{OwoColorize, Style, Styled};
17use ring::digest::{Context, SHA256};
18use rust_embed::Embed;
19use serde::{Deserialize, Serialize};
20use similar::{
21    ChangeTag::{self, Delete, Equal, Insert},
22    TextDiff,
23};
24use std::error::Error;
25use std::fs::create_dir_all;
26use std::fs::File;
27use std::io::copy;
28use std::io::Cursor;
29use std::io::{BufReader, Read};
30use std::path::{Path, PathBuf};
31use tracing::{debug, error, warn};
32use which::which;
33
34/// See <https://developer.mozilla.org/en-US/docs/Web/HTTP/MIME_types/Common_types>
35#[derive(Clone, Debug, Display)]
36pub enum MimeType {
37    #[display("text/csv")]
38    Csv,
39    #[display("application/ld+json")]
40    LdJson,
41    #[display("image/jpeg")]
42    Jpeg,
43    #[display("application/json")]
44    Json,
45    #[display("font/otf")]
46    Otf,
47    #[display("image/png")]
48    Png,
49    #[display("image/svg+xml")]
50    Svg,
51    #[display("text/plain")]
52    Text,
53}
54#[derive(Clone, Copy, Debug, Display)]
55pub enum ProgrammingLanguage {
56    #[display("html")]
57    Html,
58    #[display("markdown")]
59    Markdown,
60    #[display("json")]
61    Json,
62    #[display("yaml")]
63    Yaml,
64}
65#[derive(Embed)]
66#[folder = "assets/constants/"]
67pub struct Constant;
68/// Struct for using and sharing colorized logging labels
69pub struct Label {}
70/// Semantic version
71///
72/// see <https://semver.org/>
73#[derive(Builder, Clone, Copy, Debug, Deserialize, Display, Serialize)]
74#[builder(start_fn = init)]
75#[display("{}.{}.{}", major, minor, patch)]
76pub struct SemanticVersion {
77    /// Version when you make incompatible API changes
78    #[builder(default = 0)]
79    pub major: u32,
80    /// Version when you add functionality in a backward compatible manner
81    #[builder(default = 0)]
82    pub minor: u32,
83    /// Version when you make backward compatible bug fixes
84    #[builder(default = 0)]
85    pub patch: u32,
86}
87impl Label {
88    pub const CAUTION: Emoji<'_, '_> = Emoji("⚠️ ", "!!! ");
89    pub const CHECKMARK: Emoji<'_, '_> = Emoji("✅ ", "☑ ");
90    pub const PROGRESS_BAR_TEMPLATE: &str = "  {spinner:.green}{pos:>5} of{len:^5}[{bar:40.green}] {msg}";
91    pub fn dry_run() -> Styled<&'static &'static str> {
92        let style = Style::new().black().on_yellow();
93        " DRY_RUN ■ ".style(style)
94    }
95    pub fn invalid() -> String {
96        Label::fmt_invalid("INVALID")
97    }
98    pub fn fmt_invalid(value: &str) -> String {
99        let style = Style::new().red().on_default_color();
100        value.style(style).to_string()
101    }
102    pub fn fail() -> String {
103        Label::fmt_fail("FAIL")
104    }
105    pub fn fmt_fail(value: &str) -> String {
106        let style = Style::new().white().on_red();
107        format!(" ✗ {} ", value).style(style).to_string()
108    }
109    pub fn found() -> Styled<&'static &'static str> {
110        let style = Style::new().green().on_default_color();
111        "FOUND".style(style)
112    }
113    pub fn not_found() -> String {
114        Label::fmt_not_found("NOT_FOUND")
115    }
116    pub fn fmt_not_found(value: &str) -> String {
117        let style = Style::new().red().on_default_color();
118        value.style(style).to_string()
119    }
120    pub fn output() -> String {
121        Label::fmt_output("OUTPUT")
122    }
123    pub fn fmt_output(value: &str) -> String {
124        let style = Style::new().cyan().dimmed().on_default_color();
125        value.style(style).to_string()
126    }
127    pub fn pass() -> String {
128        Label::fmt_pass("SUCCESS")
129    }
130    pub fn fmt_pass(value: &str) -> String {
131        let style = Style::new().green().bold().on_default_color();
132        format!("{}{}", Label::CHECKMARK, value).style(style).to_string()
133    }
134    pub fn read() -> Styled<&'static &'static str> {
135        let style = Style::new().green().on_default_color();
136        "READ".style(style)
137    }
138    pub fn skip() -> String {
139        Label::fmt_skip("SKIP")
140    }
141    pub fn fmt_skip(value: &str) -> String {
142        let style = Style::new().yellow().on_default_color();
143        format!("{}{} ", Label::CAUTION, value).style(style).to_string()
144    }
145    pub fn using() -> String {
146        Label::fmt_using("USING")
147    }
148    pub fn fmt_using(value: &str) -> String {
149        let style = Style::new().cyan();
150        value.style(style).to_string()
151    }
152}
153impl Constant {
154    pub fn from_asset(file_name: &str) -> String {
155        match Constant::get(file_name) {
156            | Some(value) => String::from_utf8_lossy(value.data.as_ref()).into(),
157            | None => {
158                error!(file_name, "=> {} Import Constant asset", Label::fail());
159                panic!("Unable to import {}", file_name)
160            }
161        }
162    }
163    pub fn get_last_values(file_name: &str) -> impl Iterator<Item = String> {
164        Constant::csv(file_name)
165            .into_iter()
166            .map(|x| match x.last() {
167                | Some(value) => value.to_string(),
168                | None => "".to_string(),
169            })
170            .filter(|x| !x.is_empty())
171    }
172    pub fn read_lines(file_name: &str) -> Vec<String> {
173        let data = Constant::from_asset(file_name);
174        data.lines().map(String::from).collect()
175    }
176    pub fn csv(file_name: &str) -> Vec<Vec<String>> {
177        Constant::read_lines(format!("{}.csv", file_name).as_str())
178            .into_iter()
179            .map(|x| x.split(",").map(String::from).collect())
180            .collect()
181    }
182}
183impl MimeType {
184    pub fn from_path(file_name: &str) -> MimeType {
185        let name = &file_name.to_lowercase();
186        match get_extension(Path::new(name)).as_str() {
187            | "csv" => MimeType::Csv,
188            | "jpg" | "jpeg" => MimeType::Jpeg,
189            | "json" => MimeType::Json,
190            | "jsonld" => MimeType::LdJson,
191            | "otf" => MimeType::Otf,
192            | "png" => MimeType::Png,
193            | "svg" => MimeType::Svg,
194            | "txt" => MimeType::Text,
195            | _ => unimplemented!("Unsupported MIME type"),
196        }
197    }
198}
199impl SemanticVersion {
200    pub fn from_command(name: &str) -> Option<SemanticVersion> {
201        if test_command(name.to_owned()) {
202            let result = cmd(name, vec!["--version"]).read();
203            match result {
204                | Ok(value) => {
205                    let first_line = value.lines().collect::<Vec<_>>().first().cloned();
206                    match first_line {
207                        | Some(line) => Some(SemanticVersion::from_string(line)),
208                        | None => None,
209                    }
210                }
211                | Err(_) => None,
212            }
213        } else {
214            None
215        }
216    }
217    pub fn from_string(value: &str) -> SemanticVersion {
218        let value = match Regex::new(r"\d*[.]\d*[.]\d*") {
219            | Ok(re) => match re.find(value) {
220                | Ok(value) => match value {
221                    | Some(value) => value.as_str().to_string(),
222                    | None => unreachable!(),
223                },
224                | Err(_) => unreachable!(),
225            },
226            | Err(_) => unreachable!(),
227        };
228        let mut parts = value.split('.');
229        let major = parts.next().unwrap().parse::<u32>().unwrap();
230        let minor = parts.next().unwrap().parse::<u32>().unwrap();
231        let patch = parts.next().unwrap().parse::<u32>().unwrap();
232        SemanticVersion { major, minor, patch }
233    }
234}
235impl Default for SemanticVersion {
236    fn default() -> Self {
237        SemanticVersion::init().build()
238    }
239}
240pub fn download_binary(url: String, destination: PathBuf) -> Result<PathBuf, String> {
241    async fn download(url: String, destination: PathBuf) -> Result<(), String> {
242        let client = reqwest::Client::new();
243        let response = client.get(url.clone()).send();
244        let filename = PathBuf::from(url.clone()).file_name().unwrap().to_str().unwrap().to_string();
245        match response.await {
246            | Ok(data) => match data.bytes().await {
247                | Ok(content) => {
248                    let mut output = File::create(destination.join(filename.clone())).unwrap();
249                    let _ = copy(&mut Cursor::new(content.clone()), &mut output);
250                    debug!(filename = filename, "=> {} Downloaded", Label::output());
251                    Ok(())
252                }
253                | Err(_) => Err(format!("No content downloaded from {url}")),
254            },
255            | Err(_) => Err(format!("Failed to download {url}")),
256        }
257    }
258    let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
259    let _ = runtime.block_on(download(url.clone(), destination.clone()));
260    let filename = PathBuf::from(url.clone()).file_name().unwrap().to_str().unwrap().to_string();
261    Ok(destination.join(filename))
262}
263pub fn generate_guid() -> String {
264    let alphabet = [
265        '-', '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',
266        'e', 'f', 'g', 'h', 'i', 'j', 'k', 'm', 'n', 'p', 'q', 'r', 't', 'w', 'x', 'y', 'z', '3', '4', '6', '7', '8', '9',
267    ];
268    let id = nanoid!(10, &alphabet);
269    debug!(id, "=> {}", Label::using());
270    id
271}
272pub fn get_all_files(path: PathBuf, extension: Option<&str>, ignore: Option<String>) -> Vec<PathBuf> {
273    fn paths_to_vec(paths: glob::Paths) -> Vec<PathBuf> {
274        paths.collect::<Vec<_>>().into_iter().filter_map(|x| x.ok()).collect::<Vec<_>>()
275    }
276    if path.is_dir() {
277        let pattern = match extension {
278            | Some(value) => {
279                let ext = &value.to_lowercase();
280                format!("{}/**/*.{}", path_to_string(path), ext)
281            }
282            | None => {
283                format!("{}/**/*", path_to_string(path))
284            }
285        };
286        debug!(pattern, "=> {}", Label::using());
287        let paths = glob(&pattern);
288        match ignore {
289            | Some(ignore_pattern) => match Regex::new(&ignore_pattern) {
290                | Ok(re) => match paths {
291                    | Ok(values) => paths_to_vec(values)
292                        .into_iter()
293                        .filter(|path| !re.is_match(&path_to_string(path.to_path_buf())).unwrap())
294                        .collect(),
295                    | Err(why) => {
296                        error!("=> {} Get all files - {why}", Label::fail());
297                        vec![]
298                    }
299                },
300                | Err(why) => {
301                    error!("=> {} get_all_files Regex - {why}", Label::fail());
302                    vec![]
303                }
304            },
305            | None => match paths {
306                | Ok(values) => paths_to_vec(values),
307                | Err(why) => {
308                    error!("=> {} Get all files - {why}", Label::fail());
309                    vec![]
310                }
311            },
312        }
313    } else {
314        if extension.is_some() {
315            warn!(
316                path = path_to_string(path.clone()),
317                "=> {} Extension passed with single file to get_all_files() - please make sure this is desired",
318                Label::using()
319            );
320        }
321        vec![path]
322    }
323}
324pub fn get_changes(old: &str, new: &str) -> Vec<(ChangeTag, String)> {
325    TextDiff::from_lines(old, new)
326        .iter_all_changes()
327        .map(|line| {
328            let tag = line.tag();
329            let text = match tag {
330                | Delete => format!("- {}", line).red().to_string(),
331                | Insert => format!("+ {}", line).green().to_string(),
332                | Equal => format!("  {}", line).dimmed().to_string(),
333            };
334            (tag, text)
335        })
336        .collect::<Vec<_>>()
337}
338// Get SHA256 hash of a file
339// https://rust-lang-nursery.github.io/rust-cookbook/cryptography/hashing.html
340pub fn get_checksum(path: PathBuf) -> String {
341    match File::open(path.clone()) {
342        | Ok(file) => {
343            let mut buffer = [0; 1024];
344            let mut context = Context::new(&SHA256);
345            let mut reader = BufReader::new(file);
346            loop {
347                let count = reader.read(&mut buffer).unwrap();
348                if count == 0 {
349                    break;
350                }
351                context.update(&buffer[..count]);
352            }
353            let digest = context.finish();
354            let result = HEXUPPER.encode(digest.as_ref());
355            result.to_lowercase()
356        }
357        | Err(err) => {
358            error!(error = err.to_string(), path = path_to_string(path), "=> {} Read file", Label::fail());
359            "".to_string()
360        }
361    }
362}
363pub fn get_command_folder(task: &str, default: Option<PathBuf>) -> PathBuf {
364    let root = match default {
365        | Some(value) => value,
366        | None => match ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION) {
367            | Some(dirs) => dirs.cache_dir().join(task).to_path_buf(),
368            | None => PathBuf::from(format!("./{}", task)),
369        },
370    };
371    match create_dir_all(root.clone()) {
372        | Ok(_) => {}
373        | Err(why) => error!(directory = path_to_string(root.clone()), "=> {} Create - {}", Label::fail(), why),
374    };
375    root.join(generate_guid())
376}
377/// Get file extension
378///
379/// # Examples
380/// ```
381/// use std::path::Path;
382/// use acorn_lib::util::get_extension;
383///
384/// assert_eq!("txt", get_extension(Path::new("hello.txt")));
385/// assert_eq!("md", get_extension(Path::new("README.md")));
386/// assert_eq!("", get_extension(Path::new(".dotfile")));
387/// assert_eq!("", get_extension(Path::new("/path/to/folder")));
388/// ```
389pub fn get_extension(path: &Path) -> String {
390    path.extension().unwrap_or_default().to_str().unwrap_or_default().to_string()
391}
392fn filter_git_command_result(result: Result<String, std::io::Error>, extension: Option<&str>) -> Vec<PathBuf> {
393    match result {
394        | Ok(value) => match extension {
395            | Some(ext) => value
396                .to_lowercase()
397                .split("\n")
398                .map(PathBuf::from)
399                .filter(|x| get_extension(Path::new(x)) == ext)
400                .collect::<Vec<_>>(),
401            | None => value.to_lowercase().split("\n").map(PathBuf::from).collect::<Vec<_>>(),
402        },
403        | Err(_) => vec![],
404    }
405}
406pub fn get_files_from_git_branch(value: &str, extension: Option<&str>) -> Vec<PathBuf> {
407    if test_command("git".to_owned()) {
408        let default_branch = match get_git_default_branch_name() {
409            | Some(value) => value,
410            | None => "main".to_string(),
411        };
412        let args = vec!["diff", "--name-only", &default_branch, "--merge-base", value];
413        let result = cmd("git", args).read();
414        filter_git_command_result(result, extension)
415    } else {
416        vec![]
417    }
418}
419pub fn get_files_from_git_commit(value: &str, extension: Option<&str>) -> Vec<PathBuf> {
420    if test_command("git".to_owned()) {
421        let args = vec!["diff-tree", "--no-commit-id", "--name-only", "-r", value];
422        let result = cmd("git", args).read();
423        filter_git_command_result(result, extension)
424    } else {
425        vec![]
426    }
427}
428pub fn get_git_branch_name() -> Option<String> {
429    if test_command("git".to_owned()) {
430        let args = vec!["symbolic-ref", "--short", "HEAD"];
431        let result = cmd("git", args).read();
432        match result {
433            | Ok(ref value) => {
434                let name = match value.clone().split("/").last() {
435                    | Some(x) => Some(x.to_string()),
436                    | None => None,
437                };
438                name
439            }
440            | Err(_) => None,
441        }
442    } else {
443        None
444    }
445}
446pub fn get_git_default_branch_name() -> Option<String> {
447    if test_command("git".to_owned()) {
448        let args = vec!["symbolic-ref", "refs/remotes/origin/HEAD", "--short"];
449        let result = cmd("git", args).read();
450        match result {
451            | Ok(ref value) => {
452                let name = match value.clone().split("/").last() {
453                    | Some(x) => Some(x.to_string()),
454                    | None => None,
455                };
456                name
457            }
458            | Err(_) => None,
459        }
460    } else {
461        None
462    }
463}
464pub fn get_image_paths(root: PathBuf) -> Vec<PathBuf> {
465    let extensions = ["jpg", "jpeg", "png", "svg"];
466    let mut files = extensions
467        .iter()
468        .flat_map(|ext| glob(&format!("{}/**/*.{}", root.display(), ext)))
469        .flat_map(|paths| paths.collect::<Vec<_>>())
470        .flatten()
471        .collect::<Vec<PathBuf>>();
472    files.sort();
473    files
474}
475pub fn get_parent(path: String) -> String {
476    PathBuf::from(PathBuf::from(path).parent().unwrap()).display().to_string()
477}
478#[cfg(any(unix, target_os = "wasi", target_os = "redox"))]
479pub fn make_executable(path: &PathBuf) -> bool {
480    use std::os::unix::fs::PermissionsExt;
481    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
482    path.is_executable()
483}
484#[cfg(windows)]
485pub fn make_executable(path: &PathBuf) -> bool {
486    // TODO: Add windows support...pass through?
487    path.is_executable()
488}
489pub fn path_to_string(path: PathBuf) -> String {
490    // NOTE: fs::canonicalize might cause problems on Windows
491    let result = match std::fs::canonicalize(path.as_path()) {
492        | Ok(value) => value,
493        | Err(_) => path,
494    };
495    result.to_str().unwrap().to_string()
496}
497pub fn pretty_print(text: &str, syntax: ProgrammingLanguage, highlight: Vec<usize>) {
498    let input = format!("{}\n", text);
499    let language = syntax.to_string();
500    let mut printer = PrettyPrinter::new();
501    printer
502        .input_from_bytes(input.as_bytes())
503        .theme("zenburn")
504        .language(&language)
505        .line_numbers(true);
506    for line in highlight {
507        printer.highlight(line);
508    }
509    printer.print().unwrap();
510}
511pub fn print_changes(old: &str, new: &str) {
512    let changes = get_changes(old, new);
513    let has_no_changes = changes.clone().into_iter().all(|(tag, _)| tag == Equal);
514    if has_no_changes {
515        debug!("=> {}No format changes", Label::skip());
516    } else {
517        for change in changes {
518            print!("{}", change.1);
519        }
520    }
521}
522pub fn print_values_as_table(title: &str, headers: Vec<&str>, rows: Vec<Vec<String>>) {
523    let mut table = Table::new();
524    table
525        .load_preset(UTF8_FULL)
526        .apply_modifier(UTF8_ROUND_CORNERS)
527        .set_content_arrangement(ContentArrangement::Dynamic)
528        .set_header(headers);
529    rows.into_iter().for_each(|row| {
530        table.add_row(row);
531    });
532    println!("=> {} \n{table}", title.green().bold());
533}
534pub fn read_file(path: PathBuf) -> Result<String, Box<dyn Error>> {
535    let mut content = String::new();
536    let _ = match File::open(path.clone()) {
537        | Ok(mut file) => {
538            debug!(path = path.to_str().unwrap(), "=> {}", Label::read());
539            file.read_to_string(&mut content)
540        }
541        | Err(err) => {
542            error!(path = path.to_str().unwrap(), "=> {} Cannot read file", Label::fail());
543            Err(err)
544        }
545    };
546    Ok(content)
547}
548pub fn test_command(name: String) -> bool {
549    match which(&name) {
550        | Ok(value) => {
551            let path = path_to_string(value.clone());
552            match value.try_exists() {
553                | Ok(true) => {
554                    debug!(path, "=> {} Command", Label::found());
555                    true
556                }
557                | _ => {
558                    debug!(path, "=> {} Command", Label::not_found());
559                    false
560                }
561            }
562        }
563        | Err(_) => {
564            warn!(name, "=> {} Command", Label::not_found());
565            false
566        }
567    }
568}
569pub fn tokio_runtime() -> tokio::runtime::Runtime {
570    debug!("=> {} Tokio runtime", Label::using());
571    tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap()
572}
573pub fn to_string(values: Vec<&str>) -> Vec<String> {
574    values.iter().map(|s| s.to_string()).collect()
575}
576
577#[cfg(test)]
578mod tests;