#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
#![warn(
clippy::unwrap_used,
clippy::unimplemented,
clippy::todo,
clippy::str_to_string
)]
#![allow(clippy::module_name_repetitions)]
pub mod logs;
use crate::logs::{LogLine, UnknownArgumentLine};
use derive_builder::UninitializedFieldError;
use std::ffi::OsString;
use std::future::Future;
use std::io::Error as IoError;
use std::path::{Path, PathBuf};
use std::process::{Command as StdCommand, ExitStatus, Stdio};
use tempfile::{Builder as TempFileBuilder, TempDir};
use tokio::process::Command as TokioCommand;
use tokio::sync::mpsc::Sender as MpscSender;
use tokio_util::sync::CancellationToken;
pub type CommandArgumentBuilder = Box<
dyn FnOnce(
&TempDir,
) -> Box<
dyn Future<Output = Result<Vec<OsString>, ConversionError>>
+ Send
+ Sync
+ Unpin
+ 'static,
> + Send
+ Sync,
>;
pub enum Command<'a> {
MapToBsp(&'a [u8]),
MapToAas(&'a [u8]),
BspToMap(&'a [u8]),
BspToBsp(&'a [u8]),
BspToAas(&'a [u8]),
Other(CommandArgumentBuilder),
}
impl<'a> Command<'a> {
async fn try_into_args(self, temp_dir: &TempDir) -> Result<Vec<OsString>, ConversionError> {
if let Command::Other(build_arguments) = self {
build_arguments(temp_dir).await
} else {
let input_file_extension = match self {
Command::MapToBsp(_) | Command::MapToAas(_) => "map",
Command::BspToMap(_) | Command::BspToBsp(_) | Command::BspToAas(_) => "bsp",
Command::Other(_) => unreachable!(),
};
let input_file_contents = match self {
Command::MapToBsp(contents)
| Command::MapToAas(contents)
| Command::BspToMap(contents)
| Command::BspToBsp(contents)
| Command::BspToAas(contents) => contents,
Command::Other(_) => unreachable!(),
};
let subcommand = match self {
Command::MapToBsp(_) => "-map2bsp",
Command::MapToAas(_) => "-map2aas",
Command::BspToMap(_) => "-bsp2map",
Command::BspToBsp(_) => "-bsp2bsp",
Command::BspToAas(_) => "-bsp2aas",
Command::Other { .. } => unreachable!(),
};
let input_file_path = temp_dir
.path()
.join(format!("input.{}", input_file_extension));
tokio::fs::write(&input_file_path, input_file_contents)
.await
.map_err(|err| ConversionError::TempDirectoryIo(err, input_file_path.clone()))?;
let args = vec![subcommand.into(), input_file_path.clone().into()];
Ok(args)
}
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(derive_builder::Builder)]
#[builder(build_fn(private, name = "fallible_build", error = "PrivateOptionsBuilderError"))]
pub struct Options {
#[builder(default = "false")]
pub verbose: bool,
#[builder(default, setter(strip_option))]
pub threads: Option<usize>,
#[builder(default, setter(strip_option))]
pub cancellation_token: Option<CancellationToken>,
#[builder(default, setter(strip_option))]
pub log_stream: Option<MpscSender<LogLine>>,
#[builder(default, setter(custom))]
pub additional_args: Vec<OsString>,
#[builder(default = "true")]
pub log_command: bool,
}
#[derive(Debug)]
struct PrivateOptionsBuilderError(UninitializedFieldError);
impl From<UninitializedFieldError> for PrivateOptionsBuilderError {
fn from(err: UninitializedFieldError) -> Self {
Self(err)
}
}
impl Options {
#[must_use]
pub fn builder() -> OptionsBuilder {
OptionsBuilder::default()
}
}
impl OptionsBuilder {
#[must_use]
pub fn build(&mut self) -> Options {
self.fallible_build()
.expect("OptionsBuilder::build() should not fail")
}
pub fn additional_args<I, S>(&mut self, args: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.additional_args
.get_or_insert_with(Vec::new)
.extend(args.into_iter().map(Into::into));
self
}
pub fn additional_arg<S>(&mut self, arg: S) -> &mut Self
where
S: Into<OsString>,
{
self.additional_args
.get_or_insert_with(Vec::new)
.push(arg.into());
self
}
}
impl Options {
#[must_use]
fn into_args(self) -> Vec<OsString> {
let mut args: Vec<OsString> = Vec::new();
if !self.verbose {
args.push("-noverbose".into());
}
if let Some(threads) = self.threads {
args.push("-threads".into());
args.push(threads.to_string().into());
}
args.extend(self.additional_args);
args
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Output {
pub exit: ExitStatus,
pub exit_code: Option<i32>,
pub files: Vec<OutputFile>,
pub args: Vec<String>,
pub logs: Vec<LogLine>,
}
#[derive(Debug, thiserror::Error)]
pub enum ConversionError {
#[error("provided path to bspc executable (\"{0}\") does not exist or is not a file: {0}")]
ExecutableNotFound(PathBuf),
#[error("failed to create a temporary directory to store inputs/outputs: {0}")]
TempDirectoryCreationFailed(#[source] IoError),
#[error(
"failed to read/write to the temporary directory (at \"{1}\") storing inputs/outputs: {0}"
)]
TempDirectoryIo(#[source] IoError, PathBuf),
#[error("failed to start child \"bspc\" process: {0}")]
ProcessStartFailure(#[source] IoError),
#[error("failed to wait for child \"bspc\" process to exit: {0}")]
ProcessWaitFailure(#[source] IoError),
#[error("conversion was cancelled by the cancellation token")]
Cancelled,
#[error("child \"bspc\" process was provided unknown argument '{unknown_argument}': full argument list: {args:?}")]
UnknownArgument {
unknown_argument: String,
args: Vec<String>,
},
#[error("\"bspc\" did not find any files when it ran the conversion process (see logs). If a standard command was used, then this indicates that the temporary file may have been deleted before \"bspc\"")]
NoInputFilesFound(Output),
#[error("child \"bspc\" process exited with a non-zero exit code {} (see logs)", .0.exit)]
ProcessExitFailure(Output),
#[error("child \"bspc\" process resulted in no output files (see logs)")]
NoOutputFiles(Output),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutputFile {
pub name: String,
pub extension: Option<String>,
pub contents: Vec<u8>,
}
#[allow(clippy::too_many_lines)]
pub async fn convert(
executable_path: impl AsRef<Path> + Send,
cmd: Command<'_>,
mut options: Options,
) -> Result<Output, ConversionError> {
let cancellation_token = options
.cancellation_token
.take()
.unwrap_or_else(CancellationToken::new);
let log_stream = options.log_stream.take();
let log_command = options.log_command;
let option_args = options.into_args();
let executable_path = executable_path.as_ref();
let executable_path = tokio::fs::canonicalize(executable_path)
.await
.map_err(|_| ConversionError::ExecutableNotFound(executable_path.to_owned()))?;
let executable_metadata = tokio::fs::metadata(&executable_path)
.await
.map_err(|_| ConversionError::ExecutableNotFound(executable_path.clone()))?;
if !executable_metadata.is_file() {
return Err(ConversionError::ExecutableNotFound(executable_path));
}
let temp_dir = TempFileBuilder::new()
.prefix("bspc-rs")
.tempdir()
.map_err(ConversionError::TempDirectoryCreationFailed)?;
let output_directory_path = temp_dir.path().join("output");
tokio::fs::create_dir(&output_directory_path)
.await
.map_err(|e| ConversionError::TempDirectoryIo(e, output_directory_path.clone()))?;
let mut args: Vec<OsString> = Vec::new();
let command_args = cmd.try_into_args(&temp_dir).await?;
args.extend(command_args);
args.push("-output".into());
args.push(output_directory_path.as_os_str().to_owned());
args.extend(option_args);
let debug_args: Vec<String> = args
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let mut command = StdCommand::new(executable_path);
command
.env_clear()
.current_dir(temp_dir.path())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.args(args);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
use windows::Win32::System::Threading::CREATE_NO_WINDOW;
command.creation_flags(CREATE_NO_WINDOW.0);
}
let initial_log_lines: Vec<LogLine> = {
let mut initial_log_lines: Vec<LogLine> = Vec::new();
if log_command {
let command_log_line =
LogLine::Info(format!("> bspc {}", pretty_format_args(&debug_args)));
if let Some(log_stream) = &log_stream {
let _send_err = log_stream.send(command_log_line.clone()).await;
}
initial_log_lines.push(command_log_line);
}
initial_log_lines
};
let mut child = TokioCommand::from(command)
.spawn()
.map_err(ConversionError::ProcessStartFailure)?;
let wait_with_output_future = async {
let mut stdout_pipe = child
.stdout
.take()
.expect("child should have a piped stdout stream");
let wait_future = async {
child
.wait()
.await
.map_err(ConversionError::ProcessWaitFailure)
};
let consume_log_future = async {
crate::logs::collect_logs(&mut stdout_pipe, log_stream)
.await
.map_err(ConversionError::ProcessWaitFailure)
};
let (exit, logs) = tokio::try_join!(wait_future, consume_log_future)?;
let logs = {
let mut constructed_logs = Vec::with_capacity(initial_log_lines.len() + logs.len());
constructed_logs.extend(initial_log_lines);
constructed_logs.extend(logs);
constructed_logs
};
drop(stdout_pipe);
Ok((exit, logs))
};
let (exit_status, log_lines): (ExitStatus, Vec<LogLine>) = {
#[allow(clippy::redundant_pub_crate)]
let cancellation_result: Result<
Result<(ExitStatus, Vec<LogLine>), ConversionError>,
(),
> = tokio::select! {
result = wait_with_output_future => Ok(result),
_ = cancellation_token.cancelled() => Err(()),
};
match cancellation_result {
Ok(Ok((exit, log_lines))) => (exit, log_lines),
Ok(Err(err)) => {
let _err = child.kill().await;
return Err(err);
}
Err(_) => {
let _err = child.kill().await;
return Err(ConversionError::Cancelled);
}
}
};
let mut no_files_found: bool = false;
let mut unknown_argument: Option<UnknownArgumentLine> = None;
for line in &log_lines {
match line {
LogLine::UnknownArgument(unknown_argument_line) => {
unknown_argument = Some(unknown_argument_line.clone());
}
LogLine::NoFilesFound(_) => {
no_files_found = true;
}
_ => {}
}
}
if let Some(line) = unknown_argument {
return Err(ConversionError::UnknownArgument {
unknown_argument: line.argument,
args: debug_args,
});
}
let mut output_files: Vec<OutputFile> = Vec::new();
let mut read_dir = tokio::fs::read_dir(&output_directory_path)
.await
.map_err(|err| ConversionError::TempDirectoryIo(err, output_directory_path.clone()))?;
while let Some(entry) = read_dir
.next_entry()
.await
.map_err(|err| ConversionError::TempDirectoryIo(err, output_directory_path.clone()))?
{
let file_name = entry.file_name().to_string_lossy().into_owned();
let file_path = entry.path();
let file_extension = file_path
.extension()
.map(|ext| ext.to_string_lossy().into_owned());
let file_contents = tokio::fs::read(&file_path)
.await
.map_err(|err| ConversionError::TempDirectoryIo(err, file_path))?;
output_files.push(OutputFile {
name: file_name,
extension: file_extension,
contents: file_contents,
});
}
let output = Output {
exit_code: exit_status.code(),
exit: exit_status,
files: output_files,
args: debug_args,
logs: log_lines,
};
if no_files_found {
return Err(ConversionError::NoInputFilesFound(output));
}
if !output.exit.success() {
return Err(ConversionError::ProcessExitFailure(output));
}
if output.files.is_empty() {
return Err(ConversionError::NoOutputFiles(output));
}
Ok(output)
}
fn pretty_format_args<I, S>(args: I) -> String
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
args.into_iter()
.map(|a| {
let a = a.as_ref();
if a.contains(char::is_whitespace) || a.contains(char::is_control) || a.contains('"') {
format!("{:?}", a)
} else {
a.to_owned()
}
})
.collect::<Vec<_>>()
.join(" ")
}