mdxbook 0.4.25

Fork of mdBook, with more customizations and flexibility for programmers
Documentation
use super::{Postprocessor, PostprocessorContext, RenderedBook};
use crate::errors::*;
use crate::postprocess::{Rendered, RenderedDocumentOfBook};
use log::{debug, trace, warn};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use shlex::Shlex;
use std::io::{self, Read, Write};
use std::process::{Child, Command, Stdio};

/// A custom postprocessor which will shell out to a 3rd-party program.
///
/// # Preprocessing Protocol
///
/// When the `supports_renderer()` method is executed, `CmdPreprocessor` will
/// execute the shell command `$cmd supports $renderer`. If the renderer is
/// supported, custom postprocessors should exit with a exit code of `0`,
/// any other exit code be considered as unsupported.
///
/// The `run()` method is implemented by passing a `(PreprocessorContext, Book)`
/// tuple to the spawned command (`$cmd`) as JSON via `stdin`. Preprocessors
/// should then "return" a processed book by printing it to `stdout` as JSON.
/// For convenience, the `CmdPreprocessor::parse_input()` function can be used
/// to parse the input provided by `mdbook`.
///
/// Exiting with a non-zero exit code while preprocessing is considered an
/// error. `stderr` is passed directly through to the user, so it can be used
/// for logging or emitting warnings if desired.
///
/// # Examples
///
/// An example postprocessor is available in this project's `examples/`
/// directory.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CmdPostprocessor {
    /// The name of the post processor.
    pub name: String,
    /// The command to execute.
    pub cmd: String,
}

impl CmdPostprocessor {
    /// Create a new `CmdPreprocessor`.
    pub fn new(name: String, cmd: String) -> CmdPostprocessor {
        CmdPostprocessor { name, cmd }
    }

    /// A convenience function custom postprocessors can use to parse the input
    /// written to `stdin` by a `CmdRenderer`.
    pub fn parse_input<R: Read>(reader: R) -> Result<(PostprocessorContext, Rendered)> {
        serde_json::from_reader(reader).with_context(|| "Unable to parse the input")
    }

    fn write_input_to_child(&self, child: &mut Child, book: &Rendered, ctx: &PostprocessorContext) {
        let stdin = child.stdin.take().expect("Child has stdin");

        if let Err(e) = self.write_input(stdin, book, ctx) {
            // Looks like the backend hung up before we could finish
            // sending it the render context. Log the error and keep going
            warn!("Error writing the RenderContext to the backend, {}", e);
        }
    }

    fn write_input<W: Write>(
        &self,
        writer: W,
        book: &Rendered,
        ctx: &PostprocessorContext,
    ) -> Result<()> {
        serde_json::to_writer(writer, &(ctx, book)).map_err(Into::into)
    }

    /// The command this `Preprocessor` will invoke.
    pub fn cmd(&self) -> &str {
        &self.cmd
    }

    fn command(&self) -> Result<Command> {
        let mut words = Shlex::new(&self.cmd);
        let executable = match words.next() {
            Some(e) => e,
            None => bail!("Command string was empty"),
        };

        let mut cmd = Command::new(executable);

        for arg in words {
            cmd.arg(arg);
        }

        Ok(cmd)
    }

    fn process_document<T: DeserializeOwned>(
        &self,
        ctx: &PostprocessorContext,
        rendered_book: Rendered,
    ) -> Result<T> {
        let mut cmd = self.command()?;

        let mut child = cmd
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::inherit())
            .spawn()
            .with_context(|| {
                format!(
                    "Unable to start the \"{}\" postprocessor. Is it installed?",
                    self.name()
                )
            })?;

        self.write_input_to_child(&mut child, &rendered_book, &ctx);

        let output = child.wait_with_output().with_context(|| {
            format!(
                "Error waiting for the \"{}\" postprocessor to complete",
                self.name
            )
        })?;

        trace!("{} exited with output: {:?}", self.cmd, output);
        ensure!(
            output.status.success(),
            format!(
                "The \"{}\" postprocessor exited unsuccessfully with {} status",
                self.name, output.status
            )
        );

        serde_json::from_slice(&output.stdout).with_context(|| {
            format!(
                "Unable to parse the preprocessed book from \"{}\" processor",
                self.name
            )
        })
    }
}

