1use crate::cmd;
23use crate::prelude::CommandOutput;
24use crate::prelude::{
25 canonicalize, consts, create_dir_all, current_dir, io, remove_file, var, var_os, write, BufReader, Cursor, File, HashSet, OpenOptions, Path,
26 PathBuf, Read, Write,
27};
28#[cfg(any(unix, target_os = "wasi", target_os = "redox"))]
29use crate::prelude::{set_permissions, OpenOptionsExt, Permissions, PermissionsExt};
30use crate::util::constants::app::{APPLICATION, LARGE_FILE_THRESHOLD_BYTES, ORGANIZATION, QUALIFIER};
31#[cfg(windows)]
32use crate::util::file_extension;
33use crate::util::{generate_guid, suffix, Checksum, ChecksumAlgorithm, Label, MimeType, SemanticVersion, StringConversion, ToStrings};
34use chrono::Utc;
35use color_eyre::eyre::{eyre, Report, Result};
36use core::fmt;
37use core::pin::Pin;
38use core::time::Duration;
39use data_encoding::HEXUPPER;
40use directories::ProjectDirs;
41use fancy_regex::Regex;
42use futures::stream::{self, StreamExt};
43use futures::Future;
44use glob::glob;
45use indicatif::{ProgressBar, ProgressStyle};
46use is_executable::IsExecutable;
47use rand::rngs::OsRng;
48use ring::digest::{Context, SHA256};
49use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey};
50use rsa::{RsaPrivateKey, RsaPublicKey};
51use schemars::JsonSchema;
52use serde::{Deserialize, Serialize};
53use strum::AsRefStr;
54use tokio::runtime::{Builder, Runtime};
55use tracing::{debug, error, info, trace, warn};
56use which::which;
57use zip::write::SimpleFileOptions;
58use zip::{ZipArchive, ZipWriter};
59
60pub mod api;
61pub mod bagit;
62pub mod config;
63pub mod database;
64pub mod docx;
65pub mod http;
66#[cfg(feature = "agentic")]
67pub mod mcp;
68#[cfg(feature = "powerpoint")]
69pub mod powerpoint;
70pub mod source;
71
72pub use source::Source;
73
74pub type ApiFuture<'a> = Pin<Box<dyn Future<Output = ApiResult<()>> + 'a>>;
76pub type ApiResult<T> = Result<T, Report>;
78pub type RsaKeyPair = (rsa::RsaPrivateKey, rsa::RsaPublicKey);
80pub trait FromCommand {
82 fn from_command<S>(name: S) -> Option<Self>
84 where
85 Self: Sized,
86 S: Into<String> + core::marker::Copy;
87}
88pub trait FromPath {
90 fn from_path<P>(value: &P) -> Self
92 where
93 P: AsRef<Path> + ?Sized;
94}
95pub trait InputOutput: Sized {
97 fn read(path: impl Into<PathBuf>) -> ApiResult<Self>;
99 fn read_cff(_path: impl Into<PathBuf>) -> ApiResult<Self> {
101 Err(eyre!("CFF read not implemented for this type"))
102 }
103 fn read_json(path: PathBuf) -> ApiResult<Self>;
105 fn read_markdown(_path: PathBuf) -> Option<Self> {
107 None
108 }
109 fn read_yaml(path: PathBuf) -> ApiResult<Self>;
111 fn write(&self, path: impl Into<PathBuf>) -> ApiResult<()>;
113 fn write_cff(&self, _path: impl Into<PathBuf>) -> ApiResult<()> {
115 Err(eyre!("CFF write not implemented for this type"))
116 }
117 fn write_json(&self, path: impl Into<PathBuf>) -> ApiResult<()>;
119 fn write_markdown(&self, _path: impl Into<PathBuf>) -> ApiResult<()> {
121 Err(eyre!("Markdown write not implemented for this type"))
122 }
123 fn write_yaml(&self, path: impl Into<PathBuf>) -> ApiResult<()>;
125}
126#[derive(AsRefStr, Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
130#[serde(untagged, rename_all = "snake_case")]
131pub enum Executor {
132 #[serde(alias = "Singularity", alias = "singularity")]
136 Apptainer,
137 Docker,
141 Podman,
147 Sandbox,
149 #[serde(alias = "zsh", alias = "pwsh", alias = "cmd", alias = "local")]
151 Shell,
152 #[serde(alias = "remote")]
154 Ssh,
155 #[serde(alias = "k8s")]
159 Kubernetes,
160 #[serde(alias = "vm")]
166 VirtualMachine,
167 Other(String),
169}
170#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
172#[serde(untagged)]
173pub enum License {
174 Multiple(Vec<String>),
176 Single(String),
178}
179#[derive(Clone, Copy, Debug, Default)]
181pub enum ProgressType {
182 #[default]
184 Bar,
185 Spinner,
187 Counter,
189 Silent,
191}
192#[derive(Debug, Deserialize)]
230pub struct GitlabMergeRequestDiffResponse {
231 new_path: String,
232 }
240pub struct StringList<'a>(pub &'a Vec<PathBuf>);
242impl fmt::Display for Executor {
243 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244 let value = match self {
245 | Executor::Apptainer => "apptainer",
246 | Executor::Docker => "docker",
247 | Executor::Podman => "podman",
248 | Executor::Sandbox => "sandbox",
249 | Executor::Shell => "shell",
250 | Executor::Ssh => "ssh",
251 | Executor::Kubernetes => "kubernetes",
252 | Executor::VirtualMachine => "virtual_machine",
253 | Executor::Other(value) => value.as_str(),
254 };
255 write!(f, "{value}")
256 }
257}
258impl From<&str> for Executor {
259 fn from(value: &str) -> Self {
261 match value.to_lowercase().as_str() {
262 | "apptainer" | "singularity" => Executor::Apptainer,
263 | "docker" => Executor::Docker,
264 | "podman" => Executor::Podman,
265 | "sandbox" => Executor::Sandbox,
266 | "shell" => Executor::Shell,
267 | "ssh" => Executor::Ssh,
268 | "kubernetes" | "k8s" => Executor::Kubernetes,
269 | "virtual machine" | "virtual_machine" | "vm" => Executor::VirtualMachine,
270 | other => Executor::Other(other.to_string()),
271 }
272 }
273}
274impl From<Executor> for std::ffi::OsString {
275 fn from(value: Executor) -> Self {
276 Self::from(value.to_string())
277 }
278}
279impl From<Executor> for String {
280 fn from(value: Executor) -> Self {
281 value.to_string()
282 }
283}
284impl Executor {
285 pub fn default_gitlab_runner_config_directory() -> &'static str {
287 match cfg!(target_os = "macos") {
288 | true => "/Users/Shared/gitlab-runner/config",
289 | false => "/srv/gitlab-runner/config",
290 }
291 }
292 pub fn command(&self) -> Option<&str> {
297 match self {
298 | Executor::Docker => Some("docker"),
299 | Executor::Podman => Some("podman"),
300 | Executor::Apptainer => Some("apptainer"),
301 | Executor::Shell | Executor::Ssh | Executor::Kubernetes | Executor::Sandbox | Executor::VirtualMachine => None,
302 | Executor::Other(value) => Some(value.as_str()),
303 }
304 }
305 pub fn gitlab_runner_type(&self) -> &str {
307 match self {
308 | Executor::Docker | Executor::Podman => "docker",
309 | Executor::Shell => "shell",
310 | Executor::Ssh => "ssh",
311 | Executor::Kubernetes => "kubernetes",
312 | Executor::VirtualMachine => match consts::OS {
313 | "macos" => "parallels",
314 | _ => "virtualbox",
315 },
316 | Executor::Apptainer | Executor::Sandbox | Executor::Other(_) => "docker",
317 }
318 }
319 pub fn socket(&self) -> Option<String> {
321 match self {
322 | Executor::Docker | Executor::Apptainer => {
323 Some("/var/run/docker.sock".to_string())
325 }
326 | Executor::Podman => {
327 if let Some(value) = var_os("XDG_RUNTIME_DIR") {
329 let path = PathBuf::from(value).join("podman/podman.sock");
330 if path.exists() {
331 Some(path.to_absolute_string())
332 } else {
333 None
334 }
335 } else {
336 let path = PathBuf::from("/run/podman/podman.sock");
338 if path.exists() {
339 Some(path.to_absolute_string())
340 } else {
341 None
342 }
343 }
344 }
345 | Executor::Shell | Executor::Ssh | Executor::Kubernetes | Executor::Sandbox | Executor::VirtualMachine | Executor::Other(_) => None,
346 }
347 }
348}
349impl FromCommand for SemanticVersion {
350 #[cfg(feature = "std")]
363 fn from_command<S>(name: S) -> Option<SemanticVersion>
364 where
365 S: Into<String> + core::marker::Copy,
366 {
367 let command = name.into();
368 if command_exists(command.clone()) {
369 match cmd!(&command, ["--version"]) {
370 | Ok(output) if output.status.success() => output.stdout().lines().next().map(SemanticVersion::from),
371 | Ok(_) | Err(_) => None,
372 }
373 } else {
374 None
375 }
376 }
377}
378impl FromPath for MimeType {
379 fn from_path<P>(value: &P) -> MimeType
391 where
392 P: AsRef<Path> + ?Sized,
393 {
394 MimeType::from(value.as_ref().display().to_string())
395 }
396}
397impl<P: Into<PathBuf> + Clone> ToStrings for Vec<P> {
398 fn to_strings(&self) -> Vec<String> {
399 self.iter()
400 .map(|p| <P as Into<PathBuf>>::into(p.clone()).to_string_lossy().to_string())
401 .collect()
402 }
403 fn to_absolute_strings(&self) -> Vec<String> {
404 self.iter().map(|p| <P as Into<PathBuf>>::into(p.clone()).to_absolute_string()).collect()
405 }
406}
407impl ProgressType {
408 fn template(&self) -> Option<&'static str> {
409 match self {
410 | ProgressType::Bar => Some(Label::PROGRESS_BAR_TEMPLATE),
411 | ProgressType::Spinner => Some(Label::PROGRESS_SPINNER_TEMPLATE),
412 | ProgressType::Counter => Some(Label::PROGRESS_COUNTER_TEMPLATE),
413 | ProgressType::Silent => None,
414 }
415 }
416 fn is_indeterminate(&self) -> bool {
417 matches!(self, ProgressType::Spinner)
418 }
419}
420impl StringConversion for PathBuf {
421 fn file_name_with_parent(&self) -> String {
422 file_name_with_parent(self.clone())
423 }
424 fn to_absolute_string(&self) -> String {
425 to_absolute_string(self.clone())
426 }
427}
428pub fn archive(path: PathBuf, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
430 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
431 let zip_file_path = match destination {
432 | Some(value) => value,
433 | None => path.with_extension("zip"),
434 };
435 info!("=> {} Create archive at {}", Label::using(), zip_file_path.to_absolute_string());
436 let prepared = if zip_file_path.exists() {
437 match zip_file_path.symlink_metadata() {
438 | Ok(metadata) => {
439 let file_type = metadata.file_type();
440 if file_type.is_symlink() {
441 error!("=> {} Create zip archive — destination cannot be a symlink", Label::fail());
442 false
443 } else if metadata.is_file() {
444 match remove_file(zip_file_path.clone()) {
445 | Ok(_) => true,
446 | Err(why) => {
447 error!("=> {} Prepare zip destination — {why}", Label::fail());
448 false
449 }
450 }
451 } else {
452 error!("=> {} Create zip archive — destination exists and is not a file", Label::fail());
453 false
454 }
455 }
456 | Err(why) => {
457 error!("=> {} Inspect zip destination — {why}", Label::fail());
458 false
459 }
460 }
461 } else {
462 true
463 };
464 let zip_file = if prepared {
465 match OpenOptions::new().write(true).create_new(true).open(&zip_file_path) {
466 | Ok(zip_file) => Some(ZipWriter::new(zip_file)),
467 | Err(why) => {
468 error!("=> {} Create zip archive — {why}", Label::fail());
469 None
470 }
471 }
472 } else {
473 None
474 };
475 if let Some(mut zip) = zip_file {
476 let files = files_all(path.clone(), None).into_iter().filter(|x| x.is_file());
477 for file_path in files {
478 if let Ok(file) = File::open(file_path.clone()) {
479 let name = match path.canonicalize() {
480 | Ok(relative) => file_path.strip_prefix(relative).unwrap_or_else(|_| &file_path),
481 | Err(_) => &file_path,
482 };
483 trace!(
484 file = name.to_path_buf().to_absolute_string(),
485 "=> {} Add file to archive",
486 Label::using()
487 );
488 match zip.start_file_from_path(name, options) {
489 | Ok(_) => {
490 let mut buffer = Vec::new();
491 match io::copy(&mut file.take(u64::MAX), &mut buffer) {
492 | Ok(_) => match zip.write_all(&buffer) {
493 | Ok(_) => {}
494 | Err(why) => {
495 error!(file = file_path.to_absolute_string(), "=> {} Write zip archive — {why}", Label::fail())
496 }
497 },
498 | Err(why) => {
499 error!("=> {} Copy buffer - {why}", Label::fail())
500 }
501 }
502 }
503 | Err(why) => {
504 error!(file = file_path.to_absolute_string(), "=> {} Start zip archive - {why}", Label::fail());
505 }
506 }
507 }
508 }
509 match zip.finish() {
510 | Ok(_) => Ok(zip_file_path),
511 | Err(why) => {
512 error!(file = path.to_absolute_string(), "=> {} Finish zip archive - {why}", Label::fail());
513 Err(why.into())
514 }
515 }
516 } else {
517 Err(eyre!("Unable to create zip archive"))
518 }
519}
520pub fn async_runtime() -> Runtime {
528 debug!("=> {} Async runtime", Label::using());
529 #[allow(clippy::unwrap_used)]
530 Builder::new_current_thread().enable_all().build().unwrap()
531}
532pub fn command_exists<S>(name: S) -> bool
542where
543 S: Into<String>,
544{
545 let command = name.into();
546 match which(&command) {
547 | Ok(value) => {
548 let path = value.clone().to_absolute_string();
549 match value.try_exists() {
550 | Ok(true) => {
551 debug!(path, "=> {} Command", Label::found());
552 true
553 }
554 | _ => {
555 debug!(path, "=> {} Command", Label::not_found());
556 false
557 }
558 }
559 }
560 | Err(_) => {
561 warn!("=> {} Command {}", Label::not_found(), command);
562 false
563 }
564 }
565}
566pub fn create_rsa_keypair() -> ApiResult<RsaKeyPair> {
568 let bits = 2048;
569 let mut rng = OsRng;
570 match RsaPrivateKey::new(&mut rng, bits) {
571 | Ok(private_key) => {
572 let public_key = RsaPublicKey::from(&private_key);
573 Ok((private_key, public_key))
574 }
575 | Err(why) => {
576 error!("=> {} Create RSA key pair — {why}", Label::fail());
577 Err(eyre!("Failed to create RSA key pair — {why}"))
578 }
579 }
580}
581pub fn current_date() -> String {
592 Utc::now().format("%Y-%m-%d").to_string()
593}
594pub async fn download_binary<S, P>(url: S, destination: P) -> ApiResult<PathBuf>
608where
609 S: Into<String> + Clone + core::marker::Copy,
610 P: Into<PathBuf> + Clone,
611{
612 let url_string: String = url.into();
613 let dest: PathBuf = destination.clone().into();
614 let filename = PathBuf::from(url_string.clone())
615 .file_name()
616 .and_then(|f| f.to_str())
617 .unwrap_or("downloaded_file")
618 .to_string();
619 match http::get(url_string.clone()).send().await {
620 | Ok(data) => match data.bytes().await {
621 | Ok(content) => {
622 let output = dest.clone().join(filename.clone());
623 match write(output.clone(), content.as_slice()) {
624 | Ok(_) => {
625 debug!(filename, "=> {} Downloaded", Label::output());
626 Ok(output)
627 }
628 | Err(why) => Err(eyre!("Failed to write {filename} - {why}")),
629 }
630 }
631 | Err(_) => Err(eyre!("No content downloaded from {url_string}")),
632 },
633 | Err(_) => Err(eyre!("Failed to download {url_string}")),
634 }
635}
636pub fn env_var_is_truthy(name: impl AsRef<str>) -> Option<bool> {
641 var(name.as_ref())
642 .ok()
643 .map(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
644}
645pub fn extract_zip(path: PathBuf, destination: Option<PathBuf>) -> ApiResult<PathBuf> {
649 let root = match destination {
650 | Some(value) => value,
651 | None => standard_project_folder("extract", None),
652 };
653 match File::open(path.clone()) {
654 | Ok(zip_file) => match ZipArchive::new(zip_file) {
655 | Ok(mut archive) => {
656 let success = (0..archive.len()).all(|index| match archive.by_index(index) {
657 | Ok(mut file) => {
658 let target = root.join(file.name());
659 if let Some(parent) = target.parent() {
660 match create_dir_all(parent) {
661 | Ok(_) => {}
662 | Err(why) => error!(path = parent.to_path_buf().to_absolute_string(), "=> {} Create - {}", Label::fail(), why),
663 }
664 }
665 match OpenOptions::new().write(true).create_new(true).open(&target) {
666 | Ok(mut output_file) => match io::copy(&mut file, &mut output_file) {
667 | Ok(_) => true,
668 | Err(why) => {
669 error!(path = target.to_absolute_string(), "=> {} Copy file content - {why}", Label::fail());
670 false
671 }
672 },
673 | Err(_) => {
674 error!(path = target.to_absolute_string(), "=> {} Create file", Label::fail());
675 false
676 }
677 }
678 }
679 | Err(why) => {
680 error!(path = path.to_absolute_string(), "=> {} Extract file - {why}", Label::fail());
681 false
682 }
683 });
684 if success {
685 info!(path = root.to_absolute_string(), "=> {} Extract zip archive", Label::pass());
686 Ok(root)
687 } else {
688 error!(path = root.to_absolute_string(), "=> {} Extract zip archive", Label::fail());
689 Err(eyre!("Failed to extract zip archive"))
690 }
691 }
692 | Err(why) => {
693 error!(path = path.to_absolute_string(), "=> {} Read zip archive - {why}", Label::fail());
694 Err(eyre!("Failed to read zip archive - {why}"))
695 }
696 },
697 | Err(why) => {
698 error!(path = path.to_absolute_string(), "=> {} Read file - {why}", Label::fail());
699 Err(eyre!("Failed to read file - {why}"))
700 }
701 }
702}
703pub fn file_checksum<P>(path: P, algorithm: Option<&'static ring::digest::Algorithm>) -> Option<Checksum>
716where
717 P: Into<PathBuf>,
718{
719 let value = path.into();
720 let digest_algorithm = algorithm.unwrap_or(&SHA256);
721 let checksum_algorithm = ChecksumAlgorithm::from(digest_algorithm);
722 match File::open(value.clone()) {
723 | Ok(file) => {
724 let mut buffer = [0; 1024];
725 let mut context = Context::new(digest_algorithm);
726 let mut reader = BufReader::new(file);
727 loop {
728 let count = match reader.read(&mut buffer) {
729 | Ok(c) => c,
730 | Err(err) => {
731 error!(
732 error = err.to_string(),
733 path = value.to_absolute_string(),
734 "=> {} Read file checksum",
735 Label::fail()
736 );
737 return None;
738 }
739 };
740 if count == 0 {
741 break;
742 }
743 context.update(buffer.get(..count).unwrap_or(&[]));
744 }
745 let digest = context.finish();
746 let result = HEXUPPER.encode(digest.as_ref());
747 Some(Checksum {
748 algorithm: checksum_algorithm,
749 checksum_value: result.to_lowercase(),
750 })
751 }
752 | Err(err) => {
753 error!(
754 error = err.to_string(),
755 path = value.to_absolute_string(),
756 "=> {} Read file",
757 Label::fail()
758 );
759 None
760 }
761 }
762}
763pub fn files_all(path: PathBuf, extensions: Option<Vec<&str>>) -> Vec<PathBuf> {
772 let path = uri_to_path(path);
773 fn paths_to_vec(paths: glob::Paths) -> Vec<PathBuf> {
774 paths.collect::<Vec<_>>().into_iter().filter_map(|x| x.ok()).collect::<Vec<_>>()
775 }
776 fn pattern(path: PathBuf, extension: &str) -> String {
777 let ext = &extension.to_lowercase();
778 let result = format!("{}/**/*.{}", path.to_absolute_string(), ext);
779 debug!("=> {} {result}", Label::using());
780 result
781 }
782 if path.is_dir() {
783 match extensions {
784 | Some(values) => {
785 values
786 .into_iter()
787 .map(|extension| {
788 let glob_pattern = pattern(path.clone(), extension);
789 glob(&glob_pattern)
790 })
791 .filter_map(|x| x.ok())
792 .flat_map(paths_to_vec)
793 .fold((HashSet::new(), Vec::new()), |(mut seen, mut ordered), path| {
794 if seen.insert(path.clone()) {
795 ordered.push(path);
796 }
797 (seen, ordered)
798 })
799 .1
800 }
801 | None => match glob(&format!("{}/**/*", path.to_absolute_string())) {
802 | Ok(paths) => paths_to_vec(paths),
803 | Err(why) => {
804 error!("=> {} Get all files (Glob) - {why}", Label::fail());
805 vec![]
806 }
807 },
808 }
809 } else {
810 if extensions.is_some() {
811 warn!(
812 path = path.clone().to_absolute_string(),
813 "=> {} Extension passed with single file to files_all()...was this intended?",
814 Label::using()
815 );
816 }
817 vec![path]
818 }
819}
820pub fn files_from_git_branch(value: &str, extensions: Option<Vec<&str>>) -> Vec<PathBuf> {
827 if command_exists("git".to_owned()) {
828 let default_branch = match git_default_branch_name() {
829 | Some(value) => value,
830 | None => "main".to_string(),
831 };
832 let args = vec!["diff", "--name-only", &default_branch, "--merge-base", value];
833 match cmd!("git", args) {
834 | Ok(output) if output.status.success() => filter_git_command_result(output.stdout(), extensions),
835 | Ok(output) => {
836 let why = output.stderr();
837 let message = if why.is_empty() {
838 format!("process exited with status {}", output.status)
839 } else {
840 why
841 };
842 error!("=> {} Get files from Git branch - {}", Label::fail(), message);
843 vec![]
844 }
845 | Err(why) => {
846 error!("=> {} Get files from Git branch - {why}", Label::fail());
847 vec![]
848 }
849 }
850 } else {
851 vec![]
852 }
853}
854pub fn files_from_git_commit(value: &str, extensions: Option<Vec<&str>>) -> Vec<PathBuf> {
861 if command_exists("git".to_owned()) {
862 let args = vec!["diff-tree", "--no-commit-id", "--name-only", "-r", value];
863 let result = cmd!("git", args);
864 debug!("=> {} Git command response - {result:?}", Label::using());
865 let files = match result {
866 | Ok(output) if output.status.success() => filter_git_command_result(output.stdout(), extensions),
867 | Ok(output) => {
868 let why = output.stderr();
869 let message = if why.is_empty() {
870 format!("process exited with status {}", output.status)
871 } else {
872 why
873 };
874 error!("=> {} Get files from Git commit - {}", Label::fail(), message);
875 vec![]
876 }
877 | Err(why) => {
878 error!("=> {} Get files from Git commit - {why}", Label::fail());
879 vec![]
880 }
881 };
882 debug!(
883 "=> {} Found {} file{} from Git commit - {files:?}",
884 Label::using(),
885 files.len(),
886 suffix(files.len())
887 );
888 files
889 } else {
890 vec![]
891 }
892}
893pub async fn files_from_gitlab_merge_request(extensions: Option<Vec<&str>>) -> Vec<PathBuf> {
899 let root = var("CI_API_V4_URL").unwrap_or_default();
900 let project_id = var("CI_MERGE_REQUEST_PROJECT_ID").unwrap_or_default();
901 let merge_request_iid = var("CI_MERGE_REQUEST_IID").unwrap_or_default();
902 let path = format!("/projects/{project_id}/merge_requests/{merge_request_iid}/diffs");
903 let url = format!("{root}{path}");
904 match http::get(url).send().await {
905 | Ok(response) => {
906 let content: serde_json::Result<Vec<GitlabMergeRequestDiffResponse>> = response.text().await.map_or_else(
907 |_| Err(serde_json::Error::io(io::Error::other("Failed to read response text"))),
908 |body| serde_json::from_str(&body),
909 );
910 match content {
911 | Ok(data) => {
912 debug!("=> {} GitLab API merge request diff response - {data:#?}", Label::using());
913 let results = data.into_iter().map(|x| PathBuf::from(x.new_path)).collect::<Vec<PathBuf>>();
914 match extensions {
915 | Some(values) => results
916 .into_iter()
917 .filter(|path| values.iter().any(|ext| MimeType::from_path(path).file_type() == *ext.to_lowercase()))
918 .collect::<Vec<_>>(),
919 | None => results,
920 }
921 }
922 | Err(why) => {
923 error!("=> {} Parse GitLab API merge request diff response - {why}", Label::fail());
924 vec![]
925 }
926 }
927 }
928 | Err(why) => {
929 error!("=> {} Get GitLab API merge request diff response - {why}", Label::fail());
930 vec![]
931 }
932 }
933}
934pub fn filter_git_command_result(value: String, extensions: Option<Vec<&str>>) -> Vec<PathBuf> {
936 match extensions {
937 | Some(values) => value
938 .to_lowercase()
939 .split("\n")
940 .map(PathBuf::from)
941 .filter(|path| values.iter().any(|ext| MimeType::from_path(path).file_type() == *ext.to_lowercase()))
942 .collect::<Vec<_>>(),
943 | None => value.to_lowercase().split("\n").map(PathBuf::from).collect::<Vec<_>>(),
944 }
945}
946pub fn filter_ignored(paths: Vec<PathBuf>, ignore: Option<String>) -> ApiResult<Vec<PathBuf>> {
958 match ignore {
959 | Some(ignore_pattern) => match Regex::new(&ignore_pattern) {
960 | Ok(re) => Ok(paths
961 .into_iter()
962 .map(to_absolute_string)
963 .filter(|x| !re.is_match(x).unwrap_or(false))
964 .map(PathBuf::from)
965 .collect()),
966 | Err(why) => Err(eyre!("Invalid regex/filter pattern: {why}")),
967 },
968 | None => Ok(paths),
969 }
970}
971pub fn filter_ignored_with_root(paths: Vec<PathBuf>, ignore: Option<String>, root: PathBuf) -> ApiResult<Vec<PathBuf>> {
975 match ignore {
976 | Some(ignore_pattern) => match Regex::new(&ignore_pattern) {
977 | Ok(re) => {
978 let root = if root.is_file() {
979 root.parent().map(|value| value.to_path_buf()).unwrap_or(root)
980 } else {
981 root
982 };
983 let normalized_root = canonicalize(root.clone()).unwrap_or(root);
984 let mut filtered: Vec<PathBuf> = vec![];
985 for path in paths {
986 let normalized_path = canonicalize(path.clone()).unwrap_or(path.clone());
987 match normalized_path.strip_prefix(&normalized_root) {
988 | Ok(relative) => {
989 let value = relative.to_string_lossy().to_string().replace('\\', "/");
990 if !re.is_match(&value).unwrap_or(false) {
991 filtered.push(path);
992 }
993 }
994 | Err(_) => {
995 return Err(eyre!(
996 "Path '{}' is outside resolved root '{}'",
997 normalized_path.to_absolute_string(),
998 normalized_root.to_absolute_string()
999 ));
1000 }
1001 }
1002 }
1003 Ok(filtered)
1004 }
1005 | Err(why) => Err(eyre!("Invalid regex/filter pattern: {why}")),
1006 },
1007 | None => Ok(paths),
1008 }
1009}
1010pub fn first_env_var(names: &[&str]) -> Option<String> {
1023 names.iter().find_map(|name| var(name).ok())
1024}
1025pub fn folder_size<P: Into<PathBuf>>(path: P) -> u64 {
1027 files_all(path.into(), None)
1028 .into_iter()
1029 .filter_map(|p| p.metadata().ok())
1030 .filter(|m| m.is_file())
1031 .map(|m| m.len())
1032 .sum()
1033}
1034pub fn git_branch_name() -> Option<String> {
1040 if command_exists("git".to_owned()) {
1041 let args = vec!["symbolic-ref", "--short", "HEAD"];
1042 match cmd!("git", args) {
1043 | Ok(output) if output.status.success() => output.stdout().split("/").last().map(|x| x.to_string()),
1044 | Ok(_) => None,
1045 | Err(_) => None,
1046 }
1047 } else {
1048 None
1049 }
1050}
1051pub fn git_default_branch_name() -> Option<String> {
1057 if command_exists("git".to_owned()) {
1058 let args = vec!["symbolic-ref", "refs/remotes/origin/HEAD", "--short"];
1059 match cmd!("git", args) {
1060 | Ok(output) if output.status.success() => output.stdout().split("/").last().map(|x| x.to_string()),
1061 | Ok(_) => None,
1062 | Err(_) => None,
1063 }
1064 } else {
1065 None
1066 }
1067}
1068pub fn image_paths<P>(root: P) -> Vec<PathBuf>
1083where
1084 P: Into<PathBuf> + Clone,
1085{
1086 let extensions = ["jpg", "jpeg", "png", "svg", "gif"];
1087 let mut files = extensions
1088 .iter()
1089 .flat_map(|ext| glob(&format!("{}/**/*.{}", root.clone().into().display(), ext)))
1090 .flat_map(|paths| paths.collect::<Vec<_>>())
1091 .flatten()
1092 .collect::<Vec<PathBuf>>();
1093 files.sort();
1094 files
1095}
1096#[cfg(any(unix, target_os = "wasi", target_os = "redox"))]
1106pub fn make_executable<P>(path: P) -> bool
1107where
1108 P: Into<PathBuf> + Clone,
1109{
1110 let path = path.into();
1111 let create_with_mode = OpenOptions::new().write(true).create_new(true).mode(0o755).open(path.as_path());
1112 match create_with_mode {
1113 | Ok(_) => path.is_executable(),
1114 | Err(why) => {
1115 if why.kind() == io::ErrorKind::AlreadyExists {
1116 match set_permissions(path.as_path(), Permissions::from_mode(0o755)) {
1117 | Ok(()) => path.is_executable(),
1118 | Err(why) => {
1119 debug!(path = path.to_absolute_string(), "=> {} Set permissions — {why}", Label::fail());
1120 false
1121 }
1122 }
1123 } else {
1124 debug!(path = path.to_absolute_string(), "=> {} Create executable file — {why}", Label::fail());
1125 false
1126 }
1127 }
1128 }
1129}
1130#[cfg(windows)]
1140pub fn make_executable<P>(path: P) -> bool
1141where
1142 P: Into<PathBuf> + Clone,
1143{
1144 let binary = match file_extension(path.clone().into().to_absolute_string()) {
1145 | None => path.into().with_extension("exe"),
1146 | _ => path.into(),
1147 };
1148 debug!("=> {} {binary:#?}", Label::using());
1149 binary.is_executable()
1150}
1151pub fn file_name_with_parent(value: impl Into<PathBuf>) -> String {
1155 let path = value.into();
1156 let name = path.file_name().and_then(|value| value.to_str()).unwrap_or_default().to_string();
1157 if path.is_dir() {
1158 name
1159 } else {
1160 let parent_name = path
1161 .parent()
1162 .and_then(|value| value.file_name())
1163 .and_then(|value| value.to_str())
1164 .unwrap_or_default();
1165 if parent_name.is_empty() {
1166 name
1167 } else {
1168 format!("{parent_name}/{name}")
1169 }
1170 }
1171}
1172pub fn parent<P>(path: P) -> PathBuf
1174where
1175 P: Into<PathBuf> + Clone,
1176{
1177 let default = PathBuf::from(".");
1178 match path.clone().into().canonicalize() {
1179 | Ok(value) => match value.parent() {
1180 | Some(value) => value.to_path_buf(),
1181 | None => {
1182 warn!("=> {} Resolve parent path", Label::fail());
1183 default
1184 }
1185 },
1186 | Err(why) => {
1187 debug!("=> {} Resolve absolute path - {why}", Label::fail());
1188 match path.into().parent() {
1189 | Some(value) if !value.to_path_buf().to_absolute_string().is_empty() => value.to_path_buf(),
1190 | Some(_) | None => {
1191 warn!("=> {} Parent path was empty or could not be resolved", Label::fail());
1192 default
1193 }
1194 }
1195 }
1196 }
1197}
1198pub fn read_file<P>(path: P) -> ApiResult<String>
1223where
1224 P: Into<PathBuf> + Clone + Send,
1225{
1226 let path_buf = path.into();
1227 let filename = path_buf.file_name().unwrap_or_default().to_string_lossy().to_string();
1228 let is_large_file = match path_buf.metadata() {
1229 | Ok(metadata) => metadata.len() >= LARGE_FILE_THRESHOLD_BYTES,
1230 | Err(_) => false,
1231 };
1232 if is_large_file {
1233 trace!(filename, "=> {} Read file with large-file strategy", Label::using());
1234 read_large_file(path_buf)
1235 } else {
1236 match File::open(&path_buf) {
1237 | Ok(file) => {
1238 let mut reader = BufReader::new(file);
1239 let mut content = String::new();
1240 match reader.read_to_string(&mut content) {
1241 | Ok(_) => Ok(content),
1242 | Err(why) => Err(eyre!("Failed to read file content — {why}")),
1243 }
1244 }
1245 | Err(why) => {
1246 error!(filename, "=> {} Read file", Label::fail());
1247 Err(eyre!("Failed to read file — {why}"))
1248 }
1249 }
1250 }
1251}
1252pub fn read_large_file<P>(path: P) -> ApiResult<String>
1257where
1258 P: Into<PathBuf> + Clone + Send,
1259{
1260 match File::open(path.into()) {
1261 | Ok(file) => {
1262 let capacity = file
1263 .metadata()
1264 .ok()
1265 .and_then(|metadata| usize::try_from(metadata.len()).ok())
1266 .unwrap_or(0);
1267 let mut reader = BufReader::with_capacity(1024 * 1024, file);
1268 let mut content = if capacity > 0 { String::with_capacity(capacity) } else { String::new() };
1269 match reader.read_to_string(&mut content) {
1270 | Ok(_) => Ok(content),
1271 | Err(why) => Err(eyre!("Failed to read large file content — {why}")),
1272 }
1273 }
1274 | Err(why) => Err(eyre!("Failed to read large file — {why}")),
1275 }
1276}
1277pub fn standard_project_folder(namespace: &str, default: Option<PathBuf>) -> PathBuf {
1293 let root = match default {
1294 | Some(value) => value,
1295 | None => match ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION) {
1296 | Some(dirs) => dirs.cache_dir().join(namespace).to_path_buf(),
1297 | None => PathBuf::from(format!("./{namespace}")),
1298 },
1299 };
1300 match create_dir_all(root.clone()) {
1301 | Ok(_) => {}
1302 | Err(why) => error!(directory = root.clone().to_absolute_string(), "=> {} Create - {why}", Label::fail()),
1303 };
1304 root.join(generate_guid())
1305}
1306pub fn to_absolute_string<P>(path: P) -> String
1320where
1321 P: Into<PathBuf> + Clone,
1322{
1323 let result = match canonicalize(path.clone().into().as_path()) {
1324 | Ok(value) => value,
1325 | Err(_) => path.into(),
1326 };
1327 let s = result.display().to_string();
1328 #[cfg(windows)]
1329 let s = s.strip_prefix(r"\\?\").unwrap_or(&s).to_string();
1330 s
1331}
1332pub fn unique_file_extensions(paths: &[PathBuf]) -> Vec<String> {
1334 let mut extensions = paths
1335 .iter()
1336 .filter_map(|path| path.extension().map(|extension| extension.to_string_lossy().to_lowercase()))
1337 .collect::<HashSet<_>>()
1338 .into_iter()
1339 .collect::<Vec<_>>();
1340 extensions.sort_unstable();
1341 extensions
1342}
1343pub fn uri_to_path<P>(path: P) -> PathBuf
1347where
1348 P: Into<PathBuf>,
1349{
1350 let value: PathBuf = path.into();
1351 let s = value.to_string_lossy().into_owned();
1352 s.strip_prefix("file://")
1353 .map(|stripped| {
1354 #[cfg(windows)]
1355 let normalized = match stripped.get(1..3) {
1356 | Some(drive) if drive.contains(':') => &stripped[1..],
1357 | _ => stripped,
1358 };
1359 #[cfg(not(windows))]
1360 let normalized = stripped;
1361 PathBuf::from(normalized)
1362 })
1363 .unwrap_or(value)
1364}
1365pub fn create_progress_bar(count: usize, progress_type: ProgressType) -> ProgressBar {
1367 if matches!(progress_type, ProgressType::Silent) {
1368 ProgressBar::hidden()
1369 } else {
1370 let progress = if progress_type.is_indeterminate() {
1371 let spinner = ProgressBar::new_spinner();
1372 spinner.enable_steady_tick(Duration::from_millis(120));
1373 spinner
1374 } else {
1375 ProgressBar::new(count as u64)
1376 };
1377 if let Some(template) = progress_type.template() {
1378 #[allow(clippy::unwrap_used)]
1379 progress.set_style(ProgressStyle::with_template(template).unwrap());
1380 }
1381 progress
1382 }
1383}
1384pub fn apply_progress_style(progress: &ProgressBar, template: &str) {
1386 #[allow(clippy::unwrap_used)]
1387 progress.set_style(ProgressStyle::with_template(template).unwrap());
1388}
1389pub fn finish_progress_bar(progress: &ProgressBar, message: String) {
1391 #[allow(clippy::unwrap_used)]
1392 progress.set_style(ProgressStyle::with_template(" {msg}").unwrap());
1393 progress.finish_with_message(message);
1394}
1395pub async fn with_progress<T, U, M, F, Fut>(
1419 items: Vec<T>,
1420 message: M,
1421 operation: F,
1422 finish_message: impl FnOnce(usize) -> String,
1423 buffer_size: Option<usize>,
1424 progress_type: ProgressType,
1425) -> ApiResult<Vec<U>>
1426where
1427 M: for<'a> Fn(&'a T) -> String,
1428 F: Fn(T) -> Fut,
1429 Fut: Future<Output = ApiResult<U>>,
1430{
1431 let concurrency = buffer_size.unwrap_or(10).max(1);
1432 let count = items.len();
1433 let progress = create_progress_bar(count, progress_type);
1434 if matches!(progress_type, ProgressType::Spinner) {
1435 progress.enable_steady_tick(Duration::from_millis(120));
1436 }
1437 let output = stream::iter(items)
1438 .map(|item| {
1439 let msg = message(&item);
1440 let future = operation(item);
1441 async move {
1442 let result = future.await;
1443 (msg, result)
1444 }
1445 })
1446 .buffer_unordered(concurrency)
1447 .map(|(msg, result)| {
1448 progress.set_message(msg);
1449 progress.inc(1);
1450 result
1451 })
1452 .collect::<Vec<_>>()
1453 .await
1454 .into_iter()
1455 .collect::<ApiResult<Vec<_>>>();
1456
1457 if !matches!(progress_type, ProgressType::Silent) {
1458 finish_progress_bar(&progress, finish_message(count));
1459 }
1460 output
1461}
1462pub fn write_file<P>(path: P, content: String) -> ApiResult<()>
1472where
1473 P: Into<PathBuf>,
1474{
1475 write(path.into(), content.as_bytes())
1476 .map(|_| ())
1477 .map_err(|why| eyre!("Failed to write file - {why}"))
1478}
1479pub async fn write_file_bytes<P, F, Fut, E>(path: P, get_bytes: F) -> ApiResult<()>
1489where
1490 P: Into<PathBuf>,
1491 F: FnOnce() -> Fut,
1492 Fut: Future<Output = Result<Vec<u8>, E>>,
1493 E: Into<Report>,
1494{
1495 let path = path.into();
1496 match path.parent() {
1497 | Some(parent) => {
1498 let folder = parent.display().to_string();
1499 match create_dir_all(folder.clone()) {
1500 | Ok(_) => match OpenOptions::new().write(true).create_new(true).open(&path) {
1501 | Ok(mut file) => match get_bytes().await.map_err(Into::into) {
1502 | Ok(bytes) => {
1503 let mut content = Cursor::new(bytes);
1504 match io::copy(&mut content, &mut file) {
1505 | Ok(_) => Ok(()),
1506 | Err(why) => Err(eyre!("Failed to write bytes — {why}")),
1507 }
1508 }
1509 | Err(why) => Err(why),
1510 },
1511 | Err(why) => Err(eyre!("Failed to create output file — {why}")),
1512 },
1513 | Err(why) => Err(eyre!("Failed to create output folder — {why}")),
1514 }
1515 }
1516 | None => Err(eyre!("Output path has no parent directory")),
1517 }
1518}
1519pub fn write_rsa_keypair<P>(values: RsaKeyPair, path: Option<P>) -> ApiResult<(PathBuf, PathBuf)>
1525where
1526 P: Into<PathBuf>,
1527{
1528 let resolved = match path {
1529 | Some(p) => Ok(p.into()),
1530 | None => match current_dir() {
1531 | Ok(cwd) => Ok(cwd.join("id_rsa")),
1532 | Err(why) => Err(eyre!("Failed to get current directory — {why}")),
1533 },
1534 };
1535 match resolved {
1536 | Ok(path) => {
1537 let (private_key, public_key) = values;
1538 match private_key.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF) {
1539 | Ok(private_key_pem) => match public_key.to_public_key_pem(rsa::pkcs8::LineEnding::LF) {
1540 | Ok(public_key_pem) => {
1541 let public_key_path = PathBuf::from(format!("{}.pub", path.display()));
1542 let private_key_path = path.clone();
1543 match write_file(path, (*private_key_pem).clone()) {
1544 | Ok(_) => match write_file(public_key_path.clone(), public_key_pem) {
1545 | Ok(_) => Ok((private_key_path, public_key_path)),
1546 | Err(why) => Err(why),
1547 },
1548 | Err(why) => Err(why),
1549 }
1550 }
1551 | Err(why) => {
1552 error!("=> {} Write RSA keypair (public key) — {why}", Label::fail());
1553 Err(eyre!("Failed to serialize public key to PEM — {why}"))
1554 }
1555 },
1556 | Err(why) => {
1557 error!("=> {} Write RSA keypair (private key) — {why}", Label::fail());
1558 Err(eyre!("Failed to serialize private key to PEM — {why}"))
1559 }
1560 }
1561 }
1562 | Err(why) => Err(why),
1563 }
1564}
1565
1566#[cfg(test)]
1567mod tests;