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