1pub 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}
99pub type ApiFuture<'a> = Pin<Box<dyn Future<Output = ApiResult<()>> + 'a>>;
101pub type RsaKeyPair = (rsa::RsaPrivateKey, rsa::RsaPublicKey);
103pub trait FromCommand {
105 fn from_command<S>(name: S) -> Option<Self>
107 where
108 Self: Sized,
109 S: Into<String> + core::marker::Copy;
110}
111pub trait FromPath {
113 fn from_path<P>(value: &P) -> Self
115 where
116 P: AsRef<Path> + ?Sized;
117}
118pub trait FileExtension {
120 fn extension(&self) -> String;
122}
123pub trait InputOutput: Sized {
125 fn read(path: impl Into<PathBuf>) -> ApiResult<Self>;
127 fn read_cff(_path: impl Into<PathBuf>) -> ApiResult<Self> {
129 Err(eyre!("CFF read not implemented for this type"))
130 }
131 fn read_json(path: PathBuf) -> ApiResult<Self>;
133 fn read_jsonc(_path: PathBuf) -> ApiResult<Self> {
135 Err(eyre!("JSONC read not implemented for this type"))
136 }
137 fn read_markdown(_path: PathBuf) -> ApiResult<Self> {
139 Err(eyre!("Markdown read not implemented for this type"))
140 }
141 fn read_yaml(path: PathBuf) -> ApiResult<Self>;
143 fn write(&self, path: impl Into<PathBuf>) -> ApiResult<()>;
145 fn write_cff(&self, _path: impl Into<PathBuf>) -> ApiResult<()> {
147 Err(eyre!("CFF write not implemented for this type"))
148 }
149 fn write_json(&self, path: impl Into<PathBuf>) -> ApiResult<()>;
151 fn write_markdown(&self, _path: impl Into<PathBuf>) -> ApiResult<()> {
153 Err(eyre!("Markdown write not implemented for this type"))
154 }
155 fn write_yaml(&self, path: impl Into<PathBuf>) -> ApiResult<()>;
157}
158pub trait PathConversion {
160 fn cross_platform_display(&self) -> String;
162 fn relative(&self) -> ApiResult<PathBuf>;
164}
165pub trait PathExt {
167 fn is_windows(&self) -> bool;
169 fn same_as(&self, other: &Path) -> bool;
171}
172#[derive(Clone, Debug, Deserialize, EnumIs, Eq, JsonSchema, PartialEq, Serialize)]
176#[serde(untagged, rename_all = "snake_case")]
177pub enum Executor {
178 #[serde(alias = "Singularity", alias = "singularity")]
182 Apptainer,
183 Docker,
187 Podman,
193 Sandbox,
195 #[serde(alias = "zsh", alias = "pwsh", alias = "cmd", alias = "local")]
197 Shell,
198 #[serde(alias = "remote")]
200 Ssh,
201 #[serde(alias = "k8s")]
205 Kubernetes,
206 #[serde(alias = "vm")]
212 VirtualMachine,
213 Other(String),
215}
216#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
218#[serde(untagged)]
219pub enum License {
220 Multiple(Vec<String>),
222 Single(String),
224}
225#[derive(Clone, Copy, Debug, Default)]
227pub enum ProgressType {
228 #[default]
230 Bar,
231 Spinner,
233 Counter,
235 Silent,
237}
238pub(crate) struct CstValue<'a>(pub(crate) &'a Value);
239#[derive(Debug, Deserialize)]
277pub struct GitlabMergeRequestDiffResponse {
278 new_path: String,
279 }
287#[derive(Clone, Debug, Eq, PartialEq)]
289pub struct Remote(Location);
290pub 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 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 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 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 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 pub fn is_available(&self) -> bool {
405 command_exists(self.as_ref())
406 }
407 pub fn socket(&self) -> Option<String> {
409 match self {
410 | Executor::Docker | Executor::Apptainer => {
411 Some(DOCKER_SOCKET.to_string())
413 }
414 | Executor::Podman => {
415 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 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 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 #[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 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 pub fn as_str(&self) -> &str {
590 (&self.0).into()
591 }
592 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 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 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}
686pub fn apply_progress_style(progress: &ProgressBar, template: &str) {
688 #[allow(clippy::unwrap_used)]
689 progress.set_style(ProgressStyle::with_template(template).unwrap());
690}
691pub 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}
703pub 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}
737pub 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}
759pub 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}
774pub fn current_date() -> String {
785 Timestamp::now().strftime("%Y-%m-%d").to_string()
786}
787pub 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}
801pub 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}
843pub 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}
852pub 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}
907pub 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}
928pub fn files_all<T: FileExtension>(path: PathBuf, extensions: Option<Vec<T>>) -> Vec<PathBuf> {
937 files_all_with_max_depth(path, extensions, None)
938}
939pub 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}
1001pub 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}
1035pub 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}
1074pub 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}
1116pub 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}
1129pub 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}
1154pub 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}
1193pub 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}
1199pub 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}
1217pub 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}
1226pub 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}
1242pub 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}
1258pub 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}
1264pub 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}
1292pub 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#[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#[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}
1362pub 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}
1388pub 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}
1412pub fn progress_renderer() -> MultiProgress {
1414 PROGRESS_RENDERER.clone()
1415}
1416pub 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}
1470pub 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}
1495pub 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}
1509pub 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#[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#[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}
1563pub 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}
1589pub 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}
1600pub 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}
1629pub 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}
1642pub 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}
1707pub 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}
1724pub 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}
1764pub 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;