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