Skip to main content

acorn/io/
mod.rs

1//! # IO Utilities
2//!
3//! Module to isolate input/output operations to enhance portability
4//!
5//! ## Example Uses
6//!
7//! ### Perform file read and write operations
8//! ```ignore
9//! use acorn::util::{checksum, read_file, write_file};
10//! use std::path::PathBuf;
11//!
12//! // Verify file integrity
13//! assert_eq!(checksum(PathBuf::from("/path/to/file")), "somesha256hashvaluethatisreallylong");
14//!
15//! // Read file contents
16//! let contents = read_file(PathBuf::from("/path/to/this/file"));
17//!
18//! // Write file contents
19//! write_file(PathBuf::from("/path/to/that/file"), contents);
20//! ```
21//!
22use crate::cmd;
23use crate::prelude::CommandOutput;
24use crate::prelude::{
25    canonicalize, consts, create_dir_all, current_dir, io, remove_file, var, var_os, write, BufReader, Cursor, File, HashSet, OpenOptions, Path,
26    PathBuf, Read, Write,
27};
28#[cfg(any(unix, target_os = "wasi", target_os = "redox"))]
29use crate::prelude::{set_permissions, OpenOptionsExt, Permissions, PermissionsExt};
30use crate::util::constants::app::{APPLICATION, LARGE_FILE_THRESHOLD_BYTES, ORGANIZATION, QUALIFIER};
31#[cfg(windows)]
32use crate::util::file_extension;
33use crate::util::{generate_guid, suffix, Checksum, ChecksumAlgorithm, Label, MimeType, SemanticVersion, StringConversion, ToStrings};
34use chrono::Utc;
35use color_eyre::eyre::{eyre, Report, Result};
36use core::fmt;
37use core::pin::Pin;
38use core::time::Duration;
39use data_encoding::HEXUPPER;
40use directories::ProjectDirs;
41use fancy_regex::Regex;
42use futures::stream::{self, StreamExt};
43use futures::Future;
44use glob::glob;
45use indicatif::{ProgressBar, ProgressStyle};
46use is_executable::IsExecutable;
47use rand::rngs::OsRng;
48use ring::digest::{Context, SHA256};
49use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey};
50use rsa::{RsaPrivateKey, RsaPublicKey};
51use schemars::JsonSchema;
52use serde::{Deserialize, Serialize};
53use strum::AsRefStr;
54use tokio::runtime::{Builder, Runtime};
55use tracing::{debug, error, info, trace, warn};
56use which::which;
57use zip::write::SimpleFileOptions;
58use zip::{ZipArchive, ZipWriter};
59
60pub mod api;
61pub mod bagit;
62pub mod config;
63pub mod database;
64pub mod docx;
65pub mod http;
66#[cfg(feature = "agentic")]
67pub mod mcp;
68#[cfg(feature = "powerpoint")]
69pub mod powerpoint;
70pub mod source;
71
72pub use source::Source;
73
74/// Utility type alias for I/O operation futures (e.g., read, write, copy, etc.)
75pub type ApiFuture<'a> = Pin<Box<dyn Future<Output = ApiResult<()>> + 'a>>;
76/// Utility type alias for I/O operation results (e.g., read, write, copy, etc.)
77pub type ApiResult<T> = Result<T, Report>;
78/// An RSA key pair consisting of a private key and its corresponding public key
79pub type RsaKeyPair = (rsa::RsaPrivateKey, rsa::RsaPublicKey);
80/// Add `from_command` trait to `SemanticVersion`
81pub trait FromCommand {
82    /// Convert a command name to a `SemanticVersion` value
83    fn from_command<S>(name: S) -> Option<Self>
84    where
85        Self: Sized,
86        S: Into<String> + core::marker::Copy;
87}
88/// Add `from_path` trait to a value (like `MimeType`)
89pub trait FromPath {
90    /// Convert a path to a value
91    fn from_path<P>(value: &P) -> Self
92    where
93        P: AsRef<Path> + ?Sized;
94}
95/// Trait for I/O operations such as read and write
96pub trait InputOutput: Sized {
97    /// Read data from specified file path
98    fn read(path: impl Into<PathBuf>) -> ApiResult<Self>;
99    /// Read data as CFF from specified path
100    fn read_cff(_path: impl Into<PathBuf>) -> ApiResult<Self> {
101        Err(eyre!("CFF read not implemented for this type"))
102    }
103    /// Read data from specified JSON file path
104    fn read_json(path: PathBuf) -> ApiResult<Self>;
105    /// Read data as Markdown from specified path
106    fn read_markdown(_path: PathBuf) -> Option<Self> {
107        None
108    }
109    /// Read data from specified YAML file path
110    fn read_yaml(path: PathBuf) -> ApiResult<Self>;
111    /// Write data to specified path
112    fn write(&self, path: impl Into<PathBuf>) -> ApiResult<()>;
113    /// Write data as CFF to specified path
114    fn write_cff(&self, _path: impl Into<PathBuf>) -> ApiResult<()> {
115        Err(eyre!("CFF write not implemented for this type"))
116    }
117    /// Write data as JSON to specified path
118    fn write_json(&self, path: impl Into<PathBuf>) -> ApiResult<()>;
119    /// Write data as Markdown (MD) to specified path
120    fn write_markdown(&self, _path: impl Into<PathBuf>) -> ApiResult<()> {
121        Err(eyre!("Markdown write not implemented for this type"))
122    }
123    /// Write data as YAML to specified path
124    fn write_yaml(&self, path: impl Into<PathBuf>) -> ApiResult<()>;
125}
126/// The "engine" or "execution method" that determines where and how to run (e.g., "execute") code
127/// ### Note
128/// At a minimum, choosing an executor also determines level of isolation and security for code execution.
129#[derive(AsRefStr, Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
130#[serde(untagged, rename_all = "snake_case")]
131pub enum Executor {
132    /// Simplifies the creation and execution of containers, ensuring software components are encapsulated for portability and reproducibility
133    /// ### Note
134    /// Formerly Singularity
135    #[serde(alias = "Singularity", alias = "singularity")]
136    Apptainer,
137    /// Container engine and ecosystem for building, running, and managing containers
138    ///
139    /// See <https://www.docker.com/products/docker-desktop> for more information
140    Docker,
141    /// Daemonless, open-source container engine for building, running, and managing containers, with a Docker-like command line
142    /// ### Note
143    /// Podman is rootless by default
144    ///
145    /// See <https://podman.io/> for more information
146    Podman,
147    /// Secure execution environment that leverages technology other than containerization to achieve isolation
148    Sandbox,
149    /// Local command-line interface that interprets and runs commands for the operating system directly
150    #[serde(alias = "zsh", alias = "pwsh", alias = "cmd", alias = "local")]
151    Shell,
152    /// Secure Shell (SSH) protocol for remote command execution
153    #[serde(alias = "remote")]
154    Ssh,
155    /// Portable, extensible, open source platform for managing containerized workloads and services that facilitate both declarative configuration and automation
156    ///
157    /// See <https://kubernetes.io/> for more information
158    #[serde(alias = "k8s")]
159    Kubernetes,
160    /// Software-based computers that run an operating system inside another host system, with stronger isolation than containers
161    /// ### Examples
162    /// - [Oracle VirtualBox](https://www.oracle.com/virtualization/virtualbox/)
163    /// - [Vagrant](https://www.vagrantup.com/)
164    /// - [Amazon Firecracker](https://firecracker-microvm.github.io/)
165    #[serde(alias = "vm")]
166    VirtualMachine,
167    /// Custom or unspecified execution method
168    Other(String),
169}
170/// Root-level or reference-level license declaration.
171#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
172#[serde(untagged)]
173pub enum License {
174    /// Multiple SPDX identifiers interpreted as OR.
175    Multiple(Vec<String>),
176    /// Single SPDX identifier.
177    Single(String),
178}
179/// Progress indicator types for with_progress
180#[derive(Clone, Copy, Debug, Default)]
181pub enum ProgressType {
182    /// Progress bar with spinner, count, and percentage indicator
183    #[default]
184    Bar,
185    /// Indeterminate spinner for unknown item counts
186    Spinner,
187    /// Simple counter showing position of total (e.g., "5 of 100")
188    Counter,
189    /// No progress output
190    Silent,
191}
192/// Struct for parsing GitLab API merge request diff responses
193///
194/// Used by [`files_from_gitlab_merge_request`]
195///
196/// ### Example Response JSON
197/// ```json
198/// [
199///     {
200///         "old_path": "README",
201///         "new_path": "README",
202///         "a_mode": "100644",
203///         "b_mode": "100644",
204///         "diff": "@@ -1 +1 @@\ -Title\ +README",
205///         "collapsed": false,
206///         "too_large": false,
207///         "new_file": false,
208///         "renamed_file": false,
209///         "deleted_file": false,
210///         "generated_file": false
211///     },
212///     {
213///         "old_path": "VERSION",
214///         "new_path": "VERSION",
215///         "a_mode": "100644",
216///         "b_mode": "100644",
217///         "diff": "@@\ -1.9.7\ +1.9.8",
218///         "collapsed": false,
219///         "too_large": false,
220///         "new_file": false,
221///         "renamed_file": false,
222///         "deleted_file": false,
223///         "generated_file": false
224///     }
225/// ]
226/// ```
227///
228/// See <https://docs.gitlab.com/api/merge_requests/#list-merge-request-diffs> for more information
229#[derive(Debug, Deserialize)]
230pub struct GitlabMergeRequestDiffResponse {
231    new_path: String,
232    // diff: String,
233    // old_path: String,
234    // too_large: Option<bool>,
235    // new_file: bool,
236    // renamed_file: bool,
237    // deleted_file: bool,
238    // generated_file: bool,
239}
240/// Struct for adding ToStringList functionality
241pub struct StringList<'a>(pub &'a Vec<PathBuf>);
242impl fmt::Display for Executor {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        let value = match self {
245            | Executor::Apptainer => "apptainer",
246            | Executor::Docker => "docker",
247            | Executor::Podman => "podman",
248            | Executor::Sandbox => "sandbox",
249            | Executor::Shell => "shell",
250            | Executor::Ssh => "ssh",
251            | Executor::Kubernetes => "kubernetes",
252            | Executor::VirtualMachine => "virtual_machine",
253            | Executor::Other(value) => value.as_str(),
254        };
255        write!(f, "{value}")
256    }
257}
258impl From<&str> for Executor {
259    /// Parses a string into a `Executor` value
260    fn from(value: &str) -> Self {
261        match value.to_lowercase().as_str() {
262            | "apptainer" | "singularity" => Executor::Apptainer,
263            | "docker" => Executor::Docker,
264            | "podman" => Executor::Podman,
265            | "sandbox" => Executor::Sandbox,
266            | "shell" => Executor::Shell,
267            | "ssh" => Executor::Ssh,
268            | "kubernetes" | "k8s" => Executor::Kubernetes,
269            | "virtual machine" | "virtual_machine" | "vm" => Executor::VirtualMachine,
270            | other => Executor::Other(other.to_string()),
271        }
272    }
273}
274impl From<Executor> for std::ffi::OsString {
275    fn from(value: Executor) -> Self {
276        Self::from(value.to_string())
277    }
278}
279impl From<Executor> for String {
280    fn from(value: Executor) -> Self {
281        value.to_string()
282    }
283}
284impl Executor {
285    /// Returns the default configuration directory on host for GitLab runners based on the operating system.
286    pub fn default_gitlab_runner_config_directory() -> &'static str {
287        match cfg!(target_os = "macos") {
288            | true => "/Users/Shared/gitlab-runner/config",
289            | false => "/srv/gitlab-runner/config",
290        }
291    }
292    /// Returns the OS binary name used to manage this executor.
293    ///
294    /// Returns `"docker"`, `"podman"`, `"apptainer"` for container-based
295    /// executors, or `"gitlab-runner"` for all others.
296    pub fn command(&self) -> Option<&str> {
297        match self {
298            | Executor::Docker => Some("docker"),
299            | Executor::Podman => Some("podman"),
300            | Executor::Apptainer => Some("apptainer"),
301            | Executor::Shell | Executor::Ssh | Executor::Kubernetes | Executor::Sandbox | Executor::VirtualMachine => None,
302            | Executor::Other(value) => Some(value.as_str()),
303        }
304    }
305    /// Returns the value passed to `gitlab-runner register --executor`.
306    pub fn gitlab_runner_type(&self) -> &str {
307        match self {
308            | Executor::Docker | Executor::Podman => "docker",
309            | Executor::Shell => "shell",
310            | Executor::Ssh => "ssh",
311            | Executor::Kubernetes => "kubernetes",
312            | Executor::VirtualMachine => match consts::OS {
313                | "macos" => "parallels",
314                | _ => "virtualbox",
315            },
316            | Executor::Apptainer | Executor::Sandbox | Executor::Other(_) => "docker",
317        }
318    }
319    /// Returns the path to the socket file used to manage this executor, if applicable.
320    pub fn socket(&self) -> Option<String> {
321        match self {
322            | Executor::Docker | Executor::Apptainer => {
323                // Assumes linux-based image is used for GitLab runner
324                Some("/var/run/docker.sock".to_string())
325            }
326            | Executor::Podman => {
327                // TODO: Windows support
328                if let Some(value) = var_os("XDG_RUNTIME_DIR") {
329                    let path = PathBuf::from(value).join("podman/podman.sock");
330                    if path.exists() {
331                        Some(path.to_absolute_string())
332                    } else {
333                        None
334                    }
335                } else {
336                    // root permission fallback
337                    let path = PathBuf::from("/run/podman/podman.sock");
338                    if path.exists() {
339                        Some(path.to_absolute_string())
340                    } else {
341                        None
342                    }
343                }
344            }
345            | Executor::Shell | Executor::Ssh | Executor::Kubernetes | Executor::Sandbox | Executor::VirtualMachine | Executor::Other(_) => None,
346        }
347    }
348}
349impl FromCommand for SemanticVersion {
350    /// Returns a `SemanticVersion` value based on the output of the `--version` command-line flag
351    /// of the given executable name. Tested with [cargo](https://rustup.rs/), [git](https://git-scm.com/book/en/v2/Getting-Started-The-Command-Line), and [pandoc](https://pandoc.org/).
352    ///
353    /// <div class="warning">this function only supports commands that provide a `--version` flag</div>
354    ///
355    /// ### Example
356    /// ```ignore
357    /// use acorn::schema::validate::SemanticVersion;
358    ///
359    /// let version = SemanticVersion::from_command("cargo").to_string();
360    /// assert_eq!(version, "1.90.0");
361    /// ```
362    #[cfg(feature = "std")]
363    fn from_command<S>(name: S) -> Option<SemanticVersion>
364    where
365        S: Into<String> + core::marker::Copy,
366    {
367        let command = name.into();
368        if command_exists(command.clone()) {
369            match cmd!(&command, ["--version"]) {
370                | Ok(output) if output.status.success() => output.stdout().lines().next().map(SemanticVersion::from),
371                | Ok(_) | Err(_) => None,
372            }
373        } else {
374            None
375        }
376    }
377}
378impl FromPath for MimeType {
379    /// Returns a [`MimeType`] value based on the file extension of the given file name.
380    ///
381    /// Uses [`MimeType::from_string`].
382    ///
383    /// ```ignore
384    /// use acorn::util::MimeType;
385    /// use std::path::Path;
386    ///
387    /// let mime = MimeType::from_path(Path::new("test.cff"));
388    /// assert_eq!(mime, MimeType::Yaml);
389    /// ```
390    fn from_path<P>(value: &P) -> MimeType
391    where
392        P: AsRef<Path> + ?Sized,
393    {
394        MimeType::from(value.as_ref().display().to_string())
395    }
396}
397impl<P: Into<PathBuf> + Clone> ToStrings for Vec<P> {
398    fn to_strings(&self) -> Vec<String> {
399        self.iter()
400            .map(|p| <P as Into<PathBuf>>::into(p.clone()).to_string_lossy().to_string())
401            .collect()
402    }
403    fn to_absolute_strings(&self) -> Vec<String> {
404        self.iter().map(|p| <P as Into<PathBuf>>::into(p.clone()).to_absolute_string()).collect()
405    }
406}
407impl ProgressType {
408    fn template(&self) -> Option<&'static str> {
409        match self {
410            | ProgressType::Bar => Some(Label::PROGRESS_BAR_TEMPLATE),
411            | ProgressType::Spinner => Some(Label::PROGRESS_SPINNER_TEMPLATE),
412            | ProgressType::Counter => Some(Label::PROGRESS_COUNTER_TEMPLATE),
413            | ProgressType::Silent => None,
414        }
415    }
416    fn is_indeterminate(&self) -> bool {
417        matches!(self, ProgressType::Spinner)
418    }
419}
420impl StringConversion for PathBuf {
421    fn file_name_with_parent(&self) -> String {
422        file_name_with_parent(self.clone())
423    }
424    fn to_absolute_string(&self) -> String {
425        to_absolute_string(self.clone())
426    }
427}
428/// Creates zip archive from directory
429pub fn archive(path: PathBuf, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
430    let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
431    let zip_file_path = match destination {
432        | Some(value) => value,
433        | None => path.with_extension("zip"),
434    };
435    info!("=> {} Create archive at {}", Label::using(), zip_file_path.to_absolute_string());
436    let prepared = if zip_file_path.exists() {
437        match zip_file_path.symlink_metadata() {
438            | Ok(metadata) => {
439                let file_type = metadata.file_type();
440                if file_type.is_symlink() {
441                    error!("=> {} Create zip archive — destination cannot be a symlink", Label::fail());
442                    false
443                } else if metadata.is_file() {
444                    match remove_file(zip_file_path.clone()) {
445                        | Ok(_) => true,
446                        | Err(why) => {
447                            error!("=> {} Prepare zip destination — {why}", Label::fail());
448                            false
449                        }
450                    }
451                } else {
452                    error!("=> {} Create zip archive — destination exists and is not a file", Label::fail());
453                    false
454                }
455            }
456            | Err(why) => {
457                error!("=> {} Inspect zip destination — {why}", Label::fail());
458                false
459            }
460        }
461    } else {
462        true
463    };
464    let zip_file = if prepared {
465        match OpenOptions::new().write(true).create_new(true).open(&zip_file_path) {
466            | Ok(zip_file) => Some(ZipWriter::new(zip_file)),
467            | Err(why) => {
468                error!("=> {} Create zip archive — {why}", Label::fail());
469                None
470            }
471        }
472    } else {
473        None
474    };
475    if let Some(mut zip) = zip_file {
476        let files = files_all(path.clone(), None).into_iter().filter(|x| x.is_file());
477        for file_path in files {
478            if let Ok(file) = File::open(file_path.clone()) {
479                let name = match path.canonicalize() {
480                    | Ok(relative) => file_path.strip_prefix(relative).unwrap_or_else(|_| &file_path),
481                    | Err(_) => &file_path,
482                };
483                trace!(
484                    file = name.to_path_buf().to_absolute_string(),
485                    "=> {} Add file to archive",
486                    Label::using()
487                );
488                match zip.start_file_from_path(name, options) {
489                    | Ok(_) => {
490                        let mut buffer = Vec::new();
491                        match io::copy(&mut file.take(u64::MAX), &mut buffer) {
492                            | Ok(_) => match zip.write_all(&buffer) {
493                                | Ok(_) => {}
494                                | Err(why) => {
495                                    error!(file = file_path.to_absolute_string(), "=> {} Write zip archive — {why}", Label::fail())
496                                }
497                            },
498                            | Err(why) => {
499                                error!("=> {} Copy buffer - {why}", Label::fail())
500                            }
501                        }
502                    }
503                    | Err(why) => {
504                        error!(file = file_path.to_absolute_string(), "=> {} Start zip archive - {why}", Label::fail());
505                    }
506                }
507            }
508        }
509        match zip.finish() {
510            | Ok(_) => Ok(zip_file_path),
511            | Err(why) => {
512                error!(file = path.to_absolute_string(), "=> {} Finish zip archive - {why}", Label::fail());
513                Err(why.into())
514            }
515        }
516    } else {
517        Err(eyre!("Unable to create zip archive"))
518    }
519}
520/// Create a new [Tokio](https://tokio.rs/) runtime
521/// ### Example
522/// ```ignore
523/// async_runtime().block_on(async {
524///     // ...async stuff
525/// });
526/// ```
527pub fn async_runtime() -> Runtime {
528    debug!("=> {} Async runtime", Label::using());
529    #[allow(clippy::unwrap_used)]
530    Builder::new_current_thread().enable_all().build().unwrap()
531}
532/// Checks if a given command exists in current terminal context.
533///
534/// # Arguments
535///
536/// * `name` - A string slice or `String` containing the name of the command to be checked.
537///
538/// # Return
539///
540/// A boolean indicating whether the command exists or not.
541pub fn command_exists<S>(name: S) -> bool
542where
543    S: Into<String>,
544{
545    let command = name.into();
546    match which(&command) {
547        | Ok(value) => {
548            let path = value.clone().to_absolute_string();
549            match value.try_exists() {
550                | Ok(true) => {
551                    debug!(path, "=> {} Command", Label::found());
552                    true
553                }
554                | _ => {
555                    debug!(path, "=> {} Command", Label::not_found());
556                    false
557                }
558            }
559        }
560        | Err(_) => {
561            warn!("=> {} Command {}", Label::not_found(), command);
562            false
563        }
564    }
565}
566/// Create an RSA public/private key pair with 2048 bits of entropy using the `rsa` crate
567pub fn create_rsa_keypair() -> ApiResult<RsaKeyPair> {
568    let bits = 2048;
569    let mut rng = OsRng;
570    match RsaPrivateKey::new(&mut rng, bits) {
571        | Ok(private_key) => {
572            let public_key = RsaPublicKey::from(&private_key);
573            Ok((private_key, public_key))
574        }
575        | Err(why) => {
576            error!("=> {} Create RSA key pair — {why}", Label::fail());
577            Err(eyre!("Failed to create RSA key pair — {why}"))
578        }
579    }
580}
581/// Returns the current date in ISO8601 format (YYYY-MM-DD).
582/// ### Examples
583/// ```rust
584/// use acorn::io::current_date;
585///
586/// let date = current_date();
587/// // Returns something like "2026-01-22"
588/// assert_eq!(date.len(), 10);
589/// assert!(date.contains("-"));
590/// ```
591pub fn current_date() -> String {
592    Utc::now().format("%Y-%m-%d").to_string()
593}
594/// Downloads a binary file from the given URL to the destination path.
595///
596/// # Arguments
597///
598/// * `url` - A string slice representing the URL of the binary to download.
599/// * `destination` - A path to the root directory where the file should be saved.
600///
601/// # Returns
602///
603/// A `Result` containing a `PathBuf` to the downloaded file on success, or a string error message on failure.
604///
605/// # Notes
606/// - Uses [`async_runtime`] for asynchronous operations.
607pub async fn download_binary<S, P>(url: S, destination: P) -> ApiResult<PathBuf>
608where
609    S: Into<String> + Clone + core::marker::Copy,
610    P: Into<PathBuf> + Clone,
611{
612    let url_string: String = url.into();
613    let dest: PathBuf = destination.clone().into();
614    let filename = PathBuf::from(url_string.clone())
615        .file_name()
616        .and_then(|f| f.to_str())
617        .unwrap_or("downloaded_file")
618        .to_string();
619    match http::get(url_string.clone()).send().await {
620        | Ok(data) => match data.bytes().await {
621            | Ok(content) => {
622                let output = dest.clone().join(filename.clone());
623                match write(output.clone(), content.as_slice()) {
624                    | Ok(_) => {
625                        debug!(filename, "=> {} Downloaded", Label::output());
626                        Ok(output)
627                    }
628                    | Err(why) => Err(eyre!("Failed to write {filename} - {why}")),
629                }
630            }
631            | Err(_) => Err(eyre!("No content downloaded from {url_string}")),
632        },
633        | Err(_) => Err(eyre!("Failed to download {url_string}")),
634    }
635}
636/// Returns whether an environment variable is set to a truthy value.
637///
638/// Recognized truthy values are `1`, `true`, `yes`, and `on`, matched case-insensitively.
639/// Returns `None` when the variable is not set.
640pub fn env_var_is_truthy(name: impl AsRef<str>) -> Option<bool> {
641    var(name.as_ref())
642        .ok()
643        .map(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
644}
645/// Extract zip archive
646/// ### Note
647/// If `destination` is not provided, the extracted files will be saved in a folder named "extract" an OS-specific cache location.
648pub fn extract_zip(path: PathBuf, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
649    let root = match destination {
650        | Some(value) => value,
651        | None => standard_project_folder("extract", None),
652    };
653    match File::open(path.clone()) {
654        | Ok(zip_file) => match ZipArchive::new(zip_file) {
655            | Ok(mut archive) => {
656                let success = (0..archive.len()).all(|index| match archive.by_index(index) {
657                    | Ok(mut file) => {
658                        let target = root.join(file.name());
659                        if let Some(parent) = target.parent() {
660                            match create_dir_all(parent) {
661                                | Ok(_) => {}
662                                | Err(why) => error!(path = parent.to_path_buf().to_absolute_string(), "=> {} Create - {}", Label::fail(), why),
663                            }
664                        }
665                        match OpenOptions::new().write(true).create_new(true).open(&target) {
666                            | Ok(mut output_file) => match io::copy(&mut file, &mut output_file) {
667                                | Ok(_) => true,
668                                | Err(why) => {
669                                    error!(path = target.to_absolute_string(), "=> {} Copy file content - {why}", Label::fail());
670                                    false
671                                }
672                            },
673                            | Err(_) => {
674                                error!(path = target.to_absolute_string(), "=> {} Create file", Label::fail());
675                                false
676                            }
677                        }
678                    }
679                    | Err(why) => {
680                        error!(path = path.to_absolute_string(), "=> {} Extract file - {why}", Label::fail());
681                        false
682                    }
683                });
684                if success {
685                    info!(path = root.to_absolute_string(), "=> {} Extract zip archive", Label::pass());
686                    Ok(root)
687                } else {
688                    error!(path = root.to_absolute_string(), "=> {} Extract zip archive", Label::fail());
689                    Err(eyre!("Failed to extract zip archive"))
690                }
691            }
692            | Err(why) => {
693                error!(path = path.to_absolute_string(), "=> {} Read zip archive - {why}", Label::fail());
694                Err(eyre!("Failed to read zip archive - {why}"))
695            }
696        },
697        | Err(why) => {
698            error!(path = path.to_absolute_string(), "=> {} Read file - {why}", Label::fail());
699            Err(eyre!("Failed to read file - {why}"))
700        }
701    }
702}
703/// Get SHA256 hash of a file
704///
705/// See <https://rust-lang-nursery.github.io/rust-cookbook/cryptography/hashing.html>
706///
707/// ### Example
708/// ```ignore
709/// use ring::digest::SHA512;
710/// use acorn::io::file_checksum;
711///
712/// let checksum = file_checksum("path/to/file", Some(&SHA512));
713/// assert!(checksum.is_some());
714/// ```
715pub fn file_checksum<P>(path: P, algorithm: Option<&'static ring::digest::Algorithm>) -> Option<Checksum>
716where
717    P: Into<PathBuf>,
718{
719    let value = path.into();
720    let digest_algorithm = algorithm.unwrap_or(&SHA256);
721    let checksum_algorithm = ChecksumAlgorithm::from(digest_algorithm);
722    match File::open(value.clone()) {
723        | Ok(file) => {
724            let mut buffer = [0; 1024];
725            let mut context = Context::new(digest_algorithm);
726            let mut reader = BufReader::new(file);
727            loop {
728                let count = match reader.read(&mut buffer) {
729                    | Ok(c) => c,
730                    | Err(err) => {
731                        error!(
732                            error = err.to_string(),
733                            path = value.to_absolute_string(),
734                            "=> {} Read file checksum",
735                            Label::fail()
736                        );
737                        return None;
738                    }
739                };
740                if count == 0 {
741                    break;
742                }
743                context.update(buffer.get(..count).unwrap_or(&[]));
744            }
745            let digest = context.finish();
746            let result = HEXUPPER.encode(digest.as_ref());
747            Some(Checksum {
748                algorithm: checksum_algorithm,
749                checksum_value: result.to_lowercase(),
750            })
751        }
752        | Err(err) => {
753            error!(
754                error = err.to_string(),
755                path = value.to_absolute_string(),
756                "=> {} Read file",
757                Label::fail()
758            );
759            None
760        }
761    }
762}
763/// Returns a vector of `PathBuf` containing all files in a directory that match at least one of the given extensions.
764///
765/// # Arguments
766/// * `path` - A `PathBuf` to the directory to search — also accepts URI format paths (e.g., `"file:///path/to/directory"`).
767/// * `extensions` - An `Option` containing a list of string slice(s) representing the file extension(s) to search for.
768///
769/// # Returns
770/// A `Vec` containing `PathBuf` values of all files in the given directory that match at least one of the given extensions.
771pub fn files_all(path: PathBuf, extensions: Option<Vec<&str>>) -> Vec<PathBuf> {
772    let path = uri_to_path(path);
773    fn paths_to_vec(paths: glob::Paths) -> Vec<PathBuf> {
774        paths.collect::<Vec<_>>().into_iter().filter_map(|x| x.ok()).collect::<Vec<_>>()
775    }
776    fn pattern(path: PathBuf, extension: &str) -> String {
777        let ext = &extension.to_lowercase();
778        let result = format!("{}/**/*.{}", path.to_absolute_string(), ext);
779        debug!("=> {} {result}", Label::using());
780        result
781    }
782    if path.is_dir() {
783        match extensions {
784            | Some(values) => {
785                values
786                    .into_iter()
787                    .map(|extension| {
788                        let glob_pattern = pattern(path.clone(), extension);
789                        glob(&glob_pattern)
790                    })
791                    .filter_map(|x| x.ok())
792                    .flat_map(paths_to_vec)
793                    .fold((HashSet::new(), Vec::new()), |(mut seen, mut ordered), path| {
794                        if seen.insert(path.clone()) {
795                            ordered.push(path);
796                        }
797                        (seen, ordered)
798                    })
799                    .1
800            }
801            | None => match glob(&format!("{}/**/*", path.to_absolute_string())) {
802                | Ok(paths) => paths_to_vec(paths),
803                | Err(why) => {
804                    error!("=> {} Get all files (Glob) - {why}", Label::fail());
805                    vec![]
806                }
807            },
808        }
809    } else {
810        if extensions.is_some() {
811            warn!(
812                path = path.clone().to_absolute_string(),
813                "=> {} Extension passed with single file to files_all()...was this intended?",
814                Label::using()
815            );
816        }
817        vec![path]
818    }
819}
820/// Returns a vector of `PathBuf` containing all files changed in the given Git branch relative to the default branch.
821///
822/// # Arguments
823///
824/// * `value` - A string slice representing the name of the Git branch to check.
825/// * `extension` - An `Option` containing a string slice representing the file extension to filter results by.
826pub fn files_from_git_branch(value: &str, extensions: Option<Vec<&str>>) -> Vec<PathBuf> {
827    if command_exists("git".to_owned()) {
828        let default_branch = match git_default_branch_name() {
829            | Some(value) => value,
830            | None => "main".to_string(),
831        };
832        let args = vec!["diff", "--name-only", &default_branch, "--merge-base", value];
833        match cmd!("git", args) {
834            | Ok(output) if output.status.success() => filter_git_command_result(output.stdout(), extensions),
835            | Ok(output) => {
836                let why = output.stderr();
837                let message = if why.is_empty() {
838                    format!("process exited with status {}", output.status)
839                } else {
840                    why
841                };
842                error!("=> {} Get files from Git branch - {}", Label::fail(), message);
843                vec![]
844            }
845            | Err(why) => {
846                error!("=> {} Get files from Git branch - {why}", Label::fail());
847                vec![]
848            }
849        }
850    } else {
851        vec![]
852    }
853}
854/// Returns a vector of `PathBuf` containing all files changed in the given Git commit.
855///
856/// # Arguments
857///
858/// * `value` - A string slice representing the Git commit hash to check.
859/// * `extension` - An `Option` containing a string slice representing the file extension to filter results by.
860pub fn files_from_git_commit(value: &str, extensions: Option<Vec<&str>>) -> Vec<PathBuf> {
861    if command_exists("git".to_owned()) {
862        let args = vec!["diff-tree", "--no-commit-id", "--name-only", "-r", value];
863        let result = cmd!("git", args);
864        debug!("=> {} Git command response - {result:?}", Label::using());
865        let files = match result {
866            | Ok(output) if output.status.success() => filter_git_command_result(output.stdout(), extensions),
867            | Ok(output) => {
868                let why = output.stderr();
869                let message = if why.is_empty() {
870                    format!("process exited with status {}", output.status)
871                } else {
872                    why
873                };
874                error!("=> {} Get files from Git commit - {}", Label::fail(), message);
875                vec![]
876            }
877            | Err(why) => {
878                error!("=> {} Get files from Git commit - {why}", Label::fail());
879                vec![]
880            }
881        };
882        debug!(
883            "=> {} Found {} file{} from Git commit - {files:?}",
884            Label::using(),
885            files.len(),
886            suffix(files.len())
887        );
888        files
889    } else {
890        vec![]
891    }
892}
893/// Returns a vector of `PathBuf` containing all files changed in a GitLab merge request, as determined by the `CI_API_V4_URL`, `CI_MERGE_REQUEST_PROJECT_ID`, and `CI_MERGE_REQUEST_IID` environment variables[^env].
894///
895/// See <https://docs.gitlab.com/api/merge_requests/#list-merge-request-diffs> for more information
896///
897/// [^env]: See <https://docs.gitlab.com/ci/variables/predefined_variables/> for more information about GitLab CI environment variables
898pub async fn files_from_gitlab_merge_request(extensions: Option<Vec<&str>>) -> Vec<PathBuf> {
899    let root = var("CI_API_V4_URL").unwrap_or_default();
900    let project_id = var("CI_MERGE_REQUEST_PROJECT_ID").unwrap_or_default();
901    let merge_request_iid = var("CI_MERGE_REQUEST_IID").unwrap_or_default();
902    let path = format!("/projects/{project_id}/merge_requests/{merge_request_iid}/diffs");
903    let url = format!("{root}{path}");
904    match http::get(url).send().await {
905        | Ok(response) => {
906            let content: serde_json::Result<Vec<GitlabMergeRequestDiffResponse>> = response.text().await.map_or_else(
907                |_| Err(serde_json::Error::io(io::Error::other("Failed to read response text"))),
908                |body| serde_json::from_str(&body),
909            );
910            match content {
911                | Ok(data) => {
912                    debug!("=> {} GitLab API merge request diff response - {data:#?}", Label::using());
913                    let results = data.into_iter().map(|x| PathBuf::from(x.new_path)).collect::<Vec<PathBuf>>();
914                    match extensions {
915                        | Some(values) => results
916                            .into_iter()
917                            .filter(|path| values.iter().any(|ext| MimeType::from_path(path).file_type() == *ext.to_lowercase()))
918                            .collect::<Vec<_>>(),
919                        | None => results,
920                    }
921                }
922                | Err(why) => {
923                    error!("=> {} Parse GitLab API merge request diff response - {why}", Label::fail());
924                    vec![]
925                }
926            }
927        }
928        | Err(why) => {
929            error!("=> {} Get GitLab API merge request diff response - {why}", Label::fail());
930            vec![]
931        }
932    }
933}
934/// Filter Git command result by file extension
935pub fn filter_git_command_result(value: String, extensions: Option<Vec<&str>>) -> Vec<PathBuf> {
936    match extensions {
937        | Some(values) => value
938            .to_lowercase()
939            .split("\n")
940            .map(PathBuf::from)
941            .filter(|path| values.iter().any(|ext| MimeType::from_path(path).file_type() == *ext.to_lowercase()))
942            .collect::<Vec<_>>(),
943        | None => value.to_lowercase().split("\n").map(PathBuf::from).collect::<Vec<_>>(),
944    }
945}
946/// Return file paths in a vector that don't match the ignore pattern
947/// ### Example
948/// ```rust
949/// use acorn::io::filter_ignored;
950/// use std::path::PathBuf;
951///
952/// let paths = vec![PathBuf::from("/path/to/foo.txt"), PathBuf::from("/path/to/bar.txt")];
953/// let ignore = Some("*.txt".to_string());
954/// let result = filter_ignored(paths, ignore);
955/// assert!(result.unwrap().is_empty());
956/// ```
957pub fn filter_ignored(paths: Vec<PathBuf>, ignore: Option<String>) -> ApiResult<Vec<PathBuf>> {
958    match ignore {
959        | Some(ignore_pattern) => match Regex::new(&ignore_pattern) {
960            | Ok(re) => Ok(paths
961                .into_iter()
962                .map(to_absolute_string)
963                .filter(|x| !re.is_match(x).unwrap_or(false))
964                .map(PathBuf::from)
965                .collect()),
966            | Err(why) => Err(eyre!("Invalid regex/filter pattern: {why}")),
967        },
968        | None => Ok(paths),
969    }
970}
971/// Return file paths that do not match an ignore pattern relative to a local root path.
972///
973/// This applies root containment checks and normalized relative-path matching.
974pub fn filter_ignored_with_root(paths: Vec<PathBuf>, ignore: Option<String>, root: PathBuf) -> ApiResult<Vec<PathBuf>> {
975    match ignore {
976        | Some(ignore_pattern) => match Regex::new(&ignore_pattern) {
977            | Ok(re) => {
978                let root = if root.is_file() {
979                    root.parent().map(|value| value.to_path_buf()).unwrap_or(root)
980                } else {
981                    root
982                };
983                let normalized_root = canonicalize(root.clone()).unwrap_or(root);
984                let mut filtered: Vec<PathBuf> = vec![];
985                for path in paths {
986                    let normalized_path = canonicalize(path.clone()).unwrap_or(path.clone());
987                    match normalized_path.strip_prefix(&normalized_root) {
988                        | Ok(relative) => {
989                            let value = relative.to_string_lossy().to_string().replace('\\', "/");
990                            if !re.is_match(&value).unwrap_or(false) {
991                                filtered.push(path);
992                            }
993                        }
994                        | Err(_) => {
995                            return Err(eyre!(
996                                "Path '{}' is outside resolved root '{}'",
997                                normalized_path.to_absolute_string(),
998                                normalized_root.to_absolute_string()
999                            ));
1000                        }
1001                    }
1002                }
1003                Ok(filtered)
1004            }
1005            | Err(why) => Err(eyre!("Invalid regex/filter pattern: {why}")),
1006        },
1007        | None => Ok(paths),
1008    }
1009}
1010/// Returns the value of the first environment variable in the list that is set
1011///
1012/// ### Example
1013/// ```rust
1014/// use acorn::io::first_env_var_value;
1015/// use std::env;
1016///
1017/// env::set_var("MY_APP_CONFIG", "config_value");
1018/// let result = first_env_var_value(&["NON_EXISTENT_VAR", "MY_APP_CONFIG", "ANOTHER_VAR"]);
1019/// assert_eq!(result, Some("config_value".to_string()));
1020/// env::remove_var("MY_APP_CONFIG");
1021/// ```
1022pub fn first_env_var(names: &[&str]) -> Option<String> {
1023    names.iter().find_map(|name| var(name).ok())
1024}
1025/// Returns the size of a folder in bytes
1026pub fn folder_size<P: Into<PathBuf>>(path: P) -> u64 {
1027    files_all(path.into(), None)
1028        .into_iter()
1029        .filter_map(|p| p.metadata().ok())
1030        .filter(|m| m.is_file())
1031        .map(|m| m.len())
1032        .sum()
1033}
1034/// Returns the current Git branch name if the `git` command is available and executed successfully.
1035///
1036/// This function executes the `git symbolic-ref --short HEAD` command to retrieve the name of
1037/// the current Git branch. If the command is successful, the branch name is extracted and returned
1038/// as a `String`. If the command fails or if `git` is not available, the function returns `None`.
1039pub fn git_branch_name() -> Option<String> {
1040    if command_exists("git".to_owned()) {
1041        let args = vec!["symbolic-ref", "--short", "HEAD"];
1042        match cmd!("git", args) {
1043            | Ok(output) if output.status.success() => output.stdout().split("/").last().map(|x| x.to_string()),
1044            | Ok(_) => None,
1045            | Err(_) => None,
1046        }
1047    } else {
1048        None
1049    }
1050}
1051/// Returns the default Git branch name if the `git` command is available and executed successfully.
1052///
1053/// This function executes the `git symbolic-ref refs/remotes/origin/HEAD --short` command to retrieve
1054/// the default Git branch name. If the command is successful, the branch name is extracted and returned
1055/// as a `String`. If the command fails or if `git` is not available, the function returns `None`.
1056pub fn git_default_branch_name() -> Option<String> {
1057    if command_exists("git".to_owned()) {
1058        let args = vec!["symbolic-ref", "refs/remotes/origin/HEAD", "--short"];
1059        match cmd!("git", args) {
1060            | Ok(output) if output.status.success() => output.stdout().split("/").last().map(|x| x.to_string()),
1061            | Ok(_) => None,
1062            | Err(_) => None,
1063        }
1064    } else {
1065        None
1066    }
1067}
1068/// Returns a vector of `PathBuf` representing paths to all images found in the given
1069/// directory and all of its subdirectories.
1070///
1071/// # Arguments
1072///
1073/// * `root` - A value that can be converted into a `PathBuf` and implements the `Clone` trait. This is the directory in which the search for images is performed.
1074///
1075/// # Returns
1076///
1077/// A vector of `PathBuf` representing paths to all images found in the given directory and
1078/// all of its subdirectories. The paths are sorted alphabetically.
1079///
1080/// # Notes
1081/// - Supported image formats are "JPEG", "PNG", "SVG", and "GIF"
1082pub fn image_paths<P>(root: P) -> Vec<PathBuf>
1083where
1084    P: Into<PathBuf> + Clone,
1085{
1086    let extensions = ["jpg", "jpeg", "png", "svg", "gif"];
1087    let mut files = extensions
1088        .iter()
1089        .flat_map(|ext| glob(&format!("{}/**/*.{}", root.clone().into().display(), ext)))
1090        .flat_map(|paths| paths.collect::<Vec<_>>())
1091        .flatten()
1092        .collect::<Vec<PathBuf>>();
1093    files.sort();
1094    files
1095}
1096/// Makes the given file executable.
1097///
1098/// # Parameters
1099///
1100/// * `path` - A `PathBuf` containing the path to the file to be made executable.
1101///
1102/// # Return
1103///
1104/// A boolean indicating whether the file is executable after calling this function.
1105#[cfg(any(unix, target_os = "wasi", target_os = "redox"))]
1106pub fn make_executable<P>(path: P) -> bool
1107where
1108    P: Into<PathBuf> + Clone,
1109{
1110    let path = path.into();
1111    let create_with_mode = OpenOptions::new().write(true).create_new(true).mode(0o755).open(path.as_path());
1112    match create_with_mode {
1113        | Ok(_) => path.is_executable(),
1114        | Err(why) => {
1115            if why.kind() == io::ErrorKind::AlreadyExists {
1116                match set_permissions(path.as_path(), Permissions::from_mode(0o755)) {
1117                    | Ok(()) => path.is_executable(),
1118                    | Err(why) => {
1119                        debug!(path = path.to_absolute_string(), "=> {} Set permissions — {why}", Label::fail());
1120                        false
1121                    }
1122                }
1123            } else {
1124                debug!(path = path.to_absolute_string(), "=> {} Create executable file — {why}", Label::fail());
1125                false
1126            }
1127        }
1128    }
1129}
1130/// Makes the given file executable.
1131///
1132/// # Parameters
1133///
1134/// * `path` - A `PathBuf` containing the path to the file to be made executable.
1135///
1136/// # Return
1137///
1138/// A boolean indicating whether the file is executable after calling this function.
1139#[cfg(windows)]
1140pub fn make_executable<P>(path: P) -> bool
1141where
1142    P: Into<PathBuf> + Clone,
1143{
1144    let binary = match file_extension(path.clone().into().to_absolute_string()) {
1145        | None => path.into().with_extension("exe"),
1146        | _ => path.into(),
1147    };
1148    debug!("=> {} {binary:#?}", Label::using());
1149    binary.is_executable()
1150}
1151/// Returns a string containing the file name with its parent directory.
1152///
1153/// If the `PathBuf` is a directory, only the file name is returned.
1154pub fn file_name_with_parent(value: impl Into<PathBuf>) -> String {
1155    let path = value.into();
1156    let name = path.file_name().and_then(|value| value.to_str()).unwrap_or_default().to_string();
1157    if path.is_dir() {
1158        name
1159    } else {
1160        let parent_name = path
1161            .parent()
1162            .and_then(|value| value.file_name())
1163            .and_then(|value| value.to_str())
1164            .unwrap_or_default();
1165        if parent_name.is_empty() {
1166            name
1167        } else {
1168            format!("{parent_name}/{name}")
1169        }
1170    }
1171}
1172/// Returns the absolute path of the parent directory for the given path.
1173pub fn parent<P>(path: P) -> PathBuf
1174where
1175    P: Into<PathBuf> + Clone,
1176{
1177    let default = PathBuf::from(".");
1178    match path.clone().into().canonicalize() {
1179        | Ok(value) => match value.parent() {
1180            | Some(value) => value.to_path_buf(),
1181            | None => {
1182                warn!("=> {} Resolve parent path", Label::fail());
1183                default
1184            }
1185        },
1186        | Err(why) => {
1187            debug!("=> {} Resolve absolute path - {why}", Label::fail());
1188            match path.into().parent() {
1189                | Some(value) if !value.to_path_buf().to_absolute_string().is_empty() => value.to_path_buf(),
1190                | Some(_) | None => {
1191                    warn!("=> {} Parent path was empty or could not be resolved", Label::fail());
1192                    default
1193                }
1194            }
1195        }
1196    }
1197}
1198/// Reads the given file and returns its contents as a string.
1199///
1200/// This function is thread-safe and can be used with rayon's parallel iterators.
1201///
1202/// # Parameters
1203///
1204/// * `path` - A `PathBuf` or string slice containing the path to the file to be read.
1205///
1206/// # Return
1207///
1208/// A `Result` containing the contents of the file as a string if the file is readable, or an
1209/// `std::io::Error` otherwise.
1210///
1211/// # Example with rayon
1212///
1213/// ```ignore
1214/// use rayon::prelude::*;
1215///
1216/// let paths = vec![PathBuf::from("file1.txt"), PathBuf::from("file2.txt")];
1217/// let contents: Vec<_> = paths
1218///     .par_iter()
1219///     .filter_map(|path| read_file(path).ok())
1220///     .collect();
1221/// ```
1222pub fn read_file<P>(path: P) -> ApiResult<String>
1223where
1224    P: Into<PathBuf> + Clone + Send,
1225{
1226    let path_buf = path.into();
1227    let filename = path_buf.file_name().unwrap_or_default().to_string_lossy().to_string();
1228    let is_large_file = match path_buf.metadata() {
1229        | Ok(metadata) => metadata.len() >= LARGE_FILE_THRESHOLD_BYTES,
1230        | Err(_) => false,
1231    };
1232    if is_large_file {
1233        trace!(filename, "=> {} Read file with large-file strategy", Label::using());
1234        read_large_file(path_buf)
1235    } else {
1236        match File::open(&path_buf) {
1237            | Ok(file) => {
1238                let mut reader = BufReader::new(file);
1239                let mut content = String::new();
1240                match reader.read_to_string(&mut content) {
1241                    | Ok(_) => Ok(content),
1242                    | Err(why) => Err(eyre!("Failed to read file content — {why}")),
1243                }
1244            }
1245            | Err(why) => {
1246                error!(filename, "=> {} Read file", Label::fail());
1247                Err(eyre!("Failed to read file — {why}"))
1248            }
1249        }
1250    }
1251}
1252/// Reads large files and returns the contents as a string.
1253///
1254/// This function uses a larger buffered reader and pre-allocates the output string
1255/// using file metadata when available.
1256pub fn read_large_file<P>(path: P) -> ApiResult<String>
1257where
1258    P: Into<PathBuf> + Clone + Send,
1259{
1260    match File::open(path.into()) {
1261        | Ok(file) => {
1262            let capacity = file
1263                .metadata()
1264                .ok()
1265                .and_then(|metadata| usize::try_from(metadata.len()).ok())
1266                .unwrap_or(0);
1267            let mut reader = BufReader::with_capacity(1024 * 1024, file);
1268            let mut content = if capacity > 0 { String::with_capacity(capacity) } else { String::new() };
1269            match reader.read_to_string(&mut content) {
1270                | Ok(_) => Ok(content),
1271                | Err(why) => Err(eyre!("Failed to read large file content — {why}")),
1272            }
1273        }
1274        | Err(why) => Err(eyre!("Failed to read large file — {why}")),
1275    }
1276}
1277/// Returns path to a folder in the operating system's cache directory that is unique to the given
1278/// `namespace` with a random UUID as the name of the final folder.
1279///
1280/// The folder is ***not*** created.
1281///
1282/// Used primarily by ACORN CLI where `namespace` is of a subcommand task. e.g. "check", "extract", etc.
1283///
1284/// # Arguments
1285///
1286/// * `namespace` - A string slice representing the name of the namespace.
1287/// * `default` - An optional `PathBuf` to use as the root directory instead of the cache directory.
1288///
1289/// # Returns
1290///
1291/// A `PathBuf` to the folder.
1292pub fn standard_project_folder(namespace: &str, default: Option<PathBuf>) -> PathBuf {
1293    let root = match default {
1294        | Some(value) => value,
1295        | None => match ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION) {
1296            | Some(dirs) => dirs.cache_dir().join(namespace).to_path_buf(),
1297            | None => PathBuf::from(format!("./{namespace}")),
1298        },
1299    };
1300    match create_dir_all(root.clone()) {
1301        | Ok(_) => {}
1302        | Err(why) => error!(directory = root.clone().to_absolute_string(), "=> {} Create - {why}", Label::fail()),
1303    };
1304    root.join(generate_guid())
1305}
1306/// Converts a `PathBuf` into a `String` representation of the **absolute** path.
1307/// <div class="warning">Uses <code>fs::canonicalize</code>, which might cause problems on Windows</div>
1308///
1309/// This function attempts to canonicalize the provided path, which resolves any symbolic links
1310/// and returns an absolute path. If canonicalization fails, the original path is returned as a string.
1311///
1312/// # Arguments
1313///
1314/// * `path` - A `PathBuf` representing the file system path to be converted.
1315///
1316/// # Returns
1317///
1318/// A `String` containing the absolute path if canonicalization succeeds, or the original path as a string otherwise.
1319pub fn to_absolute_string<P>(path: P) -> String
1320where
1321    P: Into<PathBuf> + Clone,
1322{
1323    let result = match canonicalize(path.clone().into().as_path()) {
1324        | Ok(value) => value,
1325        | Err(_) => path.into(),
1326    };
1327    let s = result.display().to_string();
1328    #[cfg(windows)]
1329    let s = s.strip_prefix(r"\\?\").unwrap_or(&s).to_string();
1330    s
1331}
1332/// Returns a sorted list of unique lowercase file extensions from the given paths.
1333pub fn unique_file_extensions(paths: &[PathBuf]) -> Vec<String> {
1334    let mut extensions = paths
1335        .iter()
1336        .filter_map(|path| path.extension().map(|extension| extension.to_string_lossy().to_lowercase()))
1337        .collect::<HashSet<_>>()
1338        .into_iter()
1339        .collect::<Vec<_>>();
1340    extensions.sort_unstable();
1341    extensions
1342}
1343/// Converts a `file://` URI to a [`PathBuf`].
1344///
1345/// If the input does not start with `file://`, it is returned unchanged.
1346pub fn uri_to_path<P>(path: P) -> PathBuf
1347where
1348    P: Into<PathBuf>,
1349{
1350    let value: PathBuf = path.into();
1351    let s = value.to_string_lossy().into_owned();
1352    s.strip_prefix("file://")
1353        .map(|stripped| {
1354            #[cfg(windows)]
1355            let normalized = match stripped.get(1..3) {
1356                | Some(drive) if drive.contains(':') => &stripped[1..],
1357                | _ => stripped,
1358            };
1359            #[cfg(not(windows))]
1360            let normalized = stripped;
1361            PathBuf::from(normalized)
1362        })
1363        .unwrap_or(value)
1364}
1365/// Creates a new progress bar with the specified count and progress type
1366pub fn create_progress_bar(count: usize, progress_type: ProgressType) -> ProgressBar {
1367    if matches!(progress_type, ProgressType::Silent) {
1368        ProgressBar::hidden()
1369    } else {
1370        let progress = if progress_type.is_indeterminate() {
1371            let spinner = ProgressBar::new_spinner();
1372            spinner.enable_steady_tick(Duration::from_millis(120));
1373            spinner
1374        } else {
1375            ProgressBar::new(count as u64)
1376        };
1377        if let Some(template) = progress_type.template() {
1378            #[allow(clippy::unwrap_used)]
1379            progress.set_style(ProgressStyle::with_template(template).unwrap());
1380        }
1381        progress
1382    }
1383}
1384/// Applies a new style template to an existing progress bar
1385pub fn apply_progress_style(progress: &ProgressBar, template: &str) {
1386    #[allow(clippy::unwrap_used)]
1387    progress.set_style(ProgressStyle::with_template(template).unwrap());
1388}
1389/// Finishes a progress bar with a message, applying appropriate final style
1390pub fn finish_progress_bar(progress: &ProgressBar, message: String) {
1391    #[allow(clippy::unwrap_used)]
1392    progress.set_style(ProgressStyle::with_template("  {msg}").unwrap());
1393    progress.finish_with_message(message);
1394}
1395/// Process a collection of data items with progress indication.
1396///
1397/// # Arguments
1398///
1399/// * `items` - Collection of items to process
1400/// * `message` - Function to generate progress message for each item
1401/// * `operation` - Async function to apply to each item
1402/// * `finish_message` - Function to generate completion message
1403/// * `buffer_size` - Concurrency level for parallel processing
1404/// * `progress_type` - Type of progress indicator (Bar, Spinner, Counter, Silent)
1405///
1406/// # Example
1407///
1408/// ```ignore
1409/// let result = with_progress(
1410///     items,
1411///     |item| format!("Processing {}", item),
1412///     |item| async move { process(item) },
1413///     |count| format!("Done! Processed {} items", count),
1414///     Some(10),
1415///     ProgressType::Bar,
1416/// ).await;
1417/// ```
1418pub async fn with_progress<T, U, M, F, Fut>(
1419    items: Vec<T>,
1420    message: M,
1421    operation: F,
1422    finish_message: impl FnOnce(usize) -> String,
1423    buffer_size: Option<usize>,
1424    progress_type: ProgressType,
1425) -> ApiResult<Vec<U>>
1426where
1427    M: for<'a> Fn(&'a T) -> String,
1428    F: Fn(T) -> Fut,
1429    Fut: Future<Output = ApiResult<U>>,
1430{
1431    let concurrency = buffer_size.unwrap_or(10).max(1);
1432    let count = items.len();
1433    let progress = create_progress_bar(count, progress_type);
1434    if matches!(progress_type, ProgressType::Spinner) {
1435        progress.enable_steady_tick(Duration::from_millis(120));
1436    }
1437    let output = stream::iter(items)
1438        .map(|item| {
1439            let msg = message(&item);
1440            let future = operation(item);
1441            async move {
1442                let result = future.await;
1443                (msg, result)
1444            }
1445        })
1446        .buffer_unordered(concurrency)
1447        .map(|(msg, result)| {
1448            progress.set_message(msg);
1449            progress.inc(1);
1450            result
1451        })
1452        .collect::<Vec<_>>()
1453        .await
1454        .into_iter()
1455        .collect::<ApiResult<Vec<_>>>();
1456
1457    if !matches!(progress_type, ProgressType::Silent) {
1458        finish_progress_bar(&progress, finish_message(count));
1459    }
1460    output
1461}
1462/// Writes the given content to a file at the given path
1463///
1464/// # Arguments
1465/// * `path` - A `PathBuf` or string slice containing the path to the file to be written.
1466/// * `content` - A `String` containing the content to be written to the file.
1467///
1468/// # Returns
1469/// A `Result` containing a unit value if the file is written successfully, or an
1470/// `eyre::Report` otherwise.
1471pub fn write_file<P>(path: P, content: String) -> ApiResult<()>
1472where
1473    P: Into<PathBuf>,
1474{
1475    write(path.into(), content.as_bytes())
1476        .map(|_| ())
1477        .map_err(|why| eyre!("Failed to write file - {why}"))
1478}
1479/// Writes bytes to a file at the given path, creating parent directories as needed
1480///
1481/// # Arguments
1482/// * `path` - The output file path
1483/// * `get_bytes` - An async closure/future that returns the bytes to write
1484///
1485/// # Returns
1486/// A `Result` containing a unit value if the file is written successfully, or an
1487/// `eyre::Report` otherwise.
1488pub async fn write_file_bytes<P, F, Fut, E>(path: P, get_bytes: F) -> ApiResult<()>
1489where
1490    P: Into<PathBuf>,
1491    F: FnOnce() -> Fut,
1492    Fut: Future<Output = Result<Vec<u8>, E>>,
1493    E: Into<Report>,
1494{
1495    let path = path.into();
1496    match path.parent() {
1497        | Some(parent) => {
1498            let folder = parent.display().to_string();
1499            match create_dir_all(folder.clone()) {
1500                | Ok(_) => match OpenOptions::new().write(true).create_new(true).open(&path) {
1501                    | Ok(mut file) => match get_bytes().await.map_err(Into::into) {
1502                        | Ok(bytes) => {
1503                            let mut content = Cursor::new(bytes);
1504                            match io::copy(&mut content, &mut file) {
1505                                | Ok(_) => Ok(()),
1506                                | Err(why) => Err(eyre!("Failed to write bytes — {why}")),
1507                            }
1508                        }
1509                        | Err(why) => Err(why),
1510                    },
1511                    | Err(why) => Err(eyre!("Failed to create output file — {why}")),
1512                },
1513                | Err(why) => Err(eyre!("Failed to create output folder — {why}")),
1514            }
1515        }
1516        | None => Err(eyre!("Output path has no parent directory")),
1517    }
1518}
1519/// Writes an RSA key pair to disk
1520///
1521/// The private key at `path` and the public key at `{path}.pub`.
1522///
1523/// When `path` is `None`, the current working directory is used with `id_rsa` as the base name.
1524pub fn write_rsa_keypair<P>(values: RsaKeyPair, path: Option<P>) -> ApiResult<(PathBuf, PathBuf)>
1525where
1526    P: Into<PathBuf>,
1527{
1528    let resolved = match path {
1529        | Some(p) => Ok(p.into()),
1530        | None => match current_dir() {
1531            | Ok(cwd) => Ok(cwd.join("id_rsa")),
1532            | Err(why) => Err(eyre!("Failed to get current directory — {why}")),
1533        },
1534    };
1535    match resolved {
1536        | Ok(path) => {
1537            let (private_key, public_key) = values;
1538            match private_key.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF) {
1539                | Ok(private_key_pem) => match public_key.to_public_key_pem(rsa::pkcs8::LineEnding::LF) {
1540                    | Ok(public_key_pem) => {
1541                        let public_key_path = PathBuf::from(format!("{}.pub", path.display()));
1542                        let private_key_path = path.clone();
1543                        match write_file(path, (*private_key_pem).clone()) {
1544                            | Ok(_) => match write_file(public_key_path.clone(), public_key_pem) {
1545                                | Ok(_) => Ok((private_key_path, public_key_path)),
1546                                | Err(why) => Err(why),
1547                            },
1548                            | Err(why) => Err(why),
1549                        }
1550                    }
1551                    | Err(why) => {
1552                        error!("=> {} Write RSA keypair (public key) — {why}", Label::fail());
1553                        Err(eyre!("Failed to serialize public key to PEM — {why}"))
1554                    }
1555                },
1556                | Err(why) => {
1557                    error!("=> {} Write RSA keypair (private key) — {why}", Label::fail());
1558                    Err(eyre!("Failed to serialize private key to PEM — {why}"))
1559                }
1560            }
1561        }
1562        | Err(why) => Err(why),
1563    }
1564}
1565
1566#[cfg(test)]
1567mod tests;