impl Postprocessor for CmdPostprocessor {
    fn name(&self) -> &str {
        &self.name
    }

    fn postprocess_document(
        &self,
        ctx: &PostprocessorContext,
        rendered_book: RenderedDocumentOfBook,
    ) -> Result<RenderedDocumentOfBook> {
        self.process_document(ctx, Rendered::Document(rendered_book))
    }

    fn postprocess_book(
        &self,
        ctx: &PostprocessorContext,
        rendered_book: RenderedBook,
    ) -> std::result::Result<RenderedBook, anyhow::Error> {
        self.process_document(ctx, Rendered::Book(rendered_book))
    }

    fn supports_renderer(&self, renderer: &str) -> bool {
        debug!(
            "Checking if the \"{}\" postprocessor supports \"{}\"",
            self.name(),
            renderer
        );

        let mut cmd = match self.command() {
            Ok(c) => c,
            Err(e) => {
                warn!(
                    "Unable to create the command for the \"{}\" postprocessor, {}",
                    self.name(),
                    e
                );
                return false;
            }
        };

        let outcome = cmd
            .arg("supports")
            .arg(renderer)
            .stdin(Stdio::null())
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit())
            .status()
            .map(|status| status.code() == Some(0));

        if let Err(ref e) = outcome {
            if e.kind() == io::ErrorKind::NotFound {
                warn!(
                    "The command wasn't found, is the \"{}\" postprocessor installed?",
                    self.name
                );
                warn!("\tCommand: {}", self.cmd);
            }
        }

        outcome.unwrap_or(false)
    }

    fn clone_dyn(&self) -> Box<dyn Postprocessor> {
        Box::new(self.clone())
    }

    fn to_cmd_postprocessor(&self) -> Option<CmdPostprocessor> {
        Some(self.clone())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::postprocess::{Document, PostprocessorContext, RenderedDocument};
    use crate::renderer::RenderContext;
    use crate::{BookItem, MDBook};
    use std::path::Path;

    fn guide() -> (MDBook, RenderedBook) {
        let example = Path::new(env!("CARGO_MANIFEST_DIR")).join("guide");
        let book = MDBook::load(example).unwrap();

        let fake_rendered_sections = book
            .book
            .sections
            .iter()
            .map(|s| match s {
                BookItem::Chapter(c) => RenderedDocument {
                    document: Document::Chapter(c.clone()),
                    renderer: "fake".to_string(),
                    rendered_content: "foo bah".to_string(),
                },
                BookItem::Separator => RenderedDocument {
                    document: Document::Separator,
                    renderer: "fake".to_string(),
                    rendered_content: "foo bah".to_string(),
                },
                BookItem::PartTitle(t) => RenderedDocument {
                    document: Document::Page(t.clone()),
                    renderer: "fake".to_string(),
                    rendered_content: "foo bah".to_string(),
                },
            })
            .collect::<Vec<RenderedDocument>>();

        let fake_rendered = RenderedBook {
            book: book.book.clone(),
            rendered_documents: fake_rendered_sections,
        };

        (book, fake_rendered)
    }

    #[test]
    fn round_trip_write_and_parse_input() {
        let cmd = CmdPostprocessor::new("test".to_string(), "test".to_string());
        let (md, mut rendered) = guide();
        let ctx = PostprocessorContext::new(
            md.root.clone(),
            md.config.clone(),
            "some-renderer".to_string(),
            RenderContext::new(
                md.root.clone(),
                md.book.clone(),
                md.config.clone(),
                "random",
                vec![],
            ),
        );

        let doc = rendered.rendered_documents.pop().unwrap();
        let book = RenderedDocumentOfBook {
            book: md.book.clone(),
            page: doc,
        };

        let mut buffer = Vec::new();
        cmd.write_input(&mut buffer, &Rendered::Document(book.clone()), &ctx)
            .unwrap();

        let (got_ctx, got_book) = CmdPostprocessor::parse_input(buffer.as_slice()).unwrap();

        assert!(matches!(got_book, Rendered::Document(ref got_book) if got_book == &book));
        assert!(matches!(got_book, Rendered::Document(ref got_book) if got_book.book == book.book));
        assert_eq!(got_ctx, ctx);
    }
}