bspc/lib.rs
1//! This library wraps around the [bspc](https://github.com/bnoordhuis/bspc) Quake utility tool
2//! to make it easier to use it from Rust.
3//! It does so by spawning a child process and asynchronously waiting for its output.
4//!
5//! Some features include:
6//! - setting up a temporary directory to store input/output files in
7//! - parsing output logs to look for errors/warnings
8//! - streaming the output logs in real-time (via `OptionsBuilder::log_stream`)
9//!
10//! # Links
11//!
12//! The BSPC tool itself is not included with the library.
13//! Instead, it needs to already exist in the filesystem before the library is used.
14//!
15//! - Old binary downloads for v1.2: [link](https://web.archive.org/web/20011023020820/http://www.botepidemic.com:80/gladiator/download.shtml)
16//! - Source: [bnoordhuis/bspc](https://github.com/bnoordhuis/bspc)
17//! - Fork with more recent commits: [TTimo/bspc](https://github.com/TTimo/bspc)
18//!
19//! # Example
20//!
21//! Basic example showing the conversion of a Quake BSP file to a MAP file:
22//!
23//! ```rust
24//! use bspc::{Command, Options};
25//! use tokio_util::sync::CancellationToken;
26//!
27//! # tokio_test::block_on(async {
28//! let bsp_contents = b"...";
29//! let result = bspc::convert(
30//! "./test_resources/bspci386",
31//! Command::BspToMap(bsp_contents),
32//! Options::builder()
33//! .verbose(true)
34//! .build(),
35//! )
36//! .await;
37//! match result {
38//! Ok(output) => {
39//! assert_eq!(output.files.len(), 1);
40//! println!("{}", output.files[0].name);
41//! println!("{}", String::from_utf8_lossy(&output.files[0].contents));
42//! }
43//! Err(err) => {
44//! println!("Conversion failed: {}", err);
45//! }
46//! }
47//! # })
48//! ```
49//!
50//! ## Example with cancellation
51//!
52//! The following snippet demonstrates how to cancel the conversion (in this
53//! case, using a timeout) via the cancellation token. Note that the
54//! cancellation is not done simply by dropping the future (as is normally done),
55//! since we want to ensure that the child process is killed and the temporary
56//! directory deleted before the future completes.
57//!
58//! ```rust
59//! use bspc::{Command, Options, ConversionError};
60//! use tokio_util::sync::CancellationToken;
61//!
62//! # tokio_test::block_on(async {
63//! let bsp_contents = b"...";
64//! let cancel_token = CancellationToken::new();
65//! let cancel_task = {
66//! let cancel_token = cancel_token.clone();
67//! tokio::spawn(async move {
68//! tokio::time::sleep(std::time::Duration::from_secs(10)).await;
69//! cancel_token.cancel();
70//! })
71//! };
72//! let result = bspc::convert(
73//! "./test_resources/bspci386",
74//! Command::BspToMap(bsp_contents),
75//! Options::builder()
76//! .verbose(true)
77//! .cancellation_token(cancel_token)
78//! .build(),
79//! )
80//! .await;
81//! match result {
82//! Ok(output) => {
83//! assert_eq!(output.files.len(), 1);
84//! println!("{}", output.files[0].name);
85//! println!("{}", String::from_utf8_lossy(&output.files[0].contents));
86//! }
87//! Err(ConversionError::Cancelled) => {
88//! println!("Conversion timed out after 10 seconds");
89//! }
90//! Err(err) => {
91//! println!("Conversion failed: {}", err);
92//! }
93//! }
94//! # })
95//! ```
96//!
97
98#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
99#![warn(
100 clippy::unwrap_used,
101 clippy::unimplemented,
102 clippy::todo,
103 clippy::str_to_string
104)]
105#![allow(clippy::module_name_repetitions)]
106
107pub mod logs;
108
109use crate::logs::{LogLine, UnknownArgumentLine};
110use derive_builder::UninitializedFieldError;
111use std::ffi::OsString;
112use std::future::Future;
113use std::io::Error as IoError;
114use std::path::{Path, PathBuf};
115use std::process::{Command as StdCommand, ExitStatus, Stdio};
116use tempfile::{Builder as TempFileBuilder, TempDir};
117use tokio::process::Command as TokioCommand;
118use tokio::sync::mpsc::Sender as MpscSender;
119use tokio_util::sync::CancellationToken;
120
121/// Callback used by [`Command::Other`].
122///
123/// Accepts a the temporary directory that can be used to write files to.
124pub type CommandArgumentBuilder = Box<
125 dyn FnOnce(
126 &TempDir,
127 ) -> Box<
128 dyn Future<Output = Result<Vec<OsString>, ConversionError>>
129 + Send
130 + Sync
131 + Unpin
132 // The builder function can't borrow the TempDir argument, but
133 // this is fine because any operations on it are synchronous.
134 + 'static,
135 > + Send
136 + Sync,
137>;
138
139/// The subcommand to pass to the BSPC executable.
140///
141/// If this is one of the standard subcommands (i.e. not `Other`), then the
142/// command accepts a byte slice containing the contents of the input file
143/// that should be converted. This library handles writing the input file to
144/// a temporary directory before invoking the BSPC executable.
145pub enum Command<'a> {
146 /// Corresponds to the `-map2bsp` subcommand.
147 MapToBsp(&'a [u8]),
148 /// Corresponds to the `-map2aas` subcommand.
149 MapToAas(&'a [u8]),
150 /// Corresponds to the `-bsp2map` subcommand.
151 BspToMap(&'a [u8]),
152 /// Corresponds to the `-bsp2bsp` subcommand.
153 BspToBsp(&'a [u8]),
154 /// Corresponds to the `-bsp2aas` subcommand.
155 BspToAas(&'a [u8]),
156 /// Allows sending an arbitrary command to the BSPC executable.
157 /// This is an asynchronous callback that accepts the temporary directory
158 /// that can be used to write files to, and returns a future that resolves
159 /// to a list of arguments to pass to the BSPC executable (or an error).
160 Other(CommandArgumentBuilder),
161}
162
163impl<'a> Command<'a> {
164 async fn try_into_args(self, temp_dir: &TempDir) -> Result<Vec<OsString>, ConversionError> {
165 if let Command::Other(build_arguments) = self {
166 build_arguments(temp_dir).await
167 } else {
168 let input_file_extension = match self {
169 Command::MapToBsp(_) | Command::MapToAas(_) => "map",
170 Command::BspToMap(_) | Command::BspToBsp(_) | Command::BspToAas(_) => "bsp",
171 Command::Other(_) => unreachable!(),
172 };
173 let input_file_contents = match self {
174 Command::MapToBsp(contents)
175 | Command::MapToAas(contents)
176 | Command::BspToMap(contents)
177 | Command::BspToBsp(contents)
178 | Command::BspToAas(contents) => contents,
179 Command::Other(_) => unreachable!(),
180 };
181 let subcommand = match self {
182 Command::MapToBsp(_) => "-map2bsp",
183 Command::MapToAas(_) => "-map2aas",
184 Command::BspToMap(_) => "-bsp2map",
185 Command::BspToBsp(_) => "-bsp2bsp",
186 Command::BspToAas(_) => "-bsp2aas",
187 Command::Other { .. } => unreachable!(),
188 };
189
190 // Write the input file to a temporary file.
191 let input_file_path = temp_dir
192 .path()
193 .join(format!("input.{}", input_file_extension));
194 tokio::fs::write(&input_file_path, input_file_contents)
195 .await
196 .map_err(|err| ConversionError::TempDirectoryIo(err, input_file_path.clone()))?;
197
198 let args = vec![subcommand.into(), input_file_path.clone().into()];
199 Ok(args)
200 }
201 }
202}
203
204/// Options for the conversion process.
205///
206/// Some of these are passed directly to the BSPC executable.
207#[allow(clippy::struct_excessive_bools)]
208#[derive(derive_builder::Builder)]
209#[builder(build_fn(private, name = "fallible_build", error = "PrivateOptionsBuilderError"))]
210pub struct Options {
211 /// Whether to use verbose logging.
212 ///
213 /// If this is `false`, then the `-noverbose` flag will be passed to the
214 /// BSPC executable.
215 #[builder(default = "false")]
216 pub verbose: bool,
217 /// The number of threads to use for the conversion. By default,
218 /// multi-threading is disabled (equivalent to setting this to `1`).
219 ///
220 /// This is passed to the BSPC executable via the `-threads` flag.
221 #[builder(default, setter(strip_option))]
222 pub threads: Option<usize>,
223 /// A cancellation token that can be used to cancel the conversion
224 /// (instead of dropping the future). See the docs on [`convert`] for
225 /// more information.
226 #[builder(default, setter(strip_option))]
227 pub cancellation_token: Option<CancellationToken>,
228 /// An optional channel to send log lines to as they get logged.
229 #[builder(default, setter(strip_option))]
230 pub log_stream: Option<MpscSender<LogLine>>,
231 /// Additional command-line arguments to pass to the BSPC executable.
232 /// These are added at the end, after all other arguments.
233 #[builder(default, setter(custom))]
234 pub additional_args: Vec<OsString>,
235 /// Whether to log the command that is being executed.
236 #[builder(default = "true")]
237 pub log_command: bool,
238}
239
240#[derive(Debug)]
241struct PrivateOptionsBuilderError(UninitializedFieldError);
242
243impl From<UninitializedFieldError> for PrivateOptionsBuilderError {
244 fn from(err: UninitializedFieldError) -> Self {
245 Self(err)
246 }
247}
248
249impl Options {
250 #[must_use]
251 pub fn builder() -> OptionsBuilder {
252 OptionsBuilder::default()
253 }
254}
255
256impl OptionsBuilder {
257 #[must_use]
258 pub fn build(&mut self) -> Options {
259 self.fallible_build()
260 .expect("OptionsBuilder::build() should not fail")
261 }
262
263 /// Adds additional command-line arguments to pass to the BSPC executable.
264 /// These are added at the end, after all other arguments.
265 pub fn additional_args<I, S>(&mut self, args: I) -> &mut Self
266 where
267 I: IntoIterator<Item = S>,
268 S: Into<OsString>,
269 {
270 self.additional_args
271 .get_or_insert_with(Vec::new)
272 .extend(args.into_iter().map(Into::into));
273 self
274 }
275
276 /// Adds an additional command-line argument to pass to the BSPC executable.
277 /// This is added at the end, after all other arguments.
278 pub fn additional_arg<S>(&mut self, arg: S) -> &mut Self
279 where
280 S: Into<OsString>,
281 {
282 self.additional_args
283 .get_or_insert_with(Vec::new)
284 .push(arg.into());
285 self
286 }
287}
288
289impl Options {
290 #[must_use]
291 fn into_args(self) -> Vec<OsString> {
292 // Available arguments on bspc 1.2
293 // Switches:
294 // map2bsp <[pakfilefilter/]filefilter> = convert MAP to BSP
295 // map2aas <[pakfilefilter/]filefilter> = convert MAP to AAS
296 // bsp2map <[pakfilefilter/]filefilter> = convert BSP to MAP
297 // bsp2bsp <[pakfilefilter/]filefilter> = convert BSP to BSP
298 // bsp2aas <[pakfilefilter/]filefilter> = convert BSP to AAS
299 // output <output path> = set output path
300 // noverbose = disable verbose output
301 // threads = number of threads to use
302 // ... the remaining arguments depend on the version used
303 let mut args: Vec<OsString> = Vec::new();
304 if !self.verbose {
305 args.push("-noverbose".into());
306 }
307 if let Some(threads) = self.threads {
308 args.push("-threads".into());
309 args.push(threads.to_string().into());
310 }
311 args.extend(self.additional_args);
312 args
313 }
314}
315
316/// Full output of the child process, including the exit code, log, and any
317/// output files.
318///
319/// This also includes the command-line arguments that were passed, for
320/// diagnostic purposes.
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct Output {
323 /// The exit status of the child process.
324 pub exit: ExitStatus,
325 /// The exit code corresponding to the exit status, if one exists.
326 ///
327 /// See the docs on [`std::process::ExitStatus::code`].
328 pub exit_code: Option<i32>,
329 /// All output files that the child process produced.
330 pub files: Vec<OutputFile>,
331 /// The command-line arguments that were passed to the child process.
332 pub args: Vec<String>,
333 /// The log output of the child process, as a list of parsed log lines.
334 pub logs: Vec<LogLine>,
335}
336
337/// Error type returned by [`convert`].
338#[derive(Debug, thiserror::Error)]
339pub enum ConversionError {
340 /// The provided path to the BSPC executable does not exist or is not a
341 /// file.
342 #[error("provided path to bspc executable (\"{0}\") does not exist or is not a file: {0}")]
343 ExecutableNotFound(PathBuf),
344 /// Failed to create a temporary directory to store inputs/outputs.
345 #[error("failed to create a temporary directory to store inputs/outputs: {0}")]
346 TempDirectoryCreationFailed(#[source] IoError),
347 /// Failed to read/write to the temporary directory storing inputs/outputs.
348 #[error(
349 "failed to read/write to the temporary directory (at \"{1}\") storing inputs/outputs: {0}"
350 )]
351 TempDirectoryIo(#[source] IoError, PathBuf),
352 /// Failed to start the child BSPC process.
353 #[error("failed to start child \"bspc\" process: {0}")]
354 ProcessStartFailure(#[source] IoError),
355 /// Failed to wait for the child BSPC process to exit.
356 #[error("failed to wait for child \"bspc\" process to exit: {0}")]
357 ProcessWaitFailure(#[source] IoError),
358 /// The conversion process was cancelled via the cancellation token.
359 #[error("conversion was cancelled by the cancellation token")]
360 Cancelled,
361 /// The child BSPC process was provided an unknown argument.
362 #[error("child \"bspc\" process was provided unknown argument '{unknown_argument}': full argument list: {args:?}")]
363 UnknownArgument {
364 /// The offending argument.
365 unknown_argument: String,
366 /// All arguments passed to the child BSPC process.
367 args: Vec<String>,
368 },
369 /// The child BSPC process did find any input files when it ran.
370 ///
371 /// If a standard command was used, then this indicates that the temporary
372 /// file may have been deleted before BPSC ran.
373 #[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\"")]
374 NoInputFilesFound(Output),
375 /// The child BSPC process exited with a non-zero exit code.
376 #[error("child \"bspc\" process exited with a non-zero exit code {} (see logs)", .0.exit)]
377 ProcessExitFailure(Output),
378 /// The child BSPC process resulted in no output files.
379 #[error("child \"bspc\" process resulted in no output files (see logs)")]
380 NoOutputFiles(Output),
381}
382
383/// A single output file produced by the BSPC process.
384#[derive(Debug, Clone, PartialEq, Eq)]
385pub struct OutputFile {
386 pub name: String,
387 pub extension: Option<String>,
388 pub contents: Vec<u8>,
389}
390
391/// Runs the BSPC executable with the given arguments, converting a single file
392/// to a different format, returning the complete output of the process
393/// (all output files, logs, and exit code).
394///
395/// The future returned by this function should be polled to completion, in
396/// order to best ensure that the temporary directory is cleaned up after the
397/// child process exits.
398///
399/// To time-out the child process operation (or otherwise cancel it), pass a
400/// [`CancellationToken`] to the [`Options`] argument.
401///
402/// # Executable
403///
404/// The BSPC executable must already exist in the filesystem before calling
405/// this function.
406///
407/// # Errors
408///
409/// See the variants on the [`ConversionError`] enum for more information.
410#[allow(clippy::too_many_lines)]
411pub async fn convert(
412 executable_path: impl AsRef<Path> + Send,
413 cmd: Command<'_>,
414 mut options: Options,
415) -> Result<Output, ConversionError> {
416 let cancellation_token = options
417 .cancellation_token
418 .take()
419 .unwrap_or_else(CancellationToken::new);
420 let log_stream = options.log_stream.take();
421 let log_command = options.log_command;
422 let option_args = options.into_args();
423
424 // Check to make sure that the executable path exists and is a file,
425 // asynchronously.
426 let executable_path = executable_path.as_ref();
427 let executable_path = tokio::fs::canonicalize(executable_path)
428 .await
429 .map_err(|_| ConversionError::ExecutableNotFound(executable_path.to_owned()))?;
430 let executable_metadata = tokio::fs::metadata(&executable_path)
431 .await
432 .map_err(|_| ConversionError::ExecutableNotFound(executable_path.clone()))?;
433 if !executable_metadata.is_file() {
434 return Err(ConversionError::ExecutableNotFound(executable_path));
435 }
436
437 // Create a temporary directory to store the input and output files,
438 // and to run the executable in.
439 // This may invoke synchronous I/O, but it should be very fast.
440 let temp_dir = TempFileBuilder::new()
441 .prefix("bspc-rs")
442 .tempdir()
443 .map_err(ConversionError::TempDirectoryCreationFailed)?;
444
445 // Create the output subdirectory.
446 let output_directory_path = temp_dir.path().join("output");
447 tokio::fs::create_dir(&output_directory_path)
448 .await
449 .map_err(|e| ConversionError::TempDirectoryIo(e, output_directory_path.clone()))?;
450
451 let mut args: Vec<OsString> = Vec::new();
452 let command_args = cmd.try_into_args(&temp_dir).await?;
453 args.extend(command_args);
454 args.push("-output".into());
455 args.push(output_directory_path.as_os_str().to_owned());
456 args.extend(option_args);
457
458 let debug_args: Vec<String> = args
459 .iter()
460 .map(|arg| arg.to_string_lossy().into_owned())
461 .collect::<Vec<_>>();
462
463 let mut command = StdCommand::new(executable_path);
464 command
465 .env_clear()
466 // Use the temporary directory as the working directory, since BSPC
467 // also writes a log file to the working directory.
468 .current_dir(temp_dir.path())
469 .stdin(Stdio::null())
470 // BSPC writes all logs to stdout
471 .stdout(Stdio::piped())
472 .stderr(Stdio::null())
473 .args(args);
474
475 #[cfg(windows)]
476 {
477 use std::os::windows::process::CommandExt as _;
478 use windows::Win32::System::Threading::CREATE_NO_WINDOW;
479
480 // On Windows, add the CREATE_NO_WINDOW flag to the process creation
481 // flags, so that the child process does not create a console window.
482 command.creation_flags(CREATE_NO_WINDOW.0);
483 }
484
485 let initial_log_lines: Vec<LogLine> = {
486 let mut initial_log_lines: Vec<LogLine> = Vec::new();
487 if log_command {
488 let command_log_line =
489 LogLine::Info(format!("> bspc {}", pretty_format_args(&debug_args)));
490 if let Some(log_stream) = &log_stream {
491 let _send_err = log_stream.send(command_log_line.clone()).await;
492 }
493 initial_log_lines.push(command_log_line);
494 }
495 initial_log_lines
496 };
497
498 // Spawn the child process
499 let mut child = TokioCommand::from(command)
500 .spawn()
501 .map_err(ConversionError::ProcessStartFailure)?;
502
503 let wait_with_output_future = async {
504 let mut stdout_pipe = child
505 .stdout
506 .take()
507 .expect("child should have a piped stdout stream");
508
509 let wait_future = async {
510 child
511 .wait()
512 .await
513 .map_err(ConversionError::ProcessWaitFailure)
514 };
515 let consume_log_future = async {
516 crate::logs::collect_logs(&mut stdout_pipe, log_stream)
517 .await
518 .map_err(ConversionError::ProcessWaitFailure)
519 };
520
521 let (exit, logs) = tokio::try_join!(wait_future, consume_log_future)?;
522 let logs = {
523 // Prepend initial_log_lines
524 let mut constructed_logs = Vec::with_capacity(initial_log_lines.len() + logs.len());
525 constructed_logs.extend(initial_log_lines);
526 constructed_logs.extend(logs);
527 constructed_logs
528 };
529
530 // Drop the pipe after `try_join` to mirror Tokio's implementation of
531 // `Child::wait_with_output`:
532 // https://github.com/tokio-rs/tokio/blob/d65826236b9/tokio/src/process/mod.rs#L1224-L1234
533 drop(stdout_pipe);
534
535 Ok((exit, logs))
536 };
537
538 let (exit_status, log_lines): (ExitStatus, Vec<LogLine>) = {
539 #[allow(clippy::redundant_pub_crate)]
540 let cancellation_result: Result<
541 Result<(ExitStatus, Vec<LogLine>), ConversionError>,
542 (),
543 > = tokio::select! {
544 result = wait_with_output_future => Ok(result),
545 _ = cancellation_token.cancelled() => Err(()),
546 };
547 match cancellation_result {
548 Ok(Ok((exit, log_lines))) => (exit, log_lines),
549 Ok(Err(err)) => {
550 // Try to ensure the child process is killed before returning
551 let _err = child.kill().await;
552 return Err(err);
553 }
554 Err(_) => {
555 // The cancellation token was cancelled, so we should kill the child
556 // process.
557 let _err = child.kill().await;
558 return Err(ConversionError::Cancelled);
559 }
560 }
561 };
562
563 let mut no_files_found: bool = false;
564 let mut unknown_argument: Option<UnknownArgumentLine> = None;
565 for line in &log_lines {
566 match line {
567 LogLine::UnknownArgument(unknown_argument_line) => {
568 unknown_argument = Some(unknown_argument_line.clone());
569 }
570 LogLine::NoFilesFound(_) => {
571 no_files_found = true;
572 }
573 _ => {}
574 }
575 }
576
577 // If there was an unknown argument, return an error immediately without
578 // bothering to read in the output files or return the logs/exit code.
579 if let Some(line) = unknown_argument {
580 return Err(ConversionError::UnknownArgument {
581 unknown_argument: line.argument,
582 args: debug_args,
583 });
584 }
585
586 // Read in all files in the output directory
587 let mut output_files: Vec<OutputFile> = Vec::new();
588 let mut read_dir = tokio::fs::read_dir(&output_directory_path)
589 .await
590 .map_err(|err| ConversionError::TempDirectoryIo(err, output_directory_path.clone()))?;
591 while let Some(entry) = read_dir
592 .next_entry()
593 .await
594 .map_err(|err| ConversionError::TempDirectoryIo(err, output_directory_path.clone()))?
595 {
596 let file_name = entry.file_name().to_string_lossy().into_owned();
597 let file_path = entry.path();
598 let file_extension = file_path
599 .extension()
600 .map(|ext| ext.to_string_lossy().into_owned());
601
602 let file_contents = tokio::fs::read(&file_path)
603 .await
604 .map_err(|err| ConversionError::TempDirectoryIo(err, file_path))?;
605 output_files.push(OutputFile {
606 name: file_name,
607 extension: file_extension,
608 contents: file_contents,
609 });
610 }
611
612 let output = Output {
613 exit_code: exit_status.code(),
614 exit: exit_status,
615 files: output_files,
616 args: debug_args,
617 logs: log_lines,
618 };
619
620 if no_files_found {
621 return Err(ConversionError::NoInputFilesFound(output));
622 }
623
624 if !output.exit.success() {
625 return Err(ConversionError::ProcessExitFailure(output));
626 }
627
628 if output.files.is_empty() {
629 return Err(ConversionError::NoOutputFiles(output));
630 }
631
632 Ok(output)
633}
634
635/// Performs best-effort "pretty-printing" of a sequence of arguments,
636/// attempting to produce an output where the placement of any special
637/// (i.e. whitespace, quote, control) characters can be easily discerned.
638fn pretty_format_args<I, S>(args: I) -> String
639where
640 I: IntoIterator<Item = S>,
641 S: AsRef<str>,
642{
643 args.into_iter()
644 .map(|a| {
645 let a = a.as_ref();
646 if a.contains(char::is_whitespace) || a.contains(char::is_control) || a.contains('"') {
647 // This will escape any quotes/control characters in the string,
648 // and wrap the string in quotes (in the style of Rust source
649 // code):
650 format!("{:?}", a)
651 } else {
652 a.to_owned()
653 }
654 })
655 .collect::<Vec<_>>()
656 .join(" ")
657}