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}