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