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