panic 0.3.1

Humanized panic message wrapper
Documentation
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![cfg_attr(feature = "nightly", deny(missing_docs))]
#![cfg_attr(feature = "nightly", feature(panic_info_message))]

#[cfg(feature = "color")]
pub use anstyle::AnsiColor as Color;
pub mod report;

use report::{Method, Report};
use text_placeholder::Template;

use std::{
    borrow::Cow,
    collections::HashMap,
    io::Result as IoResult,
    panic::PanicInfo,
    path::{Path, PathBuf},
};

struct Writer<'w> {
    meta: &'w Metadata,
    table: HashMap<&'w str, &'w str>,
    pub(crate) buffer: &'w mut dyn std::io::Write,
}

pub struct Metadata {
    pub messages: Messages,
    pub name: Cow<'static, str>,
    pub short_name: Cow<'static, str>,
    pub version: Cow<'static, str>,
    pub repository: Cow<'static, str>,
}

#[derive(Clone)]
pub struct Messages {
    pub head: (Option<Cow<'static, str>>, Color),
    pub body: (Option<Cow<'static, str>>, Color),
    pub footer: (Option<Cow<'static, str>>, Color),
}

#[macro_export]
macro_rules! setup_panic {
    (@field_arm colors $value:expr, $meta:expr) => {
        $meta.messages.head.1 = $value.0;
        $meta.messages.body.1 = $value.1;
        $meta.messages.footer.1 = $value.2;
    };
    (@field_arm $field:ident $value:expr, $meta:expr) => {
        let value: Cow<'static, str> = $value.into();
        $meta.messages.$field.0 = Some(value).filter(|val| !val.is_empty());
    };
    (
        $(name: $name:expr,)?
        $(short_name: $short_name:expr,)?
        $(version: $version:expr,)?
        $(repository: $repository:expr,)?
        $(messages: {
            $(colors: $colors:expr,)?
            $(head: $head:expr,)?
            $(body: $body:expr,)?
            $(footer: $footer:expr)?
        })?
    ) => {
        #[allow(unused_imports)]
        use std::{borrow::Cow, panic::{self, PanicInfo}};
        #[allow(unused_imports)]
        use $crate::{handle_dump, print_msg, Color, Messages, Metadata};

        let mut msg = Messages {
            head: (None, Color::Red),
            body: (None, Color::White),
            footer: (None, Color::Green),
        };

        let mut meta = Metadata {
            messages: msg,
            name: env!("CARGO_PKG_NAME").into(),
            short_name: env!("CARGO_PKG_NAME").into(),
            version: env!("CARGO_PKG_VERSION").into(),
            repository: env!("CARGO_PKG_REPOSITORY").into(),
        };

        $(meta.name=$name.into();)?
        $(meta.short_name=$short_name.into();)?
        $(meta.version=$version.into();)?
        $(meta.repository=$repository.into();)?

        $(
            $($crate::setup_panic!(@field_arm head $head, meta);)?
            $($crate::setup_panic!(@field_arm body $body, meta);)?
            $($crate::setup_panic!(@field_arm footer $footer, meta);)?
            $($crate::setup_panic!(@field_arm colors $colors, meta);)?
        )?

        match $crate::PanicStyle::default() {
            $crate::PanicStyle::Debug => {}
            $crate::PanicStyle::Human => {
                panic::set_hook(Box::new(move |info: &PanicInfo| {
                    let file_path = handle_dump(&meta, info);
                    print_msg(file_path, &meta).expect("human-panic: printing error message to console failed");
                }));
            }
        }
    };
}

#[derive(Copy, Clone, PartialEq, Eq)]
pub enum PanicStyle {
    Debug,
    Human,
}

#[cfg(feature = "only-release")]
impl Default for PanicStyle {
    fn default() -> Self {
        if cfg!(debug_assertions) {
            PanicStyle::Debug
        } else {
            match ::std::env::var("RUST_BACKTRACE") {
                Ok(_) => PanicStyle::Debug,
                Err(_) => PanicStyle::Human,
            }
        }
    }
}

#[cfg(not(feature = "only-release"))]
impl Default for PanicStyle {
    fn default() -> Self {
        match ::std::env::var("RUST_BACKTRACE") {
            Ok(_) => PanicStyle::Debug,
            Err(_) => PanicStyle::Human,
        }
    }
}

#[cfg(feature = "color")]
pub fn print_msg<P: AsRef<Path>>(file_path: Option<P>, meta: &Metadata) -> IoResult<()> {
    let stderr = anstream::stderr();
    let mut stderr = stderr.lock();
    let mut writer = Writer::new(&mut stderr, file_path, meta);

    write!(writer.buffer, "{}", meta.messages.head.1.render_fg())?;
    writer.head()?;
    write!(writer.buffer, "{}", anstyle::Reset.render())?;

    write!(writer.buffer, "{}", meta.messages.body.1.render_fg())?;
    writer.body()?;
    write!(writer.buffer, "{}", anstyle::Reset.render())?;

    write!(writer.buffer, "{}", meta.messages.footer.1.render_fg())?;
    writer.footer()?;
    write!(writer.buffer, "{}", anstyle::Reset.render())?;

    Ok(())
}

#[cfg(not(feature = "color"))]
pub fn print_msg<P: AsRef<Path>>(file_path: Option<P>, meta: &Metadata) -> IoResult<()> {
    let stderr = std::io::stderr();
    let mut stderr = stderr.lock();
    let mut writer = Writer::new(&mut stderr, file_path, meta);

    writer.head()?;
    writer.body()?;
    writer.footer()?;

    Ok(())
}

impl<'w> Writer<'w> {
    fn new<P: AsRef<Path>>(buffer: &'w mut impl std::io::Write, file_path: Option<P>, meta: &'w Metadata) -> Self {
        let mut table = HashMap::new();

        let file_path = match file_path {
            Some(fp) => format!("{}", fp.as_ref().display()),
            None => "<Failed to store file to disk>".to_string(),
        };

        table.insert("name", meta.name.as_ref());
        table.insert("version", meta.version.as_ref());
        table.insert("short_name", meta.short_name.as_ref());
        table.insert("repository", meta.repository.as_ref());
        table.insert("file_path", Box::leak(Box::new(file_path)));

        Self { buffer, meta, table }
    }

    fn head(&mut self) -> IoResult<()> {
        let (name, version) = (&self.meta.name, &self.meta.version);

        if let Some(head) = &self.meta.messages.head.0 {
            let tmpl = Template::new_with_placeholder(&head, "%(", ")");
            writeln!(self.buffer, "{}", tmpl.fill_with_hashmap(&self.table))?;
        } else {
            writeln!(self.buffer, "Well, this is embarrassing.")?;
            writeln!(
                self.buffer,
                "{name} v{version} had a problem and crashed. To help us diagnose the \
            problem you can send us a crash report.\n"
            )?;
        }

        Ok(())
    }

    fn body(&mut self) -> IoResult<()> {
        let (short_name, version, repository) = (&self.meta.short_name, &self.meta.version, &self.meta.repository);

        if let Some(body) = &self.meta.messages.body.0 {
            let tmpl = Template::new_with_placeholder(&body, "%(", ")");
            writeln!(self.buffer, "{}", tmpl.fill_with_hashmap(&self.table))?;
        } else {
            writeln!(
                self.buffer,
                "We have generated a report file at \"{}\". Submit an \
          issue or email with the subject of \"{short_name} v{version} crash report\" and include the \
          report as an attachment at {repository}/issues.",
                self.table.get("file_path").unwrap(),
            )?;
        }

        Ok(())
    }

    fn footer(&mut self) -> IoResult<()> {
        if let Some(footer) = &self.meta.messages.footer.0 {
            let tmpl = Template::new_with_placeholder(&footer, "%(", ")");
            writeln!(self.buffer, "{}", tmpl.fill_with_hashmap(&self.table))?;
        } else {
            writeln!(
                self.buffer,
                "\nWe take privacy seriously, and do not perform any \
          automated error collection. In order to improve the software, we rely on \
          people to submit reports.\nThank you!"
            )?;
        }
        Ok(())
    }
}

pub fn handle_dump(meta: &Metadata, panic_info: &PanicInfo) -> Option<PathBuf> {
    let mut expl = String::new();

    #[cfg(feature = "nightly")]
    let message = panic_info.message().map(|m| format!("{}", m));

    #[cfg(not(feature = "nightly"))]
    let message = match (panic_info.payload().downcast_ref::<&str>(), panic_info.payload().downcast_ref::<String>()) {
        (Some(s), _) => Some(s.to_string()),
        (_, Some(s)) => Some(s.to_string()),
        (None, None) => None,
    };

    let cause = match message {
        Some(m) => m,
        None => "Unknown".into(),
    };

    match panic_info.location() {
        Some(location) => expl.push_str(&format!("Panic occurred in file '{}' at line {}\n", location.file(), location.line())),
        None => expl.push_str("Panic location unknown.\n"),
    }

    let report = Report::new(&meta.short_name, &meta.version, Method::Panic, expl, cause);

    match report.persist() {
        Ok(f) => Some(f),
        Err(_) => {
            eprintln!("{}", report.serialize().unwrap());
            None
        }
    }
}