1pub use crate::error::ApiResult;
23#[cfg(unix)]
24use crate::prelude;
25use crate::prelude::{
26 absolute, canonicalize, consts, create_dir_all, current_dir, io, remove_file, temp_dir, var, var_os, write, BufReader, CommandOutput, Cursor,
27 File, HashSet, OpenOptions, OsString, Path, PathBuf, Read, Write,
28};
29#[cfg(any(unix, target_os = "wasi", target_os = "redox"))]
30use crate::prelude::{set_permissions, OpenOptionsExt, Permissions, PermissionsExt};
31#[cfg(windows)]
32use crate::prelude::{symlink_dir, symlink_file};
33use crate::util::constants::app::{APPLICATION, DOCKER_SOCKET, LARGE_FILE_THRESHOLD_BYTES, ORGANIZATION, QUALIFIER};
34#[cfg(windows)]
35use crate::util::file_extension;
36use crate::util::{generate_guid, suffix, Checksum, ChecksumAlgorithm, Label, MimeType, SemanticVersion, StringConversion, ToStrings};
37use crate::{args, cmd, Location};
38use color_eyre::eyre::{eyre, Report};
39use core::fmt;
40use core::pin::Pin;
41use core::time::Duration;
42use data_encoding::HEXUPPER;
43use directories::{BaseDirs, ProjectDirs};
44use fancy_regex::Regex;
45use fluent_uri::Uri;
46use futures::stream::{self, StreamExt};
47use futures::Future;
48use glob::glob;
49use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
50use is_executable::IsExecutable;
51use jiff::Timestamp;
52use jsonc_parser::{cst::CstInputValue, parse_to_serde_value, ParseOptions};
53use lazy_static::lazy_static;
54use nanoid::nanoid;
55use rand::rngs::OsRng;
56use ring::digest::{Context, SHA256, SHA512};
57use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey};
58use rsa::{RsaPrivateKey, RsaPublicKey};
59use schemars::JsonSchema;
60use serde::{de::DeserializeOwned, Deserialize, Serialize};
61use serde_json::Value;
62use strum::EnumIs;
63use tokio::runtime::{Builder, Runtime};
64use tracing::{debug, error, info, trace, warn};
65use which::which;
66use zip::write::SimpleFileOptions;
67use zip::{ZipArchive, ZipWriter};
68
69pub mod api;
70pub mod bagit;
71#[cfg(feature = "chart")]
72pub mod chart;
73pub mod config;
74pub mod database;
75pub mod document;
76pub mod download;
77pub mod fingerprint;
78pub mod http;
79#[cfg(feature = "agentic")]
80pub mod mcp;
81pub mod model;
82#[cfg(feature = "powerpoint")]
83pub mod powerpoint;
84pub mod source;
85pub mod sync;
86
87pub use fingerprint::Fingerprint;
88pub use jsonc_parser::cst::CstRootNode;
89pub use model::ModelListFile;
90pub use source::{Source, SourceAction};
91
92lazy_static! {
93 static ref PROGRESS_RENDERER: MultiProgress = MultiProgress::new();
94}
95pub type ApiFuture<'a> = Pin<Box<dyn Future<Output = ApiResult<()>> + 'a>>;
97pub type RsaKeyPair = (rsa::RsaPrivateKey, rsa::RsaPublicKey);
99pub trait FromCommand {
101 fn from_command<S>(name: S) -> Option<Self>
103 where
104 Self: Sized,
105 S: Into<String> + core::marker::Copy;
106}
107pub trait FromPath {
109 fn from_path<P>(value: &P) -> Self
111 where
112 P: AsRef<Path> + ?Sized;
113}
114pub trait FileExtension {
116 fn extension(&self) -> String;
118}
119pub trait InputOutput: Sized {
121 fn read(path: impl Into<PathBuf>) -> ApiResult<Self>;
123 fn read_cff(_path: impl Into<PathBuf>) -> ApiResult<Self> {
125 Err(eyre!("CFF read not implemented for this type"))
126 }
127 fn read_json(path: PathBuf) -> ApiResult<Self>;
129 fn read_jsonc(_path: PathBuf) -> ApiResult<Self> {
131 Err(eyre!("JSONC read not implemented for this type"))
132 }
133 fn read_markdown(_path: PathBuf) -> ApiResult<Self> {
135 Err(eyre!("Markdown read not implemented for this type"))
136 }
137 fn read_yaml(path: PathBuf) -> ApiResult<Self>;
139 fn write(&self, path: impl Into<PathBuf>) -> ApiResult<()>;
141 fn write_cff(&self, _path: impl Into<PathBuf>) -> ApiResult<()> {
143 Err(eyre!("CFF write not implemented for this type"))
144 }
145 fn write_json(&self, path: impl Into<PathBuf>) -> ApiResult<()>;
147 fn write_markdown(&self, _path: impl Into<PathBuf>) -> ApiResult<()> {
149 Err(eyre!("Markdown write not implemented for this type"))
150 }
151 fn write_yaml(&self, path: impl Into<PathBuf>) -> ApiResult<()>;
153}
154pub trait PathConversion {
156 fn cross_platform_display(&self) -> String;
158}
159pub trait PathExt {
161 fn same_as(&self, other: &Path) -> bool;
163}
164#[derive(Clone, Debug, Deserialize, EnumIs, Eq, JsonSchema, PartialEq, Serialize)]
168#[serde(untagged, rename_all = "snake_case")]
169pub enum Executor {
170 #[serde(alias = "Singularity", alias = "singularity")]
174 Apptainer,
175 Docker,
179 Podman,
185 Sandbox,
187 #[serde(alias = "zsh", alias = "pwsh", alias = "cmd", alias = "local")]
189 Shell,
190 #[serde(alias = "remote")]
192 Ssh,
193 #[serde(alias = "k8s")]
197 Kubernetes,
198 #[serde(alias = "vm")]
204 VirtualMachine,
205 Other(String),
207}
208#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
210#[serde(untagged)]
211pub enum License {
212 Multiple(Vec<String>),
214 Single(String),
216}
217#[derive(Clone, Copy, Debug, Default)]
219pub enum ProgressType {
220 #[default]
222 Bar,
223 Spinner,
225 Counter,
227 Silent,
229}
230pub(crate) struct CstValue<'a>(pub(crate) &'a Value);
231#[derive(Debug, Deserialize)]
269pub struct GitlabMergeRequestDiffResponse {
270 new_path: String,
271 }
279#[derive(Clone, Debug, Eq, PartialEq)]
281pub struct Remote(Location);
282pub struct StringList<'a>(pub &'a Vec<PathBuf>);
284impl From<&'static ring::digest::Algorithm> for ChecksumAlgorithm {
285 fn from(algorithm: &'static ring::digest::Algorithm) -> Self {
286 if core::ptr::eq(algorithm, &SHA512) {
287 Self::Sha512
288 } else {
289 Self::Sha256
290 }
291 }
292}
293impl From<CstValue<'_>> for CstInputValue {
294 fn from(value: CstValue<'_>) -> Self {
295 match value.0 {
296 | Value::Null => Self::Null,
297 | Value::Bool(value) => Self::Bool(*value),
298 | Value::Number(value) => Self::Number(value.to_string()),
299 | Value::String(value) => Self::String(value.clone()),
300 | Value::Array(values) => Self::Array(values.iter().map(|value| CstValue(value).into()).collect()),
301 | Value::Object(values) => Self::Object(values.iter().map(|(key, value)| (key.clone(), CstValue(value).into())).collect()),
302 }
303 }
304}
305impl AsRef<str> for Executor {
306 fn as_ref(&self) -> &str {
307 match self {
308 | Executor::Apptainer => "apptainer",
309 | Executor::Docker => "docker",
310 | Executor::Podman => "podman",
311 | Executor::Sandbox => "sandbox",
312 | Executor::Shell => "shell",
313 | Executor::Ssh => "ssh",
314 | Executor::Kubernetes => "kubernetes",
315 | Executor::VirtualMachine => "virtual_machine",
316 | Executor::Other(value) => value.as_str(),
317 }
318 }
319}
320impl fmt::Display for Executor {
321 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322 f.write_str(self.as_ref())
323 }
324}
325impl From<&str> for Executor {
326 fn from(value: &str) -> Self {
328 match value.to_lowercase().as_str() {
329 | "apptainer" | "singularity" => Executor::Apptainer,
330 | "docker" => Executor::Docker,
331 | "podman" => Executor::Podman,
332 | "sandbox" => Executor::Sandbox,
333 | "shell" => Executor::Shell,
334 | "ssh" => Executor::Ssh,
335 | "kubernetes" | "k8s" => Executor::Kubernetes,
336 | "virtual machine" | "virtual_machine" | "vm" => Executor::VirtualMachine,
337 | other => Executor::Other(other.to_string()),
338 }
339 }
340}
341impl<T: AsRef<str>> FileExtension for T {
342 fn extension(&self) -> String {
343 self.as_ref().to_ascii_lowercase()
344 }
345}
346impl FileExtension for MimeType {
347 fn extension(&self) -> String {
348 self.clone().file_type()
349 }
350}
351impl From<Executor> for std::ffi::OsString {
352 fn from(value: Executor) -> Self {
353 Self::from(value.to_string())
354 }
355}
356impl From<Executor> for String {
357 fn from(value: Executor) -> Self {
358 value.to_string()
359 }
360}
361impl Executor {
362 pub fn default_gitlab_runner_config_directory() -> &'static str {
364 match cfg!(target_os = "macos") {
365 | true => "/Users/Shared/gitlab-runner/config",
366 | false => "/srv/gitlab-runner/config",
367 }
368 }
369 pub fn command(&self) -> Option<&str> {
374 match self {
375 | Executor::Docker => Some("docker"),
376 | Executor::Podman => Some("podman"),
377 | Executor::Apptainer => Some("apptainer"),
378 | Executor::Shell | Executor::Ssh | Executor::Kubernetes | Executor::Sandbox | Executor::VirtualMachine => None,
379 | Executor::Other(value) => Some(value.as_str()),
380 }
381 }
382 pub fn gitlab_runner_type(&self) -> &str {
384 match self {
385 | Executor::Docker | Executor::Podman | Executor::Apptainer | Executor::Sandbox | Executor::Other(_) => "docker",
386 | Executor::Shell => "shell",
387 | Executor::Ssh => "ssh",
388 | Executor::Kubernetes => "kubernetes",
389 | Executor::VirtualMachine => match consts::OS {
390 | "macos" => "parallels",
391 | _ => "virtualbox",
392 },
393 }
394 }
395 pub fn is_available(&self) -> bool {
397 command_exists(self.as_ref())
398 }
399 pub fn socket(&self) -> Option<String> {
401 match self {
402 | Executor::Docker | Executor::Apptainer => {
403 Some(DOCKER_SOCKET.to_string())
405 }
406 | Executor::Podman => {
407 if let Some(value) = var_os("XDG_RUNTIME_DIR") {
409 let path = PathBuf::from(value).join("podman/podman.sock");
410 if path.exists() {
411 Some(path.to_absolute_path())
412 } else {
413 None
414 }
415 } else {
416 let path = PathBuf::from("/run/podman/podman.sock");
418 if path.exists() {
419 Some(path.to_absolute_path())
420 } else {
421 None
422 }
423 }
424 }
425 | Executor::Shell | Executor::Ssh | Executor::Kubernetes | Executor::Sandbox | Executor::VirtualMachine | Executor::Other(_) => None,
426 }
427 }
428 pub fn validate(&self, runners: Option<&[config::RunnerDetails]>, remote: Option<&Remote>) -> ApiResult<()> {
430 match (remote, self.is_docker()) {
431 | (Some(endpoint), false) => Err(eyre!("Remote Docker target '{endpoint}' requires the docker runtime, not {self}")),
432 | (Some(endpoint), true) => runners
433 .and_then(|values| values.iter().find(|runner| !runner.executor.is_docker()))
434 .map_or(Ok(()), |runner| {
435 Err(eyre!(
436 "Remote Docker target '{endpoint}' requires docker runner executors, not {}",
437 runner.executor
438 ))
439 }),
440 | (None, _) => Ok(()),
441 }
442 }
443}
444impl FromCommand for SemanticVersion {
445 #[cfg(feature = "std")]
458 fn from_command<S>(name: S) -> Option<SemanticVersion>
459 where
460 S: Into<String> + core::marker::Copy,
461 {
462 let command = name.into();
463 if command_exists(command.clone()) {
464 match cmd!(&command, ["--version"]) {
465 | Ok(output) if output.status.success() => output.stdout().lines().next().map(SemanticVersion::from),
466 | Ok(_) | Err(_) => None,
467 }
468 } else {
469 None
470 }
471 }
472}
473impl FromPath for MimeType {
474 fn from_path<P>(value: &P) -> MimeType
486 where
487 P: AsRef<Path> + ?Sized,
488 {
489 MimeType::from(value.as_ref().display().to_string())
490 }
491}
492impl PathExt for Path {
493 fn same_as(&self, other: &Path) -> bool {
494 let absolute_paths = absolute(self).and_then(|left| absolute(other).map(|right| (left, right)));
495 self == other || absolute_paths.is_ok_and(|(left, right)| left == right)
496 }
497}
498impl PathExt for &Path {
499 fn same_as(&self, other: &Path) -> bool {
500 <Path as PathExt>::same_as(self, other)
501 }
502}
503impl PathConversion for Path {
504 fn cross_platform_display(&self) -> String {
505 let value = self.display().to_string();
506 #[cfg(windows)]
507 let value = value.strip_prefix(r"\\?\").unwrap_or(&value).replace('/', "\\");
508 value
509 }
510}
511impl PathConversion for &Path {
512 fn cross_platform_display(&self) -> String {
513 <Path as PathConversion>::cross_platform_display(*self)
514 }
515}
516impl PathConversion for PathBuf {
517 fn cross_platform_display(&self) -> String {
518 <Path as PathConversion>::cross_platform_display(self)
519 }
520}
521impl fmt::Display for Remote {
522 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
523 formatter.write_str(self.as_str())
524 }
525}
526impl core::str::FromStr for Remote {
527 type Err = String;
528 fn from_str(value: &str) -> Result<Self, Self::Err> {
529 let invalid = || format!("invalid remote '{value}' — expected ssh://[user@]host[:port][/socket]");
530 match Uri::parse(value) {
531 | Ok(uri) => {
532 let is_ssh = uri.scheme().as_str() == "ssh";
533 let is_trimmed = value.trim() == value;
534 let has_no_fragment = uri.fragment().is_none();
535 let has_no_query = uri.query().is_none();
536 let has_no_whitespace = !value.chars().any(char::is_whitespace);
537 let is_valid_uri = is_ssh && is_trimmed && has_no_fragment && has_no_query && has_no_whitespace;
538 match uri.authority() {
539 | Some(authority) => {
540 let has_host = !authority.host().is_empty();
541 let has_no_password = authority.userinfo().is_none_or(|userinfo| !userinfo.as_str().contains(':'));
542 let has_valid_port = authority.port_to_u16().is_ok();
543 let is_valid_authority = has_host && has_no_password && has_valid_port;
544 match is_valid_uri && is_valid_authority {
545 | true => Ok(Self(Location::from(value))),
546 | false => Err(invalid()),
547 }
548 }
549 | None => Err(invalid()),
550 }
551 }
552 | Err(_) => Err(invalid()),
553 }
554 }
555}
556impl Remote {
557 pub fn as_str(&self) -> &str {
559 (&self.0).into()
560 }
561 pub fn copy_gpu_template(&self, runtime: &Executor, name: &str, template: &Path) -> Result<(), Report> {
563 let copy = self.docker_args(args!["cp", template, format!("{name}:/etc/gitlab-runner/gpu.template.toml")]);
564 match cmd!(runtime, copy) {
565 | Ok(output) if output.status.success() => Ok(()),
566 | Ok(output) => {
567 let stderr = String::from_utf8_lossy(&output.stderr);
568 Err(eyre!("Failed to copy GitLab runner GPU template to {self} — {stderr}"))
569 }
570 | Err(why) => Err(eyre!("Failed to execute docker cp for {self} — {why}")),
571 }
572 }
573 pub fn create_gpu_template(remote: Option<&Self>, config_host_dir: &str) -> io::Result<Option<PathBuf>> {
575 let parent = remote.map_or_else(|| PathBuf::from(config_host_dir), |_| temp_dir());
576 let filename = remote.map_or_else(|| "gpu.template.toml".to_string(), |_| format!("acorn-gpu-{}.template.toml", nanoid!()));
577 let template = parent.join(filename);
578 let content = "[[runners]]\n [runners.docker]\n gpus = \"all\"\n";
579 match create_dir_all(parent).and_then(|_| write(&template, content)) {
580 | Ok(()) => Ok(Some(template)),
581 | Err(why) if remote.is_some() => Err(why),
582 | Err(_) => Ok(None),
583 }
584 }
585 pub fn docker_args(&self, command: Vec<OsString>) -> Vec<OsString> {
587 args!["--host", self.as_str(), ..command]
588 }
589}
590impl<P: Into<PathBuf> + Clone> ToStrings for Vec<P> {
591 fn to_strings(&self) -> Vec<String> {
592 self.iter()
593 .map(|p| <P as Into<PathBuf>>::into(p.clone()).to_string_lossy().to_string())
594 .collect()
595 }
596 fn to_absolute_strings(&self) -> Vec<String> {
597 self.iter().map(|p| <P as Into<PathBuf>>::into(p.clone()).to_absolute_path()).collect()
598 }
599}
600impl ProgressType {
601 fn template(&self) -> Option<&'static str> {
602 match self {
603 | ProgressType::Bar => Some(Label::PROGRESS_BAR_TEMPLATE),
604 | ProgressType::Spinner => Some(Label::PROGRESS_SPINNER_TEMPLATE),
605 | ProgressType::Counter => Some(Label::PROGRESS_COUNTER_TEMPLATE),
606 | ProgressType::Silent => None,
607 }
608 }
609 fn is_indeterminate(&self) -> bool {
610 matches!(self, ProgressType::Spinner)
611 }
612}
613impl StringConversion for PathBuf {
614 fn normalized(&self) -> String {
615 self.to_string_lossy().as_ref().normalized()
616 }
617 fn to_cross_platform_path(&self) -> String {
618 self.cross_platform_display()
619 }
620 fn file_name_with_parent(&self) -> String {
621 file_name_with_parent(self.clone())
622 }
623 fn to_absolute_path(&self) -> String {
624 to_absolute_string(self.clone())
625 }
626}
627impl StringConversion for String {
628 fn normalized(&self) -> String {
629 self.as_str().normalized()
630 }
631 fn to_cross_platform_path(&self) -> String {
632 Path::new(self).cross_platform_display()
633 }
634 fn file_name_with_parent(&self) -> String {
635 file_name_with_parent(self.clone())
636 }
637 fn to_absolute_path(&self) -> String {
638 to_absolute_string(self.clone())
639 }
640}
641impl StringConversion for &str {
642 fn normalized(&self) -> String {
643 self.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase()
644 }
645 fn to_cross_platform_path(&self) -> String {
646 Path::new(self).cross_platform_display()
647 }
648 fn file_name_with_parent(&self) -> String {
649 file_name_with_parent(*self)
650 }
651 fn to_absolute_path(&self) -> String {
652 to_absolute_string(*self)
653 }
654}
655pub fn apply_progress_style(progress: &ProgressBar, template: &str) {
657 #[allow(clippy::unwrap_used)]
658 progress.set_style(ProgressStyle::with_template(template).unwrap());
659}
660pub fn archive(path: PathBuf, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
662 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
663 let zip_file_path = match destination {
664 | Some(value) => value,
665 | None => path.with_extension("zip"),
666 };
667 info!("=> {} Create archive at {}", Label::using(), zip_file_path.to_absolute_path());
668 let prepared = if zip_file_path.exists() {
669 match zip_file_path.symlink_metadata() {
670 | Ok(metadata) => {
671 let file_type = metadata.file_type();
672 if file_type.is_symlink() {
673 error!("=> {} Create zip archive — destination cannot be a symlink", Label::fail());
674 false
675 } else if metadata.is_file() {
676 match remove_file(zip_file_path.clone()) {
677 | Ok(_) => true,
678 | Err(why) => {
679 error!("=> {} Prepare zip destination — {why}", Label::fail());
680 false
681 }
682 }
683 } else {
684 error!("=> {} Create zip archive — destination exists and is not a file", Label::fail());
685 false
686 }
687 }
688 | Err(why) => {
689 error!("=> {} Inspect zip destination — {why}", Label::fail());
690 false
691 }
692 }
693 } else {
694 true
695 };
696 let zip_file = if prepared {
697 match OpenOptions::new().write(true).create_new(true).open(&zip_file_path) {
698 | Ok(zip_file) => Some(ZipWriter::new(zip_file)),
699 | Err(why) => {
700 error!("=> {} Create zip archive — {why}", Label::fail());
701 None
702 }
703 }
704 } else {
705 None
706 };
707 if let Some(mut zip) = zip_file {
708 let archive_root = path.canonicalize();
709 let files = files_all(path.clone(), None::<Vec<String>>).into_iter().filter(|x| x.is_file());
710 for file_path in files {
711 if let Ok(file) = File::open(file_path.clone()) {
712 let name = archive_root.as_ref().ok().and_then(|root| {
713 file_path
714 .canonicalize()
715 .ok()
716 .and_then(|absolute| absolute.strip_prefix(root).ok().map(Path::to_path_buf))
717 });
718 match name {
719 | Some(name) => {
720 trace!(file = name.to_absolute_path(), "=> {} Add file to archive", Label::using());
721 match zip.start_file_from_path(name, options) {
722 | Ok(_) => {
723 let mut buffer = Vec::new();
724 match io::copy(&mut file.take(u64::MAX), &mut buffer) {
725 | Ok(_) => match zip.write_all(&buffer) {
726 | Ok(_) => {}
727 | Err(why) => {
728 error!(file = file_path.to_absolute_path(), "=> {} Write zip archive — {why}", Label::fail())
729 }
730 },
731 | Err(why) => {
732 error!("=> {} Copy buffer - {why}", Label::fail())
733 }
734 }
735 }
736 | Err(why) => {
737 error!(file = file_path.to_absolute_path(), "=> {} Start zip archive - {why}", Label::fail());
738 }
739 }
740 }
741 | None => {
742 error!(
743 file = file_path.to_absolute_path(),
744 "=> {} Resolve relative zip archive path",
745 Label::fail()
746 );
747 }
748 }
749 }
750 }
751 match zip.finish() {
752 | Ok(_) => Ok(zip_file_path),
753 | Err(why) => {
754 error!(file = path.to_absolute_path(), "=> {} Finish zip archive - {why}", Label::fail());
755 Err(why.into())
756 }
757 }
758 } else {
759 Err(eyre!("Unable to create zip archive"))
760 }
761}
762pub fn async_runtime() -> Runtime {
770 debug!("=> {} Async runtime", Label::using());
771 #[allow(clippy::unwrap_used)]
772 Builder::new_current_thread().enable_all().build().unwrap()
773}
774pub fn command_exists<S>(name: S) -> bool
784where
785 S: Into<String>,
786{
787 let command = name.into();
788 match which(&command) {
789 | Ok(value) => {
790 let path = value.clone().to_absolute_path();
791 match value.try_exists() {
792 | Ok(true) => {
793 debug!(path, "=> {} Command", Label::found());
794 true
795 }
796 | _ => {
797 debug!(path, "=> {} Command", Label::not_found());
798 false
799 }
800 }
801 }
802 | Err(_) => {
803 warn!("=> {} Command {}", Label::not_found(), command);
804 false
805 }
806 }
807}
808pub fn create_progress_bar(count: usize, progress_type: ProgressType) -> ProgressBar {
810 create_progress_bar_with_renderer(count, progress_type, &PROGRESS_RENDERER)
811}
812fn create_progress_bar_with_renderer(count: usize, progress_type: ProgressType, renderer: &MultiProgress) -> ProgressBar {
813 if matches!(progress_type, ProgressType::Silent) {
814 ProgressBar::hidden()
815 } else {
816 let progress = if progress_type.is_indeterminate() {
817 let spinner = ProgressBar::new_spinner();
818 spinner.enable_steady_tick(Duration::from_millis(120));
819 spinner
820 } else {
821 ProgressBar::new(count as u64)
822 };
823 if let Some(template) = progress_type.template() {
824 #[allow(clippy::unwrap_used)]
825 progress.set_style(ProgressStyle::with_template(template).unwrap());
826 }
827 renderer.add(progress)
828 }
829}
830pub fn create_rsa_keypair() -> ApiResult<RsaKeyPair> {
832 let bits = 2048;
833 let mut rng = OsRng;
834 match RsaPrivateKey::new(&mut rng, bits) {
835 | Ok(private_key) => {
836 let public_key = RsaPublicKey::from(&private_key);
837 Ok((private_key, public_key))
838 }
839 | Err(why) => {
840 error!("=> {} Create RSA key pair — {why}", Label::fail());
841 Err(eyre!("Failed to create RSA key pair — {why}"))
842 }
843 }
844}
845pub fn current_date() -> String {
856 Timestamp::now().strftime("%Y-%m-%d").to_string()
857}
858pub fn directory_roots(paths: &[PathBuf]) -> Vec<PathBuf> {
860 let mut roots = paths
861 .iter()
862 .map(|path| match (path.is_dir(), path.parent()) {
863 | (true, _) => path.clone(),
864 | (false, Some(parent)) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
865 | (false, _) => Path::new(".").to_path_buf(),
866 })
867 .collect::<Vec<_>>();
868 roots.sort();
869 roots.dedup();
870 roots
871}
872pub async fn download_binary<S, P>(url: S, destination: P) -> ApiResult<PathBuf>
886where
887 S: Into<String> + Clone + core::marker::Copy,
888 P: Into<PathBuf> + Clone,
889{
890 let url_string: String = url.into();
891 let dest: PathBuf = destination.clone().into();
892 let filename = PathBuf::from(url_string.clone())
893 .file_name()
894 .and_then(|f| f.to_str())
895 .unwrap_or("downloaded_file")
896 .to_string();
897 match http::get(url_string.clone()).send().await {
898 | Ok(data) => match data.bytes().await {
899 | Ok(content) => {
900 let output = dest.clone().join(filename.clone());
901 match write(output.clone(), content.as_slice()) {
902 | Ok(_) => {
903 debug!(filename, "=> {} Downloaded", Label::output());
904 Ok(output)
905 }
906 | Err(why) => Err(eyre!("Failed to write {filename} - {why}")),
907 }
908 }
909 | Err(_) => Err(eyre!("No content downloaded from {url_string}")),
910 },
911 | Err(_) => Err(eyre!("Failed to download {url_string}")),
912 }
913}
914pub fn env_var_is_truthy(name: impl AsRef<str>) -> Option<bool> {
919 var(name.as_ref())
920 .ok()
921 .map(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
922}
923pub fn extract_zip(path: PathBuf, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
927 let root = match destination {
928 | Some(value) => value,
929 | None => standard_project_folder("extract", None),
930 };
931 match File::open(path.clone()) {
932 | Ok(zip_file) => match ZipArchive::new(zip_file) {
933 | Ok(mut archive) => {
934 let success = (0..archive.len()).all(|index| match archive.by_index(index) {
935 | Ok(mut file) => {
936 let target = root.join(file.name());
937 if let Some(parent) = target.parent() {
938 match create_dir_all(parent) {
939 | Ok(_) => {}
940 | Err(why) => error!(path = parent.to_path_buf().to_absolute_path(), "=> {} Create - {}", Label::fail(), why),
941 }
942 }
943 match OpenOptions::new().write(true).create_new(true).open(&target) {
944 | Ok(mut output_file) => match io::copy(&mut file, &mut output_file) {
945 | Ok(_) => true,
946 | Err(why) => {
947 error!(path = target.to_absolute_path(), "=> {} Copy file content - {why}", Label::fail());
948 false
949 }
950 },
951 | Err(_) => {
952 error!(path = target.to_absolute_path(), "=> {} Create file", Label::fail());
953 false
954 }
955 }
956 }
957 | Err(why) => {
958 error!(path = path.to_absolute_path(), "=> {} Extract file - {why}", Label::fail());
959 false
960 }
961 });
962 if success {
963 info!(path = root.to_absolute_path(), "=> {} Extract zip archive", Label::pass());
964 Ok(root)
965 } else {
966 error!(path = root.to_absolute_path(), "=> {} Extract zip archive", Label::fail());
967 Err(eyre!("Failed to extract zip archive"))
968 }
969 }
970 | Err(why) => {
971 error!(path = path.to_absolute_path(), "=> {} Read zip archive - {why}", Label::fail());
972 Err(eyre!("Failed to read zip archive - {why}"))
973 }
974 },
975 | Err(why) => {
976 error!(path = path.to_absolute_path(), "=> {} Read file - {why}", Label::fail());
977 Err(eyre!("Failed to read file - {why}"))
978 }
979 }
980}
981pub fn file_checksum<P>(path: P, algorithm: Option<&'static ring::digest::Algorithm>) -> Option<Checksum>
994where
995 P: Into<PathBuf>,
996{
997 let value = path.into();
998 let digest_algorithm = algorithm.unwrap_or(&SHA256);
999 let checksum_algorithm = ChecksumAlgorithm::from(digest_algorithm);
1000 match File::open(value.clone()) {
1001 | Ok(file) => {
1002 let mut buffer = [0; 1024];
1003 let mut context = Context::new(digest_algorithm);
1004 let mut reader = BufReader::new(file);
1005 loop {
1006 let count = match reader.read(&mut buffer) {
1007 | Ok(c) => c,
1008 | Err(err) => {
1009 error!(
1010 error = err.to_string(),
1011 path = value.to_absolute_path(),
1012 "=> {} Read file checksum",
1013 Label::fail()
1014 );
1015 return None;
1016 }
1017 };
1018 if count == 0 {
1019 break;
1020 }
1021 context.update(buffer.get(..count).unwrap_or(&[]));
1022 }
1023 let digest = context.finish();
1024 let result = HEXUPPER.encode(digest.as_ref());
1025 Some(Checksum {
1026 algorithm: checksum_algorithm,
1027 checksum_value: result.to_lowercase(),
1028 })
1029 }
1030 | Err(err) => {
1031 error!(error = err.to_string(), path = value.to_absolute_path(), "=> {} Read file", Label::fail());
1032 None
1033 }
1034 }
1035}
1036pub fn file_name_with_parent(value: impl Into<PathBuf>) -> String {
1040 let path = value.into();
1041 let name = path.file_name().and_then(|value| value.to_str()).unwrap_or_default().to_string();
1042 if path.is_dir() {
1043 name
1044 } else {
1045 let parent_name = path
1046 .parent()
1047 .and_then(|value| value.file_name())
1048 .and_then(|value| value.to_str())
1049 .unwrap_or_default();
1050 if parent_name.is_empty() {
1051 name
1052 } else {
1053 format!("{parent_name}/{name}")
1054 }
1055 }
1056}
1057pub fn files_all<T: FileExtension>(path: PathBuf, extensions: Option<Vec<T>>) -> Vec<PathBuf> {
1066 files_all_with_max_depth(path, extensions, None)
1067}
1068pub fn files_all_with_max_depth<T: FileExtension>(path: PathBuf, extensions: Option<Vec<T>>, max_depth: Option<usize>) -> Vec<PathBuf> {
1072 let path = uri_to_path(path);
1073 let extensions = extensions.map(|values| values.into_iter().map(|value| value.extension()).collect::<Vec<_>>());
1074 fn paths_to_vec(paths: glob::Paths) -> Vec<PathBuf> {
1075 paths.collect::<Vec<_>>().into_iter().filter_map(|x| x.ok()).collect::<Vec<_>>()
1076 }
1077 fn patterns(path: &PathBuf, extension: Option<&str>, max_depth: Option<usize>) -> Vec<String> {
1078 let suffix = extension.map_or_else(|| "*".to_string(), |value| format!("*.{}", value.to_lowercase()));
1079 match max_depth {
1080 | Some(value) => (1..=value)
1081 .map(|depth| {
1082 let descendants = (1..depth)
1083 .map(|_| "*")
1084 .chain(core::iter::once(suffix.as_str()))
1085 .collect::<Vec<_>>()
1086 .join("/");
1087 format!("{}/{descendants}", path.to_absolute_path())
1088 })
1089 .collect(),
1090 | None => vec![format!("{}/**/{suffix}", path.to_absolute_path())],
1091 }
1092 }
1093 if path.is_dir() {
1094 extensions
1095 .map_or_else(
1096 || patterns(&path, None, max_depth),
1097 |values| {
1098 values
1099 .into_iter()
1100 .flat_map(|extension| patterns(&path, Some(extension.as_str()), max_depth))
1101 .collect()
1102 },
1103 )
1104 .into_iter()
1105 .inspect(|pattern| debug!("=> {} {pattern}", Label::using()))
1106 .filter_map(|pattern| {
1107 glob(&pattern)
1108 .map_err(|why| error!("=> {} Get all files (Glob) - {why}", Label::fail()))
1109 .ok()
1110 })
1111 .flat_map(paths_to_vec)
1112 .fold((HashSet::new(), Vec::new()), |(mut seen, mut ordered), path| {
1113 if seen.insert(path.clone()) {
1114 ordered.push(path);
1115 }
1116 (seen, ordered)
1117 })
1118 .1
1119 } else {
1120 if extensions.is_some() {
1121 warn!(
1122 path = path.clone().to_absolute_path(),
1123 "=> {} Extension passed with single file to files_all()...was this intended?",
1124 Label::using()
1125 );
1126 }
1127 vec![path]
1128 }
1129}
1130pub fn files_from_git_branch<T: FileExtension>(value: &str, extensions: Option<Vec<T>>) -> Vec<PathBuf> {
1137 if command_exists("git".to_owned()) {
1138 let default_branch = match git_default_branch_name() {
1139 | Some(value) => value,
1140 | None => "main".to_string(),
1141 };
1142 let args = vec!["diff", "--name-only", &default_branch, "--merge-base", value];
1143 match cmd!("git", args) {
1144 | Ok(output) if output.status.success() => filter_git_command_result(output.stdout(), extensions),
1145 | Ok(output) => {
1146 let why = output.stderr();
1147 let message = if why.is_empty() {
1148 format!("process exited with status {}", output.status)
1149 } else {
1150 why
1151 };
1152 error!("=> {} Get files from Git branch - {}", Label::fail(), message);
1153 vec![]
1154 }
1155 | Err(why) => {
1156 error!("=> {} Get files from Git branch - {why}", Label::fail());
1157 vec![]
1158 }
1159 }
1160 } else {
1161 vec![]
1162 }
1163}
1164pub fn files_from_git_commit<T: FileExtension>(value: &str, extensions: Option<Vec<T>>) -> Vec<PathBuf> {
1171 if command_exists("git".to_owned()) {
1172 let args = vec!["diff-tree", "--no-commit-id", "--name-only", "-r", value];
1173 let result = cmd!("git", args);
1174 debug!("=> {} Git command response - {result:?}", Label::using());
1175 let files = match result {
1176 | Ok(output) if output.status.success() => filter_git_command_result(output.stdout(), extensions),
1177 | Ok(output) => {
1178 let why = output.stderr();
1179 let message = if why.is_empty() {
1180 format!("process exited with status {}", output.status)
1181 } else {
1182 why
1183 };
1184 error!("=> {} Get files from Git commit - {}", Label::fail(), message);
1185 vec![]
1186 }
1187 | Err(why) => {
1188 error!("=> {} Get files from Git commit - {why}", Label::fail());
1189 vec![]
1190 }
1191 };
1192 debug!(
1193 "=> {} Found {} file{} from Git commit - {files:?}",
1194 Label::using(),
1195 files.len(),
1196 suffix(files.len())
1197 );
1198 files
1199 } else {
1200 vec![]
1201 }
1202}
1203pub async fn files_from_gitlab_merge_request<T: FileExtension>(extensions: Option<Vec<T>>) -> Vec<PathBuf> {
1209 let root = var("CI_API_V4_URL").unwrap_or_default();
1210 let project_id = var("CI_MERGE_REQUEST_PROJECT_ID").unwrap_or_default();
1211 let merge_request_iid = var("CI_MERGE_REQUEST_IID").unwrap_or_default();
1212 let path = format!("/projects/{project_id}/merge_requests/{merge_request_iid}/diffs");
1213 let url = format!("{root}{path}");
1214 match http::get(url).send().await {
1215 | Ok(response) => {
1216 let content: serde_json::Result<Vec<GitlabMergeRequestDiffResponse>> = response.text().await.map_or_else(
1217 |_| Err(serde_json::Error::io(io::Error::other("Failed to read response text"))),
1218 |body| serde_json::from_str(&body),
1219 );
1220 match content {
1221 | Ok(data) => {
1222 debug!("=> {} GitLab API merge request diff response - {data:#?}", Label::using());
1223 let results = data.into_iter().map(|x| PathBuf::from(x.new_path)).collect::<Vec<PathBuf>>();
1224 let extensions = extensions.map(|values| values.into_iter().map(|value| value.extension()).collect::<Vec<_>>());
1225 match extensions {
1226 | Some(values) => results
1227 .into_iter()
1228 .filter(|path| values.iter().any(|ext| MimeType::from_path(path).file_type() == *ext))
1229 .collect::<Vec<_>>(),
1230 | None => results,
1231 }
1232 }
1233 | Err(why) => {
1234 error!("=> {} Parse GitLab API merge request diff response - {why}", Label::fail());
1235 vec![]
1236 }
1237 }
1238 }
1239 | Err(why) => {
1240 error!("=> {} Get GitLab API merge request diff response - {why}", Label::fail());
1241 vec![]
1242 }
1243 }
1244}
1245pub fn filter_git_command_result<T: FileExtension>(value: String, extensions: Option<Vec<T>>) -> Vec<PathBuf> {
1247 let extensions = extensions.map(|values| values.into_iter().map(|value| value.extension()).collect::<Vec<_>>());
1248 match extensions {
1249 | Some(values) => value
1250 .to_lowercase()
1251 .split("\n")
1252 .map(PathBuf::from)
1253 .filter(|path| values.iter().any(|ext| MimeType::from_path(path).file_type() == *ext))
1254 .collect::<Vec<_>>(),
1255 | None => value.to_lowercase().split("\n").map(PathBuf::from).collect::<Vec<_>>(),
1256 }
1257}
1258pub fn filter_ignored(paths: Vec<PathBuf>, ignore: Option<String>) -> ApiResult<Vec<PathBuf>> {
1270 match ignore {
1271 | Some(ignore_pattern) => match Regex::new(&ignore_pattern) {
1272 | Ok(re) => Ok(paths
1273 .into_iter()
1274 .map(to_absolute_string)
1275 .filter(|x| !re.is_match(x).unwrap_or(false))
1276 .map(PathBuf::from)
1277 .collect()),
1278 | Err(why) => Err(eyre!("Invalid regex/filter pattern: {why}")),
1279 },
1280 | None => Ok(paths),
1281 }
1282}
1283pub fn filter_ignored_with_root(paths: Vec<PathBuf>, ignore: Option<String>, root: PathBuf) -> ApiResult<Vec<PathBuf>> {
1287 match ignore {
1288 | Some(ignore_pattern) => match Regex::new(&ignore_pattern) {
1289 | Ok(re) => {
1290 let root = if root.is_file() {
1291 root.parent().map(|value| value.to_path_buf()).unwrap_or(root)
1292 } else {
1293 root
1294 };
1295 let normalized_root = canonicalize(root.clone()).unwrap_or(root);
1296 let mut filtered: Vec<PathBuf> = vec![];
1297 for path in paths {
1298 let normalized_path = canonicalize(path.clone()).unwrap_or(path.clone());
1299 match normalized_path.strip_prefix(&normalized_root) {
1300 | Ok(relative) => {
1301 let value = relative.to_string_lossy().to_string().replace('\\', "/");
1302 if !re.is_match(&value).unwrap_or(false) {
1303 filtered.push(path);
1304 }
1305 }
1306 | Err(_) => {
1307 return Err(eyre!(
1308 "Path '{}' is outside resolved root '{}'",
1309 normalized_path.to_absolute_path(),
1310 normalized_root.to_absolute_path()
1311 ));
1312 }
1313 }
1314 }
1315 Ok(filtered)
1316 }
1317 | Err(why) => Err(eyre!("Invalid regex/filter pattern: {why}")),
1318 },
1319 | None => Ok(paths),
1320 }
1321}
1322pub fn finish_progress_bar(progress: &ProgressBar, message: String) {
1324 #[allow(clippy::unwrap_used)]
1325 progress.set_style(ProgressStyle::with_template(" {msg}").unwrap());
1326 progress.finish_with_message(message);
1327}
1328pub fn first_env_var(names: &[&str]) -> Option<String> {
1341 names
1342 .iter()
1343 .filter_map(|name| var(name).ok().map(|value| value.trim().to_string()))
1344 .find(|value| !value.is_empty())
1345}
1346pub fn folder_size<P: Into<PathBuf>>(path: P) -> u64 {
1348 files_all(path.into(), None::<Vec<String>>)
1349 .into_iter()
1350 .filter_map(|p| p.metadata().ok())
1351 .filter(|m| m.is_file())
1352 .map(|m| m.len())
1353 .sum()
1354}
1355pub fn git_branch_name() -> Option<String> {
1361 if command_exists("git".to_owned()) {
1362 let args = vec!["symbolic-ref", "--short", "HEAD"];
1363 match cmd!("git", args) {
1364 | Ok(output) if output.status.success() => output.stdout().split("/").last().map(|x| x.to_string()),
1365 | Ok(_) | Err(_) => None,
1366 }
1367 } else {
1368 None
1369 }
1370}
1371pub fn git_default_branch_name() -> Option<String> {
1377 if command_exists("git".to_owned()) {
1378 let args = vec!["symbolic-ref", "refs/remotes/origin/HEAD", "--short"];
1379 match cmd!("git", args) {
1380 | Ok(output) if output.status.success() => output.stdout().split("/").last().map(|x| x.to_string()),
1381 | Ok(_) | Err(_) => None,
1382 }
1383 } else {
1384 None
1385 }
1386}
1387pub fn home_directory(child: &str) -> ApiResult<PathBuf> {
1389 BaseDirs::new()
1390 .map(|dirs| dirs.home_dir().join(child))
1391 .ok_or_else(|| eyre!("Failed to resolve home directory"))
1392}
1393pub fn image_paths<P>(root: P) -> Vec<PathBuf>
1408where
1409 P: Into<PathBuf> + Clone,
1410{
1411 let extensions = ["jpg", "jpeg", "png", "svg", "gif"];
1412 let mut files = extensions
1413 .iter()
1414 .flat_map(|ext| glob(&format!("{}/**/*.{}", root.clone().into().display(), ext)))
1415 .flat_map(|paths| paths.collect::<Vec<_>>())
1416 .flatten()
1417 .collect::<Vec<PathBuf>>();
1418 files.sort();
1419 files
1420}
1421pub fn jsonc_parse_value(content: &str) -> ApiResult<serde_json::Value> {
1425 let options = ParseOptions {
1426 allow_comments: true,
1427 allow_trailing_commas: true,
1428 allow_loose_object_property_names: false,
1429 allow_missing_commas: false,
1430 allow_single_quoted_strings: false,
1431 allow_hexadecimal_numbers: false,
1432 allow_unary_plus_numbers: false,
1433 };
1434 parse_to_serde_value(content, &options).map_err(|why| eyre!("JSONC parse error — {why}"))
1435}
1436#[cfg(any(unix, target_os = "wasi", target_os = "redox"))]
1446pub fn make_executable<P>(path: P) -> bool
1447where
1448 P: Into<PathBuf> + Clone,
1449{
1450 let path = path.into();
1451 let create_with_mode = OpenOptions::new().write(true).create_new(true).mode(0o755).open(path.as_path());
1452 match create_with_mode {
1453 | Ok(_) => path.is_executable(),
1454 | Err(why) => {
1455 if why.kind() == io::ErrorKind::AlreadyExists {
1456 match set_permissions(path.as_path(), Permissions::from_mode(0o755)) {
1457 | Ok(()) => path.is_executable(),
1458 | Err(why) => {
1459 debug!(path = path.to_absolute_path(), "=> {} Set permissions — {why}", Label::fail());
1460 false
1461 }
1462 }
1463 } else {
1464 debug!(path = path.to_absolute_path(), "=> {} Create executable file — {why}", Label::fail());
1465 false
1466 }
1467 }
1468 }
1469}
1470#[cfg(windows)]
1480pub fn make_executable<P>(path: P) -> bool
1481where
1482 P: Into<PathBuf> + Clone,
1483{
1484 let binary = match file_extension(path.clone().into().to_absolute_path()) {
1485 | None => path.into().with_extension("exe"),
1486 | _ => path.into(),
1487 };
1488 debug!("=> {} {binary:#?}", Label::using());
1489 binary.is_executable()
1490}
1491pub fn parent<P>(path: P) -> PathBuf
1493where
1494 P: Into<PathBuf> + Clone,
1495{
1496 let default = PathBuf::from(".");
1497 match path.clone().into().canonicalize() {
1498 | Ok(value) => match value.parent() {
1499 | Some(value) => value.to_path_buf(),
1500 | None => {
1501 warn!("=> {} Resolve parent path", Label::fail());
1502 default
1503 }
1504 },
1505 | Err(why) => {
1506 debug!("=> {} Resolve absolute path - {why}", Label::fail());
1507 match path.into().parent() {
1508 | Some(value) if !value.to_path_buf().to_absolute_path().is_empty() => value.to_path_buf(),
1509 | Some(_) | None => {
1510 warn!("=> {} Parent path was empty or could not be resolved", Label::fail());
1511 default
1512 }
1513 }
1514 }
1515 }
1516}
1517pub fn parse_jsonc_cst<T: DeserializeOwned>(content: &str) -> ApiResult<(T, CstRootNode)> {
1522 let options = ParseOptions {
1523 allow_comments: true,
1524 allow_trailing_commas: true,
1525 allow_loose_object_property_names: false,
1526 allow_missing_commas: false,
1527 allow_single_quoted_strings: false,
1528 allow_hexadecimal_numbers: false,
1529 allow_unary_plus_numbers: false,
1530 };
1531 CstRootNode::parse(content, &options)
1532 .map_err(|why| eyre!("JSONC parse error — {why}"))
1533 .and_then(|cst| {
1534 cst.to_serde_value().ok_or_else(|| eyre!("JSONC conversion error")).and_then(|value| {
1535 serde_json::from_value::<T>(value)
1536 .map_err(|why| eyre!("JSONC deserialize error — {why}"))
1537 .map(|config| (config, cst))
1538 })
1539 })
1540}
1541pub fn progress_renderer() -> MultiProgress {
1543 PROGRESS_RENDERER.clone()
1544}
1545pub fn read_file<P>(path: P) -> ApiResult<String>
1570where
1571 P: Into<PathBuf> + Clone + Send,
1572{
1573 let path_buf = path.into();
1574 let filename = path_buf.file_name().unwrap_or_default().to_string_lossy().to_string();
1575 let is_large_file = match path_buf.metadata() {
1576 | Ok(metadata) => metadata.len() >= LARGE_FILE_THRESHOLD_BYTES,
1577 | Err(_) => false,
1578 };
1579 if is_large_file {
1580 trace!(filename, "=> {} Read file with large-file strategy", Label::using());
1581 read_large_file(path_buf)
1582 } else {
1583 match File::open(&path_buf) {
1584 | Ok(file) => {
1585 let mut reader = BufReader::new(file);
1586 let mut content = String::new();
1587 match reader.read_to_string(&mut content) {
1588 | Ok(_) => Ok(content),
1589 | Err(why) => Err(eyre!("Failed to read file content — {why}")),
1590 }
1591 }
1592 | Err(why) => {
1593 error!(filename, "=> {} Read file", Label::fail());
1594 Err(eyre!("Failed to read file — {why}"))
1595 }
1596 }
1597 }
1598}
1599pub fn read_large_file<P>(path: P) -> ApiResult<String>
1604where
1605 P: Into<PathBuf> + Clone + Send,
1606{
1607 match File::open(path.into()) {
1608 | Ok(file) => {
1609 let capacity = file
1610 .metadata()
1611 .ok()
1612 .and_then(|metadata| usize::try_from(metadata.len()).ok())
1613 .unwrap_or(0);
1614 let mut reader = BufReader::with_capacity(1024 * 1024, file);
1615 let mut content = if capacity > 0 { String::with_capacity(capacity) } else { String::new() };
1616 match reader.read_to_string(&mut content) {
1617 | Ok(_) => Ok(content),
1618 | Err(why) => Err(eyre!("Failed to read large file content — {why}")),
1619 }
1620 }
1621 | Err(why) => Err(eyre!("Failed to read large file — {why}")),
1622 }
1623}
1624pub fn remove_fields(value: Value, fields: &[&str]) -> Value {
1626 match value {
1627 | Value::Object(values) => Value::Object(
1628 values
1629 .into_iter()
1630 .filter(|(name, _)| !fields.contains(&name.as_str()))
1631 .map(|(name, value)| (name, remove_fields(value, fields)))
1632 .collect(),
1633 ),
1634 | Value::Array(values) => Value::Array(values.into_iter().map(|value| remove_fields(value, fields)).collect()),
1635 | value => value,
1636 }
1637}
1638pub fn standard_project_folder(namespace: &str, default: Option<PathBuf>) -> PathBuf {
1654 let root = match default {
1655 | Some(value) => value,
1656 | None => match ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION) {
1657 | Some(dirs) => dirs.cache_dir().join(namespace).to_path_buf(),
1658 | None => PathBuf::from(format!("./{namespace}")),
1659 },
1660 };
1661 match create_dir_all(root.clone()) {
1662 | Ok(_) => {}
1663 | Err(why) => error!(directory = root.clone().to_absolute_path(), "=> {} Create - {why}", Label::fail()),
1664 };
1665 root.join(generate_guid())
1666}
1667#[cfg(unix)]
1671pub fn symlink(source: &Path, target: &Path) -> ApiResult<()> {
1672 match prelude::symlink(source, target) {
1673 | Ok(_) => Ok(()),
1674 | Err(why) => Err(why.into()),
1675 }
1676}
1677#[cfg(windows)]
1681pub fn symlink(source: &Path, target: &Path) -> ApiResult<()> {
1682 let result = if source.is_dir() {
1683 symlink_dir(source, target)
1684 } else {
1685 symlink_file(source, target)
1686 };
1687 match result {
1688 | Ok(_) => Ok(()),
1689 | Err(why) => Err(why.into()),
1690 }
1691}
1692pub fn to_absolute_string<P>(path: P) -> String
1706where
1707 P: Into<PathBuf> + Clone,
1708{
1709 let result = match canonicalize(path.clone().into().as_path()) {
1710 | Ok(value) => value,
1711 | Err(_) => path.into(),
1712 };
1713 let s = result.display().to_string();
1714 #[cfg(windows)]
1715 let s = s.strip_prefix(r"\\?\").unwrap_or(&s).to_string();
1716 s
1717}
1718pub fn unique_file_extensions(paths: &[PathBuf]) -> Vec<String> {
1720 let mut extensions = paths
1721 .iter()
1722 .filter_map(|path| path.extension().map(|extension| extension.to_string_lossy().to_lowercase()))
1723 .collect::<HashSet<_>>()
1724 .into_iter()
1725 .collect::<Vec<_>>();
1726 extensions.sort_unstable();
1727 extensions
1728}
1729pub fn uri_to_path<P>(value: P) -> PathBuf
1733where
1734 P: Into<PathBuf>,
1735{
1736 let path: PathBuf = value.into();
1737 let s = path.to_string_lossy().into_owned();
1738 match s.as_str() {
1739 | source if source.starts_with("file://localhost/") => {
1740 let stripped = source.trim_start_matches("file://localhost/");
1741 uri_to_path(PathBuf::from(format!("file:///{stripped}")))
1742 }
1743 | source if source.starts_with("file://") => {
1744 let stripped = source.trim_start_matches("file://");
1745 #[cfg(windows)]
1746 let normalized = match stripped.get(1..3) {
1747 | Some(drive) if drive.contains(':') => &stripped[1..],
1748 | _ => stripped,
1749 };
1750 #[cfg(not(windows))]
1751 let normalized = stripped;
1752 PathBuf::from(normalized)
1753 }
1754 | source if source.starts_with("file:") => PathBuf::from(source.trim_start_matches("file:")),
1755 | _ => path,
1756 }
1757}
1758pub fn validate_unix_timestamp_window(unix_seconds: i64, window_secs: i64) -> ApiResult<()> {
1764 let now = Timestamp::now().as_second();
1765 if u64::try_from(window_secs).map_or(true, |window| now.abs_diff(unix_seconds) > window) {
1766 Err(eyre!("Timestamp {unix_seconds} is outside the {window_secs}-second window"))
1767 } else {
1768 Ok(())
1769 }
1770}
1771pub async fn with_progress<T, U, M, F, Fut>(
1793 items: Vec<T>,
1794 message: M,
1795 operation: F,
1796 finish_message: impl FnOnce(usize) -> String,
1797 buffer_size: Option<usize>,
1798 progress_type: ProgressType,
1799) -> ApiResult<Vec<U>>
1800where
1801 M: for<'a> Fn(&'a T) -> String,
1802 F: Fn(T) -> Fut,
1803 Fut: Future<Output = ApiResult<U>>,
1804{
1805 let concurrency = buffer_size.unwrap_or(10).max(1);
1806 let count = items.len();
1807 let progress = create_progress_bar(count, progress_type);
1808 if matches!(progress_type, ProgressType::Spinner) {
1809 progress.enable_steady_tick(Duration::from_millis(120));
1810 }
1811 let output = stream::iter(items)
1812 .map(|item| {
1813 let msg = message(&item);
1814 let future = operation(item);
1815 async move {
1816 let result = future.await;
1817 (msg, result)
1818 }
1819 })
1820 .buffer_unordered(concurrency)
1821 .map(|(msg, result)| {
1822 progress.set_message(msg);
1823 progress.inc(1);
1824 result
1825 })
1826 .collect::<Vec<_>>()
1827 .await
1828 .into_iter()
1829 .collect::<ApiResult<Vec<_>>>();
1830
1831 if !matches!(progress_type, ProgressType::Silent) {
1832 finish_progress_bar(&progress, finish_message(count));
1833 }
1834 output
1835}
1836pub fn write_file<P>(path: P, content: String) -> ApiResult<()>
1846where
1847 P: Into<PathBuf>,
1848{
1849 write(path.into(), content.as_bytes())
1850 .map(|_| ())
1851 .map_err(|why| eyre!("Failed to write file - {why}"))
1852}
1853pub async fn write_file_bytes<P, F, Fut, E>(path: P, get_bytes: F) -> ApiResult<()>
1863where
1864 P: Into<PathBuf>,
1865 F: FnOnce() -> Fut,
1866 Fut: Future<Output = Result<Vec<u8>, E>>,
1867 E: Into<Report>,
1868{
1869 let path = path.into();
1870 match path.parent() {
1871 | Some(parent) => {
1872 let folder = parent.display().to_string();
1873 match create_dir_all(folder.clone()) {
1874 | Ok(_) => match OpenOptions::new().write(true).create_new(true).open(&path) {
1875 | Ok(mut file) => match get_bytes().await.map_err(Into::into) {
1876 | Ok(bytes) => {
1877 let mut content = Cursor::new(bytes);
1878 match io::copy(&mut content, &mut file) {
1879 | Ok(_) => Ok(()),
1880 | Err(why) => Err(eyre!("Failed to write bytes — {why}")),
1881 }
1882 }
1883 | Err(why) => Err(why),
1884 },
1885 | Err(why) => Err(eyre!("Failed to create output file — {why}")),
1886 },
1887 | Err(why) => Err(eyre!("Failed to create output folder — {why}")),
1888 }
1889 }
1890 | None => Err(eyre!("Output path has no parent directory")),
1891 }
1892}
1893pub fn write_rsa_keypair<P>(values: RsaKeyPair, path: Option<P>) -> ApiResult<(PathBuf, PathBuf)>
1899where
1900 P: Into<PathBuf>,
1901{
1902 let resolved = match path {
1903 | Some(p) => Ok(p.into()),
1904 | None => match current_dir() {
1905 | Ok(cwd) => Ok(cwd.join("id_rsa")),
1906 | Err(why) => Err(eyre!("Failed to get current directory — {why}")),
1907 },
1908 };
1909 match resolved {
1910 | Ok(path) => {
1911 let (private_key, public_key) = values;
1912 match private_key.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF) {
1913 | Ok(private_key_pem) => match public_key.to_public_key_pem(rsa::pkcs8::LineEnding::LF) {
1914 | Ok(public_key_pem) => {
1915 let public_key_path = PathBuf::from(format!("{}.pub", path.display()));
1916 let private_key_path = path.clone();
1917 match write_file(path, (*private_key_pem).clone()) {
1918 | Ok(_) => match write_file(public_key_path.clone(), public_key_pem) {
1919 | Ok(_) => Ok((private_key_path, public_key_path)),
1920 | Err(why) => Err(why),
1921 },
1922 | Err(why) => Err(why),
1923 }
1924 }
1925 | Err(why) => {
1926 error!("=> {} Write RSA keypair (public key) — {why}", Label::fail());
1927 Err(eyre!("Failed to serialize public key to PEM — {why}"))
1928 }
1929 },
1930 | Err(why) => {
1931 error!("=> {} Write RSA keypair (private key) — {why}", Label::fail());
1932 Err(eyre!("Failed to serialize private key to PEM — {why}"))
1933 }
1934 }
1935 }
1936 | Err(why) => Err(why),
1937 }
1938}
1939
1940#[cfg(test)]
1941mod tests;