Skip to main content

acorn/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::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//!
18use crate::prelude::HashMap;
19#[cfg(feature = "std")]
20use crate::prelude::Path;
21use crate::prelude::*;
22use crate::{fail, skip};
23use aho_corasick::AhoCorasick;
24use bon::Builder;
25#[cfg(feature = "std")]
26use comfy_table::modifiers::UTF8_ROUND_CORNERS;
27#[cfg(feature = "std")]
28use comfy_table::presets::UTF8_FULL;
29#[cfg(feature = "std")]
30use comfy_table::{ContentArrangement, Table};
31#[cfg(feature = "unicode")]
32use console::Emoji;
33use convert_case::{Case, Casing};
34use core::fmt;
35use core::iter::successors;
36use derive_more::Display;
37use fancy_regex::Regex;
38use fluent_uri::UriRef;
39#[cfg(feature = "std")]
40use nanoid::nanoid;
41use owo_colors::{OwoColorize, Style, Styled};
42#[cfg(feature = "std")]
43use ring::digest::SHA512;
44use rust_embed::Embed;
45use schemars::JsonSchema;
46use serde::{Deserialize, Serialize};
47use serde_json::Value;
48use similar::{
49    ChangeTag::{self, Delete, Equal, Insert},
50    TextDiff,
51};
52use validator::Validate;
53#[cfg(feature = "std")]
54pub mod cmd;
55pub mod constants;
56pub mod macros;
57
58use constants::{CROCKFORD_BASE32_ALPHABET, LINE_SEPARATOR};
59
60/// Trait for augmenting data with linked data context
61pub trait LinkedData {
62    /// Add linked data (e.g., JSON-LD) context
63    fn with_context(&self) -> Self;
64}
65/// Helper trait for searching lists of named elements
66pub trait Searchable<T> {
67    /// Check if a certain value is present in the list
68    /// ### Note
69    /// This method will differ greatly on the implementation and type of T
70    fn contains(&self, _value: &str) -> bool {
71        false
72    }
73    /// Filter list by ISO or ISO3 and return the first match
74    /// ### Note
75    /// This method is specific to the `Country` type in the GeoNames API, but is included in the trait for convenience and consistency with `find_by_name`
76    fn find_by_iso(&self, _value: impl Into<String>) -> Option<T> {
77        None
78    }
79    /// Filter list by name and return the first match
80    fn find_by_name(&self, value: impl Into<String>) -> Option<T>;
81}
82/// Trait for augmenting and formatting data for display
83pub trait StringConversion {
84    /// Return a string representation of the file_name with its parent folder (or just the folder name if it is a folder)
85    fn file_name_with_parent(&self) -> String;
86    /// Return a string representation of the absolute path
87    fn to_absolute_string(&self) -> String;
88}
89/// Add enhanced string interpolation functionality
90pub trait StringInterpolation<T>
91where
92    T: AsRef<str> + ToString,
93{
94    /// Replace placeholder instances with a given value (basic interpolation based on handlebars template syntax)
95    fn replace_placeholder_with_string(&self, placeholder: &str, value: &str) -> String;
96    /// Prepend indentation of a given number of spaces to each line of a text
97    fn with_indent(&self, spaces: usize) -> String;
98}
99/// Format data structures as Markdown
100pub trait ToMarkdown {
101    /// Convert `self` to Markdown format string
102    fn to_markdown(&self) -> String;
103}
104/// Format data structures as prose suitable for static analysis
105pub trait ToProse {
106    /// Convert `self` to prose format string
107    fn to_prose(&self) -> String;
108}
109/// Trait for converting a vector of non-string values to a vector of strings
110pub trait ToStrings {
111    /// Convert a vector of string slices to a vector of string values of paths
112    ///
113    /// This is a convenience that I find myself wanting to use in a lot of places.
114    ///
115    /// Adding a `to_strings` method to the `Vec<PathBuf>` types seems like a good idea.
116    /// ### Example
117    /// ```ignore
118    /// use acorn::util::ToStrings;
119    ///
120    /// let paths = vec![PathBuf::from("foo"), PathBuf::from("bar"), PathBuf::from("baz")];
121    /// assert!(paths.to_strings().contains(&"foo".to_string()));
122    /// ```
123    fn to_strings(&self) -> Vec<String>;
124    /// Convert a vector of string slices to a vector of string values of absolute paths
125    fn to_absolute_strings(&self) -> Vec<String> {
126        vec![]
127    }
128}
129/// Trait for adding chunking functionality
130pub trait ToStringChunks<T>
131where
132    T: AsRef<str> + ToString,
133{
134    /// Chunk a string into substrings of a given size
135    fn chunk(&self, size: usize) -> Vec<String>;
136}
137/// Expose raw text content from structures with a content field
138pub trait Unstructured {
139    /// Return raw content as a string slice
140    fn content(&self) -> &str;
141}
142/// Cryptographic hash algorithm used across ACORN metadata and packaging.
143///
144/// This enum is shared by DCAT checksum metadata and BagIt manifest workflows.
145#[derive(Clone, Debug, Default, Display, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
146pub enum ChecksumAlgorithm {
147    /// SHA-256 secure hash algorithm (default)
148    #[default]
149    #[display("sha256")]
150    #[serde(rename = "SHA256", alias = "sha256")]
151    Sha256,
152    /// MD2 message-digest algorithm
153    #[display("md2")]
154    #[serde(rename = "MD2", alias = "md2")]
155    Md2,
156    /// MD4 message-digest algorithm
157    #[display("md4")]
158    #[serde(rename = "MD4", alias = "md4")]
159    Md4,
160    /// MD5 message-digest algorithm
161    #[display("md5")]
162    #[serde(rename = "MD5", alias = "md5")]
163    Md5,
164    /// MD6 message-digest algorithm
165    #[display("md6")]
166    #[serde(rename = "MD6", alias = "md6")]
167    Md6,
168    /// SHA-1 secure hash algorithm
169    #[display("sha1")]
170    #[serde(rename = "SHA1", alias = "sha1")]
171    Sha1,
172    /// SHA-224 secure hash algorithm
173    #[display("sha224")]
174    #[serde(rename = "SHA224", alias = "sha224")]
175    Sha224,
176    /// SHA-384 secure hash algorithm
177    #[display("sha384")]
178    #[serde(rename = "SHA384", alias = "sha384")]
179    Sha384,
180    /// SHA-512 secure hash algorithm
181    #[display("sha512")]
182    #[serde(rename = "SHA512", alias = "sha512")]
183    Sha512,
184}
185/// SPDX compliant license identifier
186///
187/// See <https://spdx.org/licenses/> for more information
188#[derive(Clone, Debug, Display, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
189pub enum License {
190    /// GNU Affero General Public License v3.0 only
191    #[display("AGPL-3.0-only")]
192    #[serde(alias = "AGPL-3.0-only")]
193    Agpl3Only,
194    /// Apache License 2.0
195    #[display("Apache-2.0")]
196    #[serde(alias = "Apache-2.0")]
197    Apache2,
198    /// BSD 3-Clause "New" or "Revised" License
199    #[display("BSD-3-Clause")]
200    #[serde(alias = "BSD-3-Clause")]
201    Bsd3Clause,
202    /// Creative Commons Zero v1.0 Universal
203    #[display("CC0-1.0")]
204    #[serde(alias = "CC0-1.0", alias = "Creative Commons CC-0")]
205    CreativeCommons,
206    /// GNU General Public License v2.0 only
207    #[display("GPL-2.0-only")]
208    #[serde(alias = "GPL-2.0-only")]
209    Gpl2Only,
210    /// GNU General Public License v2.0 with Classpath exception
211    #[display("GPL-2.0-with-classpath-exception")]
212    #[serde(alias = "GPL-2.0-with-classpath-exception")]
213    Gpl2WithClasspathException,
214    /// GNU General Public License v3.0 only
215    #[display("GPL-3.0-only")]
216    #[serde(alias = "GPL-3.0-only")]
217    Gpl3Only,
218    /// GNU General Public License v3.0 or later
219    #[display("GPL-3.0-or-later")]
220    #[serde(alias = "GPL-3.0-or-later")]
221    Gpl3OrLater,
222    /// GNU Lesser General Public License v2.1 only
223    #[display("LGPL-2.1-only")]
224    #[serde(alias = "LGPL-2.1-only")]
225    Lgpl21Only,
226    /// LaTeX Project Public License v1.3c
227    #[display("LPPL-1.3c")]
228    #[serde(alias = "LPPL-1.3c")]
229    Lppl13c,
230    /// MIT License
231    #[display("MIT")]
232    #[serde(alias = "MIT")]
233    Mit,
234    /// PostgreSQL License
235    #[display("PostgreSQL")]
236    #[serde(alias = "PostgreSQL")]
237    PostgreSql,
238    /// Custom license reference for proprietary software
239    #[display("Proprietary")]
240    #[serde(alias = "LicenseRef-Proprietary")]
241    Proprietary,
242    /// Python Software Foundation License (based on PSF)
243    #[display("PSF-based")]
244    #[serde(alias = "PSF-based")]
245    PsfBased,
246    /// Python Software Foundation License 2.0
247    #[display("PSF-2.0")]
248    #[serde(alias = "PSF-2.0")]
249    Psf2,
250    /// Public domain (i.e., no license)
251    #[display("Public Domain")]
252    #[serde(alias = "Public Domain")]
253    PublicDomain,
254    /// Unknown license
255    #[display("Unknown")]
256    Unknown,
257    /// Various licenses (mixed or unspecified)
258    #[display("Various")]
259    #[serde(alias = "Various")]
260    Various,
261    /// World Wide Web Consortium License
262    #[display("W3C")]
263    #[serde(alias = "W3C")]
264    W3C,
265}
266/// Supports an incomplete list of common <span title="Multipurpose Internet Mail Extension">MIME</span> types
267///
268/// 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
269#[derive(Clone, Debug, Display, PartialEq)]
270pub enum MimeType {
271    /// Citation File Format (CFF)
272    /// ### Note
273    /// > CFF does not have a standard MIME type, but is valid YAML
274    ///
275    /// See <https://citation-file-format.github.io/> for more information
276    #[display("application/yaml")]
277    Cff,
278    /// Comma Separated Values (CSV)
279    #[display("text/csv")]
280    Csv,
281    /// GPT-Generated Unified Format (GGUF) model
282    ///
283    /// See <https://github.com/ggml-org/ggml/blob/master/docs/gguf.md> for more information
284    #[display("application/vnd.gguf.model")]
285    Gguf,
286    /// Linked Data [JSON](https://www.json.org/json-en.html)
287    ///
288    /// See <https://json-ld.org/>
289    #[display("application/ld+json")]
290    LdJson,
291    /// Joint Photographic Experts Group (JPEG)
292    #[display("image/jpeg")]
293    Jpeg,
294    /// JavaScript Object Notation (JSON)
295    ///
296    /// See <https://www.json.org/json-en.html>
297    #[display("application/json")]
298    Json,
299    /// Markdown
300    #[display("text/markdown")]
301    Markdown,
302    /// Model card
303    #[display("application/vnd.ai.modelcard.v1+json")]
304    ModelCard,
305    /// ONNX (Open Neural Network Exchange) model
306    ///
307    /// See <https://onnx.ai/> for more information
308    #[display("application/vnd.onnx.model")]
309    Onnx,
310    /// OpenType Font (OTF)
311    #[display("font/otf")]
312    Otf,
313    /// Parquet format
314    ///
315    /// See <https://parquet.apache.org> for more information
316    #[display("application/x-parquet")]
317    Parquet,
318    /// Portable Document Format (PDF)
319    #[display("application/pdf")]
320    Pdf,
321    /// Portable Network Graphic (PNG)
322    #[display("image/png")]
323    Png,
324    /// PyTorch model
325    ///
326    /// Commonly used for `.pt` and `.pth` model files.
327    #[display("application/vnd.pytorch.model")]
328    Pytorch,
329    /// PowerPoint Presentation (modern format)
330    ///
331    /// See <https://en.wikipedia.org/wiki/Office_Open_XML>
332    #[display("application/vnd.openxmlformats-officedocument.presentationml.presentation")]
333    Powerpoint,
334    /// LLM Prompt template
335    ///
336    /// This includes .prompt files (see <https://google.github.io/dotprompt/>)
337    #[display("application/vnd.ai.prompt.v1+json")]
338    Prompt,
339    /// Rust Source Code (RS)
340    #[display("text/rust")]
341    Rust,
342    /// Safetensors weights
343    ///
344    /// See <https://github.com/huggingface/safetensors> for more information
345    #[display("application/vnd.safetensors")]
346    Safetensors,
347    /// SBOM (Software Bill of Materials)
348    ///
349    /// See <https://cyclonedx.org/> for more information
350    #[display("application/spdx+json")]
351    Sbom,
352    /// Scalable Vector Graphic (SVG)
353    #[display("image/svg+xml")]
354    Svg,
355    /// Plain Text
356    ///
357    /// Just plain old text
358    #[display("text/plain")]
359    Text,
360    /// Tom's Obvious Minimal Language (TOML)
361    ///
362    /// See <https://toml.io/>
363    #[display("application/toml")]
364    Toml,
365    /// TrueType Font (TTF)
366    #[display("font/ttf")]
367    Ttf,
368    /// YAML Ain't Markup Language (YAML)
369    ///
370    /// See <https://yaml.org/>
371    #[display("application/yaml")]
372    Yaml,
373    /// ZIP Archive
374    ///
375    /// See <https://en.wikipedia.org/wiki/ZIP_(file_format)>
376    #[display("application/zip")]
377    Zip,
378    /// Unknown MIME type
379    #[display("application/vnd.{}", _0)]
380    Vendor(String),
381    /// Unknown MIME type
382    #[display("application/octet-stream")]
383    Unknown(String),
384}
385/// Cryptographic checksum value paired with its algorithm.
386#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
387pub struct Checksum {
388    /// The algorithm used to produce the checksum.
389    pub algorithm: ChecksumAlgorithm,
390    /// Lowercase hexadecimal digest value.
391    #[serde(rename = "checksumValue")]
392    pub checksum_value: String,
393}
394/// Struct for using and sharing constants
395///
396/// See <https://git.sr.ht/~pyrossh/rust-embed>
397#[derive(Embed)]
398#[folder = "assets/constants/"]
399pub struct Constant;
400/// Struct for using and sharing colorized logging labels
401///
402/// ### Labels [^1]
403/// | Name    | Example Output |
404/// |---------|----------------|
405/// | Dry run | "=> DRY_RUN ■ Pretending to do a thing" |
406/// | Skip    | "=> ⚠️  Thing was skipped" |
407/// | Pass    | "=> ✅ Thing passed " |
408/// | Fail    | "=> ✗ Thing failed " |
409///
410/// [^1]: Incomplete list of examples without foreground/background coloring
411pub struct Label {}
412/// Semantic version
413///
414/// see <https://semver.org/>
415///
416/// ```rust
417/// use acorn::util::SemanticVersion;
418///
419/// let version = SemanticVersion::from_string("1.2.3");
420/// assert_eq!(version.major, 1);
421/// assert_eq!(version.to_string(), "1.2.3");
422/// ```
423
424#[derive(Builder, Clone, Copy, Debug, Deserialize, Display, Serialize, JsonSchema)]
425#[builder(start_fn = init)]
426#[display("{}.{}.{}", major, minor, patch)]
427pub struct SemanticVersion {
428    /// Version when you make incompatible API changes
429    #[builder(default = 0)]
430    pub major: u32,
431    /// Version when you add functionality in a backward compatible manner
432    #[builder(default = 0)]
433    pub minor: u32,
434    /// Version when you make backward compatible bug fixes
435    #[builder(default = 0)]
436    pub patch: u32,
437}
438impl fmt::Display for Checksum {
439    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
440        write!(f, "{}", self.checksum_value)
441    }
442}
443impl From<&str> for ChecksumAlgorithm {
444    fn from(value: &str) -> Self {
445        match value.to_lowercase().as_str() {
446            | "sha256" => ChecksumAlgorithm::Sha256,
447            | "md5" => ChecksumAlgorithm::Md5,
448            | "sha1" => ChecksumAlgorithm::Sha1,
449            | "sha512" => ChecksumAlgorithm::Sha512,
450            | "md2" => ChecksumAlgorithm::Md2,
451            | "md4" => ChecksumAlgorithm::Md4,
452            | "md6" => ChecksumAlgorithm::Md6,
453            | "sha224" => ChecksumAlgorithm::Sha224,
454            | "sha384" => ChecksumAlgorithm::Sha384,
455            | _ => ChecksumAlgorithm::default(),
456        }
457    }
458}
459impl From<String> for ChecksumAlgorithm {
460    fn from(value: String) -> Self {
461        ChecksumAlgorithm::from(value.as_str())
462    }
463}
464#[cfg(feature = "std")]
465impl From<&'static ring::digest::Algorithm> for ChecksumAlgorithm {
466    fn from(algorithm: &'static ring::digest::Algorithm) -> Self {
467        if core::ptr::eq(algorithm, &SHA512) {
468            ChecksumAlgorithm::Sha512
469        } else {
470            ChecksumAlgorithm::Sha256
471        }
472    }
473}
474impl Constant {
475    /// Reads a file from the asset folder and returns its contents as a UTF-8 string.
476    pub fn from_asset(file_name: &str) -> Option<String> {
477        match Self::get(file_name) {
478            | Some(value) => Some(String::from_utf8_lossy(value.data.as_ref()).into()),
479            | None => None,
480        }
481    }
482    /// Returns an iterator over the last values of each row in the given file.
483    ///
484    /// If a row is empty, it is skipped.
485    pub fn last_values(file_name: &str) -> impl Iterator<Item = String> {
486        Self::csv(file_name)
487            .into_iter()
488            .filter_map(|x| x.last().cloned())
489            .filter(|x| !x.is_empty())
490    }
491    /// Returns an iterator over the values at the given column index for each row in the given file.
492    ///
493    /// If a row does not have a value at the given index, it is skipped.
494    pub fn nth_values(file_name: &str, column: usize) -> impl Iterator<Item = String> {
495        Self::csv(file_name)
496            .into_iter()
497            .filter_map(move |x| x.get(column).cloned())
498            .filter(|x| !x.is_empty())
499    }
500    /// Reads a file from the asset folder and returns its contents as an iterator over individual lines.
501    pub fn read_lines(file_name: &str) -> Vec<String> {
502        Self::from_asset(file_name)
503            .map(|value| value.lines().map(String::from).collect())
504            .unwrap_or_default()
505    }
506    /// Reads a CSV file from the asset folder and returns its contents as a `Vec` of `Vec<String>`,
507    /// where each inner vector represents a row and each string within the inner vector represents a cell value.
508    ///
509    /// # Arguments
510    /// * `file_name` - A string slice representing the name of the CSV file (without extension)
511    pub fn csv(file_name: impl AsRef<str>) -> Vec<Vec<String>> {
512        Self::read_lines(format!("{}.csv", file_name.as_ref()).as_str())
513            .into_iter()
514            .map(|x| x.split(",").map(String::from).collect())
515            .collect()
516    }
517    /// Reads a JSON file from the asset folder and returns its contents
518    ///
519    /// # Arguments
520    /// * `file_name` - A string slice representing the name of the JSON file (without extension)
521    pub fn json<T>(file_name: impl AsRef<str>) -> T
522    where
523        T: Default + serde::de::DeserializeOwned,
524    {
525        Self::from_asset(format!("{}.json", file_name.as_ref()).as_str())
526            .map(|text| serde_json::from_str(&text).unwrap_or_default())
527            .unwrap_or_default()
528    }
529}
530impl Label {
531    /// Prefix for use when logging a warning, caution, etc.
532    #[cfg(not(feature = "unicode"))]
533    pub const CAUTION: &str = "!!! ";
534    /// Prefix for use when logging a warning, caution, etc.
535    #[cfg(feature = "unicode")]
536    pub const CAUTION: Emoji<'_, '_> = Emoji("⚠️ ", "!!! ");
537    /// Prefix for use when logging a success, pass, etc.
538    #[cfg(not(feature = "unicode"))]
539    pub const CHECKMARK: &str = "✓ ";
540    /// Prefix for use when logging a success, pass, etc.
541    #[cfg(feature = "unicode")]
542    pub const CHECKMARK: Emoji<'_, '_> = Emoji("✅ ", "☑ ");
543    /// Template string to customize the progress bar
544    ///
545    /// See <https://docs.rs/indicatif/latest/indicatif/#templates>
546    pub const PROGRESS_BAR_TEMPLATE: &str = "  {spinner:.green}{pos:>5} of{len:^5}[{bar:40.green}] {msg}";
547    /// Template string for progress spinner (indeterminate)
548    pub const PROGRESS_SPINNER_TEMPLATE: &str = "  {spinner:.green}  {msg}";
549    /// Template string for progress counter (simple X of Y)
550    pub const PROGRESS_COUNTER_TEMPLATE: &str = "{pos:>5} of{len:^5} {msg}";
551    /// "Dry run" label
552    pub fn dry_run() -> Styled<&'static &'static str> {
553        let style = Style::new().black().on_yellow();
554        " DRY_RUN ■ ".style(style)
555    }
556    /// "Invalid" label
557    pub fn invalid() -> String {
558        Label::fmt_invalid(" ✗ INVALID")
559    }
560    /// "Invalid" label formatting
561    pub fn fmt_invalid(value: &str) -> String {
562        let style = Style::new().red().on_default_color();
563        value.style(style).to_string()
564    }
565    /// "Valid" label
566    pub fn valid() -> String {
567        Label::fmt_valid(" ✓ VALID  ")
568    }
569    /// "Invalid" label formatting
570    pub fn fmt_valid(value: &str) -> String {
571        let style = Style::new().green().on_default_color();
572        value.style(style).to_string()
573    }
574    /// "Fail" label
575    pub fn fail() -> String {
576        Label::fmt_fail("FAIL")
577    }
578    /// "Fail" label formatting
579    pub fn fmt_fail(value: &str) -> String {
580        let style = Style::new().white().on_red();
581        format!(" ✗ {value} ").style(style).to_string()
582    }
583    /// "Found" label
584    pub fn found() -> String {
585        Label::fmt_found("FOUND")
586    }
587    /// "Found" label formatting
588    pub fn fmt_found(value: &str) -> String {
589        let style = Style::new().green().on_default_color();
590        value.to_string().style(style).to_string()
591    }
592    /// "Not found" label
593    pub fn not_found() -> String {
594        Label::fmt_not_found("NOT_FOUND")
595    }
596    /// "Not found" label formatting
597    pub fn fmt_not_found(value: &str) -> String {
598        let style = Style::new().red().on_default_color();
599        value.style(style).to_string()
600    }
601    /// "Output" label
602    pub fn output() -> String {
603        Label::fmt_output("OUTPUT")
604    }
605    /// "Output" label formatting
606    pub fn fmt_output(value: &str) -> String {
607        let style = Style::new().cyan().dimmed().on_default_color();
608        value.style(style).to_string()
609    }
610    /// "Pass" label
611    pub fn pass() -> String {
612        Label::fmt_pass("SUCCESS")
613    }
614    /// "Pass" label formatting
615    pub fn fmt_pass(value: &str) -> String {
616        let style = Style::new().green().bold().on_default_color();
617        format!("{}{}", Label::CHECKMARK, value).style(style).to_string()
618    }
619    /// "Read" label
620    pub fn read() -> Styled<&'static &'static str> {
621        let style = Style::new().green().on_default_color();
622        "READ".style(style)
623    }
624    /// "Rejected" label
625    pub fn rejected() -> String {
626        Label::fmt_rejected("REJECTED")
627    }
628    /// "Rejected" label formatting
629    pub fn fmt_rejected(value: &str) -> String {
630        let style = Style::new().red().on_default_color();
631        format!("🛑 {value} ").style(style).to_string()
632    }
633    /// "Run" label
634    pub fn run() -> String {
635        Label::fmt_run("RUN")
636    }
637    /// "Run" label formatting
638    pub fn fmt_run(value: &str) -> String {
639        let style = Style::new().black().on_yellow();
640        format!(" {value} ▶ ").style(style).to_string()
641    }
642    /// "Skip" label
643    pub fn skip() -> String {
644        Label::fmt_skip("SKIP")
645    }
646    /// "Skip" label formatting
647    pub fn fmt_skip(value: &str) -> String {
648        let style = Style::new().yellow().on_default_color();
649        format!("{}{} ", Label::CAUTION, value).style(style).to_string()
650    }
651    /// "Using" label
652    pub fn using() -> String {
653        Label::fmt_using("USING")
654    }
655    /// "Using" label formatting
656    pub fn fmt_using(value: &str) -> String {
657        let style = Style::new().cyan();
658        value.style(style).to_string()
659    }
660}
661impl<T: AsRef<str>> From<T> for License
662where
663    T: ToString,
664{
665    /// Convert SPDX standard indentifier to associated `License` value
666    /// ### Notes
667    /// - Custom license identifiers (i.e., start with `LicenseRef-`) are mapped to `License::Proprietary`
668    /// - `"Public Domain"`, which is not a valid SPDX identifier is mapped to `License::PublicDomain`
669    /// - `"Unknown"`, which is not a valid SPDX identifier is mapped to `License::Unknown`
670    /// - `"Various"`, which is not a valid SPDX identifier is mapped to `License::Various`
671    fn from(value: T) -> Self {
672        match value.as_ref().to_lowercase().as_str() {
673            | "agpl-3.0-only" => License::Agpl3Only,
674            | "apache-2.0" => License::Apache2,
675            | "bsd-2-clause" => License::Bsd3Clause,
676            | "bsd-3-clause" => License::Bsd3Clause,
677            | "cc0-1.0" | "creative commons cc-0" => License::CreativeCommons,
678            | "gpl-1.0-or-later" => License::Gpl2Only,
679            | "gpl-2.0-only" => License::Gpl2Only,
680            | "gpl-2.0-with-classpath-exception" => License::Gpl2WithClasspathException,
681            | "gpl-3.0-only" => License::Gpl3Only,
682            | "gpl-3.0-or-later" => License::Gpl3OrLater,
683            | "lgpl-2.1-only" => License::Lgpl21Only,
684            | "lppl-1.3c" => License::Lppl13c,
685            | "mit" => License::Mit,
686            | "postgresql" => License::PostgreSql,
687            | "proprietary" | "licenseref-proprietary" => License::Proprietary,
688            | "psf-based" => License::PsfBased,
689            | "psf-2.0" => License::Psf2,
690            | "public-domain" | "public domain" => License::PublicDomain,
691            | "various" => License::Various,
692            | "w3c" => License::W3C,
693            | _ => License::Unknown,
694        }
695    }
696}
697impl License {
698    #[allow(dead_code)]
699    fn from_technology(value: &str) -> Option<License> {
700        let data = Constant::csv("technology");
701        let result = data
702            .into_iter()
703            .map(|row| row.into_iter().take(5).collect::<Vec<String>>())
704            .find(|pair| pair.first().map(|s| s.as_str()) == Some(value));
705        match result {
706            | Some(pair) => pair.get(4).map(|s| License::from(s.clone())),
707            | None => None,
708        }
709    }
710    #[allow(dead_code)]
711    fn is_open_source(&self) -> bool {
712        let data = Constant::csv("technology");
713        let result = data
714            .into_iter()
715            .map(|row| row.into_iter().skip(4).take(2).collect::<Vec<String>>())
716            .find(|pair| pair.first().map(|s| s.as_str()) == Some(self.to_string().as_str()));
717        match result {
718            | Some(value) => value.get(1).map(|s| s.as_str()) == Some("true"),
719            | None => false,
720        }
721    }
722}
723impl<T: AsRef<str>> From<T> for MimeType
724where
725    T: ToString,
726{
727    /// Returns a `MimeType` value based on the file extension of the given file name.
728    ///
729    /// # Supported MIME types
730    ///
731    /// | File Extension | MIME Type |
732    /// | --- | --- |
733    /// | cff | application/yaml |
734    /// | csv | text/csv |
735    /// | jpg | image/jpeg |
736    /// | jpeg | image/jpeg |
737    /// | json | application/json |
738    /// | jsonld | application/ld+json |
739    /// | md | text/markdown |
740    /// | otf | font/otf |
741    /// | ttf | font/ttf |
742    /// | pdf | application/pdf |
743    /// | png | image/png |
744    /// | pt | application/vnd.pytorch.model |
745    /// | pth | application/vnd.pytorch.model |
746    /// | pptx | application/vnd.openxmlformats-officedocument.presentationml.presentation |
747    /// | rs | text/rust |
748    /// | svg | image/svg+xml |
749    /// | toml | application/toml |
750    /// | txt | text/plain |
751    /// | yaml | application/yaml |
752    /// | zip | application/zip |
753    fn from(value: T) -> Self {
754        let name = value.to_string().to_lowercase();
755        match file_extension(name.clone()) {
756            | Some(value) => match value.as_str() {
757                | "cff" => MimeType::Cff,
758                | "csv" => MimeType::Csv,
759                | "gguf" => MimeType::Gguf,
760                | "jpg" | "jpeg" => MimeType::Jpeg,
761                | "json" => MimeType::Json,
762                | "jsonld" | "json-ld" => MimeType::LdJson,
763                | "md" | "markdown" => MimeType::Markdown,
764                | "onnx" => MimeType::Onnx,
765                | "otf" => MimeType::Otf,
766                | "ttf" => MimeType::Ttf,
767                | "parquet" => MimeType::Parquet,
768                | "pdf" => MimeType::Pdf,
769                | "png" => MimeType::Png,
770                | "pt" | "pth" => MimeType::Pytorch,
771                | "pptx" | "ppt" => MimeType::Powerpoint,
772                | "prompt" => MimeType::Prompt,
773                | "rs" => MimeType::Rust,
774                | "safetensors" => MimeType::Safetensors,
775                | "spdx.json" => MimeType::Sbom,
776                | "svg" => MimeType::Svg,
777                | "toml" => MimeType::Toml,
778                | "txt" => MimeType::Text,
779                | "yml" | "yaml" => MimeType::Yaml,
780                | value => MimeType::Vendor(value.to_string()),
781            },
782            | None => MimeType::Unknown(name),
783        }
784    }
785}
786impl MimeType {
787    /// Returns the file type as a string
788    /// ### Example
789    /// ```rust
790    /// use acorn::util::MimeType;
791    ///
792    /// let mime = MimeType::Cff;
793    /// assert_eq!(mime.file_type(), "cff");
794    /// ```
795    pub fn file_type(self) -> String {
796        match self {
797            | MimeType::Cff => "cff",
798            | MimeType::Csv => "csv",
799            | MimeType::Gguf => "gguf",
800            | MimeType::Jpeg => "jpeg",
801            | MimeType::Json => "json",
802            | MimeType::LdJson => "jsonld",
803            | MimeType::Markdown => "md",
804            | MimeType::ModelCard => "modelcard",
805            | MimeType::Onnx => "onnx",
806            | MimeType::Otf => "otf",
807            | MimeType::Ttf => "ttf",
808            | MimeType::Parquet => "parquet",
809            | MimeType::Pdf => "pdf",
810            | MimeType::Png => "png",
811            | MimeType::Pytorch => "pt",
812            | MimeType::Powerpoint => "pptx",
813            | MimeType::Prompt => "prompt",
814            | MimeType::Rust => "rs",
815            | MimeType::Safetensors => "safetensors",
816            | MimeType::Sbom => "spdx.json",
817            | MimeType::Svg => "svg",
818            | MimeType::Text => "txt",
819            | MimeType::Toml => "toml",
820            | MimeType::Yaml => "yaml",
821            | MimeType::Zip => "zip",
822            | _ => "unknown-file-type",
823        }
824        .to_string()
825    }
826}
827impl Default for SemanticVersion {
828    fn default() -> Self {
829        SemanticVersion::init().build()
830    }
831}
832impl From<&str> for SemanticVersion {
833    /// Parses a string into a `SemanticVersion` value
834    ///
835    /// ### Example
836    /// ```rust
837    /// use acorn::util::SemanticVersion;
838    ///
839    /// let version = SemanticVersion::from("1.2.3");
840    /// assert_eq!(version.minor, 2);
841    /// ```
842    fn from(value: &str) -> Self {
843        let token = value
844            .split(|c: char| !(c.is_ascii_digit() || c == '.'))
845            .find(|x: &&str| x.chars().any(|c: char| c.is_ascii_digit()))
846            .unwrap_or("");
847        let parts = token
848            .split('.')
849            .filter(|x: &&str| !x.is_empty())
850            .map(|x: &str| x.parse::<u32>())
851            .collect::<Vec<_>>();
852        match parts.as_slice() {
853            | [Ok(major), Ok(minor), Ok(patch)] => SemanticVersion::init().major(*major).minor(*minor).patch(*patch).build(),
854            | [Ok(major), Ok(minor)] => SemanticVersion::init().major(*major).minor(*minor).build(),
855            | [Ok(major)] => SemanticVersion::init().major(*major).build(),
856            | _ => SemanticVersion::default(),
857        }
858    }
859}
860impl<T: AsRef<str>> StringInterpolation<T> for T
861where
862    T: ToString,
863{
864    fn replace_placeholder_with_string(&self, placeholder: &str, value: &str) -> String {
865        match Regex::new(&format!(r"{{{{\s*{placeholder}\s*}}}}")) {
866            | Ok(re) => re.replace_all(self.as_ref(), value).to_string(),
867            | Err(err) => {
868                fail!("Regex replacement - {}", err);
869                self.to_string()
870            }
871        }
872    }
873    fn with_indent(&self, spaces: usize) -> String {
874        self.to_string()
875            .lines()
876            .map(|line| " ".repeat(spaces) + line.trim_start())
877            .collect::<Vec<_>>()
878            .join(LINE_SEPARATOR)
879    }
880}
881impl<P: AsRef<str>> ToMarkdown for Vec<P> {
882    fn to_markdown(&self) -> String {
883        if self.is_empty() {
884            "[]".to_string()
885        } else {
886            self.iter()
887                .map(|x| format!("{LINE_SEPARATOR}- {}", x.as_ref()))
888                .collect::<Vec<String>>()
889                .join("")
890        }
891    }
892}
893impl<P: AsRef<str> + ToMarkdown> ToMarkdown for Option<Vec<P>> {
894    fn to_markdown(&self) -> String {
895        match &self {
896            | Some(values) => format!("{LINE_SEPARATOR}{}", values.to_markdown()),
897            | None => "[]".to_string(),
898        }
899    }
900}
901impl<T: AsRef<str>> ToStringChunks<T> for T
902where
903    T: ToString,
904{
905    fn chunk(&self, size: usize) -> Vec<String> {
906        self.as_ref()
907            .as_bytes()
908            .chunks(size)
909            .filter_map(|chunk| String::from_utf8(chunk.to_vec()).ok())
910            .collect::<Vec<_>>()
911    }
912}
913/// Returns a base32 encoded string using the [base 32 Crockford](https://www.crockford.com/base32.html) alphabet
914/// ### Note
915/// > Uses Crockford base32 alphabet (excludes I, L, O, U to avoid confusion)
916///
917/// ### Example
918/// ```rust
919/// use acorn::util::base32_crockford_encode;
920///
921/// let encoded = base32_crockford_encode(1234);
922/// assert_eq!(encoded, "16j");
923/// ```
924pub fn base32_crockford_encode(value: u128) -> String {
925    if value == 0 {
926        "0".to_string()
927    } else {
928        const MODULUS: u128 = CROCKFORD_BASE32_ALPHABET.len() as u128;
929        successors(Some(value), |&n| (n >= MODULUS).then_some(n / MODULUS))
930            .map(|n| char::from(*CROCKFORD_BASE32_ALPHABET.get((n % MODULUS) as usize).unwrap_or(&0)))
931            .collect::<Vec<_>>()
932            .into_iter()
933            .rev()
934            .collect::<String>()
935            .to_ascii_lowercase()
936    }
937}
938/// Decode a base32 Crockford string into a u128 value.
939///
940/// ### Note
941/// - Accepts lowercase/uppercase
942/// - Treats `O` as `0` and `I`/`L` as `1`
943/// - Ignores `-`, `_`, and whitespace separators
944///
945/// ### Example
946/// ```rust
947/// use acorn::util::base32_crockford_decode;
948///
949/// let decoded = base32_crockford_decode("16j").unwrap();
950/// assert_eq!(decoded, 1234);
951/// ```
952pub fn base32_crockford_decode(value: impl AsRef<str>) -> Option<u128> {
953    const MODULUS: u128 = CROCKFORD_BASE32_ALPHABET.len() as u128;
954    value
955        .as_ref()
956        .chars()
957        .filter(|c| !c.is_whitespace() && *c != '-' && *c != '_')
958        .try_fold(0u128, |acc, c| {
959            let digit = crockford_digit(c)?;
960            match acc.checked_mul(MODULUS) {
961                | Some(value) => value.checked_add(digit),
962                | None => None,
963            }
964        })
965}
966/// Returns true when any pattern exists in the haystack.
967pub fn contains_any(patterns: &[&str], haystack: &str) -> bool {
968    match AhoCorasick::new(patterns) {
969        | Ok(matcher) => matcher.is_match(haystack),
970        | Err(_) => false,
971    }
972}
973/// Returns true when haystack contains a prefix and any suffix pattern.
974pub fn contains_any_with_prefix(haystack: &str, prefix: &str, suffixes: &[&str]) -> bool {
975    haystack.contains(prefix) && contains_any(suffixes, haystack)
976}
977fn crockford_digit(value: char) -> Option<u128> {
978    let upper = value.to_ascii_uppercase();
979    let normalized = if upper == 'O' {
980        '0'
981    } else if upper == 'I' || upper == 'L' {
982        '1'
983    } else {
984        upper
985    };
986    let byte = normalized as u8;
987    CROCKFORD_BASE32_ALPHABET.iter().position(|&b| b == byte).map(|index| index as u128)
988}
989/// Try to parse text as JSON and return `true` if successful, `false` otherwise
990pub fn detect_json(text: impl ToString) -> bool {
991    serde_json::from_str::<Value>(&text.to_string()).is_ok()
992}
993/// Try to parse text as XML and return `true` if successful, `false` otherwise
994pub fn detect_xml(text: impl ToString) -> bool {
995    let content = text.to_string();
996    let trimmed = content.trim();
997    if trimmed.starts_with('<') {
998        let mut reader = quick_xml::Reader::from_str(trimmed);
999        let mut buf = vec![];
1000        loop {
1001            match reader.read_event_into(&mut buf) {
1002                | Ok(quick_xml::events::Event::Eof) => return true,
1003                | Err(_) => return false,
1004                | _ => buf.clear(),
1005            }
1006        }
1007    } else {
1008        false
1009    }
1010}
1011/// Returns the file extension of the given file name as a string.
1012/// ### Note
1013/// > The primary benefit of this function is to get file extension without using Path or PathBuf
1014///
1015/// ### Example
1016/// ```rust
1017/// use acorn::util::file_extension;
1018///
1019/// let extension = file_extension("test.cff");
1020/// assert_eq!(extension, Some("cff".to_string()));
1021/// ```
1022pub fn file_extension<S>(value: S) -> Option<String>
1023where
1024    S: Into<String>,
1025{
1026    let filename = value.into();
1027    let segments = filename.split('.').filter(|x| !x.is_empty()).collect::<Vec<_>>();
1028    if !segments.is_empty() {
1029        let last_segment = segments.last().map(|value| (*value).to_string());
1030        let has_extension = filename.contains(".") && segments.len() > 1;
1031        match last_segment {
1032            | Some(value) => {
1033                let is_filename = !(value.contains("/") || value.is_empty());
1034                if has_extension && is_filename {
1035                    Some(value)
1036                } else {
1037                    None
1038                }
1039            }
1040            | None => None,
1041        }
1042    } else {
1043        None
1044    }
1045}
1046/// Return fisrt key/value pair with key that matches pattern
1047/// ### Example
1048/// ```rust
1049/// use acorn::util::find_first;
1050///
1051/// let values = vec![("foo".to_string(), "bar".to_string()), ("baz".to_string(), "qux".to_string())];
1052/// let pattern = "ba";
1053/// let result = find_first(values, pattern);
1054/// assert_eq!(result, Some(("baz".to_string(), "qux".to_string())));
1055/// ```
1056pub fn find_first(values: Vec<(String, String)>, pattern: &str) -> Option<(String, String)> {
1057    let results = values
1058        .clone()
1059        .into_iter()
1060        .filter(|x| !x.1.is_empty())
1061        .find(|(key, _)| key.starts_with(pattern));
1062    match results {
1063        | Some(value) => Some(value),
1064        | None => None,
1065    }
1066}
1067/// Formats a number of bytes into a human-readable string with appropriate units (B, KB, MB, GB, TB)
1068pub fn format_bytes(bytes: u64) -> String {
1069    let units = ["B", "KB", "MB", "GB", "TB"];
1070    let (size, index) = successors(Some((bytes as f64, 0usize)), |(size, index)| {
1071        if *size >= 1024.0 && *index < units.len().saturating_sub(1) {
1072            Some((size / 1024.0, index.saturating_add(1)))
1073        } else {
1074            None
1075        }
1076    })
1077    .last()
1078    .unwrap_or((bytes as f64, 0));
1079    if index == 0 {
1080        format!("{} {}", bytes, units.get(index).unwrap_or(&""))
1081    } else {
1082        format!("{:.2} {}", size, units.get(index).unwrap_or(&""))
1083    }
1084}
1085/// Parse frontmatter and body from content that contains YAML frontmatter (e.g., Markdown, dotprompt, etc.)
1086/// ### Example
1087/// Input
1088/// ```markdown
1089/// ---
1090/// title: This is frontmatter
1091/// ---
1092/// This is the body
1093/// ```
1094/// Output
1095/// ```yaml
1096/// title: This is frontmatter
1097/// ```
1098/// ```markdown
1099/// This is the body
1100/// ```
1101pub fn frontmatter_and_body<S>(value: S) -> (Option<String>, String)
1102where
1103    S: AsRef<str>,
1104{
1105    let content = value.as_ref();
1106    let pattern = r"(?s)---\s*(?<frontmatter>.*?)\s*---\s*(?<body>.*)";
1107    let groups = vec!["frontmatter", "body"];
1108    let lookup = regex_capture_lookup(pattern, content, groups);
1109    (
1110        lookup.get("frontmatter").cloned().filter(|s| !s.is_empty()),
1111        lookup.get("body").cloned().unwrap_or_else(|| content.trim().to_string()),
1112    )
1113}
1114/// Generates a random GUID using a custom alphabet.
1115///
1116/// The generated GUID is a 10-character string composed of a mix of uppercase
1117/// letters, lowercase letters, digits, and a hyphen. The function uses the
1118/// [nanoid](https://github.com/ai/nanoid) library to ensure randomness and uniqueness of the GUID.
1119///
1120/// # Returns
1121///
1122/// A `String` representing a randomly generated GUID.
1123pub fn generate_guid() -> String {
1124    #[cfg(feature = "std")]
1125    {
1126        let alphabet = [
1127            '-', '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',
1128            'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'm', 'n', 'p', 'q', 'r', 't', 'w', 'x', 'y', 'z', '3', '4', '6', '7', '8', '9',
1129        ];
1130        let id = nanoid!(10, &alphabet);
1131        id
1132    }
1133    #[cfg(not(feature = "std"))]
1134    {
1135        String::new()
1136    }
1137}
1138/// Check if value is a URI or filesystem path
1139pub fn is_uri_or_path(value: &str) -> bool {
1140    value.starts_with('/')
1141        || value.starts_with("./")
1142        || value.starts_with("../")
1143        || {
1144            #[cfg(feature = "std")]
1145            {
1146                Path::new(value).is_absolute()
1147            }
1148            #[cfg(not(feature = "std"))]
1149            {
1150                false
1151            }
1152        }
1153        || (if let Ok(uri) = UriRef::parse(value) {
1154            uri.scheme().is_some()
1155        } else {
1156            false
1157        })
1158}
1159/// Prints a diff of changes between two strings.
1160///
1161/// If there are no changes between `old` and `new`, prints a debug message indicating so.
1162/// Otherwise, prints a unified diff of the changes, with `+` indicating lines that are
1163/// present in `new` but not `old`, `-` indicating lines that are present in `old` but
1164/// not `new`, and lines that are the same in both are prefixed with a space.
1165pub fn print_changes(old: &str, new: &str) {
1166    let changes = text_diff_changes(old, new);
1167    let has_no_changes = changes.clone().into_iter().all(|(tag, _)| tag == Equal);
1168    if has_no_changes {
1169        skip!("No format changes");
1170    } else {
1171        #[cfg(feature = "std")]
1172        for change in changes {
1173            print!("{}", change.1);
1174        }
1175    }
1176}
1177/// Prints the given values as a table.
1178///
1179/// # Arguments
1180/// * `headers` - The headers of the table.
1181/// * `rows` - The rows of the table as a vector of vectors of strings.
1182/// * `title` - Optional title of the table.
1183pub fn print_values_as_table<T>(headers: Vec<&str>, rows: Vec<Vec<String>>, title: Option<T>)
1184where
1185    T: ToString,
1186{
1187    #[cfg(feature = "std")]
1188    {
1189        let mut table = Table::new();
1190        table
1191            .load_preset(UTF8_FULL)
1192            .apply_modifier(UTF8_ROUND_CORNERS)
1193            .set_content_arrangement(ContentArrangement::Dynamic)
1194            .set_header(headers);
1195        rows.into_iter().for_each(|row| {
1196            table.add_row(row);
1197        });
1198        match title {
1199            | Some(value) => println!("{} \n{table}", value.to_string().bold()),
1200            | None => println!("{table}"),
1201        }
1202    }
1203    #[cfg(not(feature = "std"))]
1204    {
1205        let _ = (headers, rows, title);
1206    }
1207}
1208/// Helper function to create a lookup dictionary for regex captures
1209/// ### Note
1210/// > This function is sensitive to "un-named" regex groups (e.g. the parentheses around `\d{4}` in `(?<year>(\d{4}))`).
1211/// > For best functionality, avoid creating such groups by omitting unnecessary parentheses.
1212/// ### Example
1213/// ```rust
1214/// use acorn::util::regex_capture_lookup;
1215/// let lookup = regex_capture_lookup(
1216///     r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})",
1217///     "2023-06-30",
1218///     vec!["year", "month", "day"]
1219/// );
1220/// assert_eq!(lookup["year"], "2023");
1221/// assert_eq!(lookup["month"], "06");
1222/// assert_eq!(lookup["day"], "30");
1223/// ```
1224pub fn regex_capture_lookup<S>(pattern: S, text: S, groups: Vec<S>) -> HashMap<S, String>
1225where
1226    S: Into<String> + AsRef<str> + Clone + core::cmp::Eq + core::hash::Hash,
1227{
1228    #[allow(clippy::unwrap_used)]
1229    let re = Regex::new(pattern.as_ref()).unwrap();
1230    let mut lookup = HashMap::new();
1231    if let Some(capture_matches) = re.captures_iter(text.as_ref()).last() {
1232        match capture_matches {
1233            | Ok(captures) => {
1234                captures.iter().skip(1).enumerate().for_each(|(index, data)| {
1235                    if let Some(results) = data {
1236                        if let Some(key) = groups.get(index) {
1237                            let value = results.as_str().to_string();
1238                            lookup.insert(key.clone(), value);
1239                        }
1240                    }
1241                });
1242            }
1243            | Err(_) => (),
1244        }
1245    };
1246    lookup
1247}
1248/// Combine a list of regex patterns into a single alternation regex string.
1249pub fn regex_join(patterns: &[String]) -> Option<String> {
1250    let groups = patterns
1251        .iter()
1252        .filter(|pattern| !pattern.is_empty())
1253        .map(|pattern| format!("(?:{pattern})"))
1254        .collect::<Vec<String>>();
1255    match groups.is_empty() {
1256        | true => None,
1257        | false => Some(groups.join("|")),
1258    }
1259}
1260/// Invert a regex pattern using negative lookahead so matches become exclusions.
1261pub fn regex_inverse(pattern: impl AsRef<str>) -> String {
1262    format!("^(?!.*(?:{})).*$", pattern.as_ref())
1263}
1264/// Converts the given string to snake case.
1265/// ### Example
1266/// ```rust
1267/// use acorn::util::snake_case;
1268///
1269/// let snake = snake_case("CamelCase");
1270/// assert_eq!(snake, "camel_case");
1271/// ```
1272pub fn snake_case<S>(value: S) -> String
1273where
1274    S: Into<String>,
1275{
1276    value.into().to_case(Case::Snake)
1277}
1278/// Returns "s" if the given value is not 1, otherwise returns an empty string.
1279/// ### Example
1280/// ```rust
1281/// use acorn::util::suffix;
1282///
1283/// assert_eq!(suffix(1_usize), "");
1284/// assert_eq!(suffix(2_usize), "s");
1285/// assert_eq!(suffix(1_u64), "");
1286/// assert_eq!(suffix(5_u64), "s");
1287/// ```
1288pub fn suffix<T>(value: T) -> String
1289where
1290    T: PartialEq + From<u8>,
1291{
1292    (if value == T::from(1) { "" } else { "s" }).to_string()
1293}
1294/// Computes the differences between two strings line by line and returns a vector of changes.
1295///
1296/// Each change is represented as a tuple containing a `ChangeTag` indicating the type of change
1297/// (deletion, insertion, or equality) and a `String` with the formatted line prefixed with a
1298/// symbol indicating the type of change (`-` for deletions, `+` for insertions, and a space for equal lines).
1299///
1300/// The formatted string is also colored: red for deletions, green for insertions, and dimmed for equal lines.
1301///
1302/// # Arguments
1303///
1304/// * `old` - A string slice representing the original text.
1305/// * `new` - A string slice representing the modified text.
1306///
1307/// # Returns
1308///
1309/// A vector of tuples, each containing a `ChangeTag` and a formatted `String` representing the changes.
1310pub fn text_diff_changes(old: &str, new: &str) -> Vec<(ChangeTag, String)> {
1311    TextDiff::from_lines(old, new)
1312        .iter_all_changes()
1313        .map(|line| {
1314            let tag = line.tag();
1315            let text = match tag {
1316                | Delete => format!("- {line}").red().to_string(),
1317                | Insert => format!("+ {line}").green().to_string(),
1318                | Equal => format!("  {line}").dimmed().to_string(),
1319            };
1320            (tag, text)
1321        })
1322        .collect::<Vec<_>>()
1323}
1324/// Convert a vector of string slices to a vector of strings
1325pub fn to_string(values: Vec<&str>) -> Vec<String> {
1326    values.iter().map(|s| s.to_string()).collect()
1327}
1328
1329#[cfg(test)]
1330mod tests;