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