df_st_core 0.3.0-development-2

Core structures for the DF Storyteller project.
Documentation
use backtrace::Backtrace;
use colored::*;
#[allow(unused_imports)]
use log::{debug, error, info, trace, warn};
use serde::{Deserialize, Serialize};
use url::{form_urlencoded, Url};

pub fn link_to_issue_page() -> String {
    "https://gitlab.com/df_storyteller/df-storyteller/issues".to_owned()
}

pub fn link_to_issue_nr(issue_nr: u32) -> String {
    format!(
        "https://gitlab.com/df_storyteller/df-storyteller/issues/{}",
        issue_nr
    )
}

pub fn link_to_new_issue() -> String {
    "https://gitlab.com/df_storyteller/df-storyteller/issues/new".to_owned()
}

pub fn link_to_issue_template(template_name: &str) -> String {
    format!(
        "https://gitlab.com/df_storyteller/df-storyteller/issues/new?issuable_template={}",
        template_name
    )
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GitIssue<T> {
    pub title: String,
    pub message: String,
    pub labels: Vec<String>,
    pub debug_info_string: Option<String>,
    pub debug_info_json: Option<T>,
    pub add_steps: bool,
    pub ask_add_files: bool,
    pub include_backtrace: bool,
}

impl<T: Serialize + Clone> GitIssue<T> {
    pub fn new() -> Self {
        Self::default()
    }

    fn get_labels(&self) -> String {
        let mut labels_string = "/label ~\"Auto Generated Issue\"".to_owned();
        for label in &self.labels {
            labels_string = format!("{} ~\"{}\"", labels_string, label);
        }
        labels_string
    }

    fn get_debug_message(&self) -> String {
        let mut message = String::new();
        if let Some(debug_info) = &self.debug_info_string {
            message = debug_info.clone();
        }
        if let Some(debug_info) = &self.debug_info_json {
            let newline = if message.is_empty() {
                "".to_owned()
            } else {
                "\n".to_owned()
            };
            message = format!(
                "{}{}```json\n{}\n```",
                message,
                newline,
                serde_json::to_string_pretty(&debug_info).unwrap(),
            );
        }
        if message.is_empty() {
            message = "Please describe what happened and what you wanted to do.".to_owned();
        }
        message
    }

    pub fn create_message(&self) -> String {
        let mut url = self.new_issue_link();
        let mut url_string = url.as_str();
        // Add some margin
        let prefix_len = "https://gitlab.com".len() - 3;
        // Note: URLs longer then 2048 characters (not including the "https://gitlab.com").
        // Might/will not work on some browsers (Firefox and Gitlab itself gave errors.).
        // If this happens disable some parts of the issue te make it shorter.
        // For more info see: https://stackoverflow.com/a/417184/2037998
        let mut self_clone = (*self).clone();
        if url_string.len() > 2048 + prefix_len {
            warn!("URL shortened because of URL Limit.");
            if self_clone.add_steps {
                warn!("URL shortened: Please add Steps to get issue back in.");
            }
            self_clone.add_steps = false;
            if self_clone.ask_add_files {
                warn!("URL shortened: Please add a link to a zip with your legend files to the issue.");
            }
            self_clone.ask_add_files = false;
            url = self_clone.new_issue_link();
            url_string = url.as_str();
        }
        // Still to big?
        if url_string.len() > 2048 + prefix_len {
            warn!("URL shortened, removing backtrace.");
            self_clone.include_backtrace = false;
            url = self_clone.new_issue_link();
            url_string = url.as_str();
        }
        // This is really long...
        if url_string.len() > 2048 + prefix_len {
            warn!("URL shortened, removing debug info.");
            if self_clone.debug_info_json.is_some() {
                warn!("URL shortened: Please include warnings on screen to the issue.");
            }
            self_clone.debug_info_json = None;
            url = self_clone.new_issue_link();
            url_string = url.as_str();
        }

        let message = format!(
            "------------Report this issue------------\n  \
            Please report this issue. It take only a min. (GitLab account required)\n  \
            (Copy this link, CTRL+Click or Right Click the link to open)\n  \
            Check if already reported: {}\n  \
            You can review/add/remove data before submitting after opening the link.\n\
            Link🔗: {}\n\
            ------------------------------------------\n",
            self.search_if_issue_exists().dimmed().bright_cyan(),
            url_string.dimmed().bright_cyan()
        );

        message
    }

    fn search_if_issue_exists(&self) -> String {
        let encoded: String = form_urlencoded::Serializer::new(String::new())
            .append_pair("search", &self.title)
            .finish();
        format!(
            "https://gitlab.com/df_storyteller/df-storyteller/issues?scope=all&state=all&{}",
            encoded
        )
    }

    fn new_issue_link(&self) -> Url {
        let add_files = if self.ask_add_files {
            "<!-- Please include the legends files, you can upload a '.zip' archive somewhere.\n\
            For example Google Drive, Microsoft OneDrive, DropBox, pCloud, ... -->\n\
            * Link to legends: ..add link..\n\
            * DF Version: \n\
            * DFHack Version: \n\n"
        } else {
            ""
        };
        let reproduce = if self.add_steps {
            "## Reproduce:\n\
            Steps to recreate this issue:\n\
            1. ...\n\
            2. ...\n\n"
        } else {
            ""
        };
        let backtrace = if self.include_backtrace {
            format!(
                "### Backtrace:\n\
                <details><summary markdown=\"span\">Backtrace</summary>\n\n\
                ```\n\
                {}\n\
                ```\n\n\
                </details>\n\n",
                print_backtrace()
            )
        } else {
            "".to_owned()
        };
        let description = format!(
            "<!-- Please check above if there are issues with the same title.\n\
            Someone else might have already reported this.-->\n\
            ## Summary:\n\
            {message}\n\n\
            {add_files}\
            {reproduce}\
            ## Debug info:\n\
            {debug_message}\n\n\
            {backtrace}\
            ### System:\n\
            * DF Storyteller version: {version}\n\
            * System architecture: {arch}\n\
            * System OS: {os}\n\
            * Database: SQLite/Postgres\n\n\
            <!-- Leave the information below untouched! -->\n\
            {labels}",
            message = self.message,
            reproduce = reproduce,
            add_files = add_files,
            backtrace = backtrace,
            labels = self.get_labels(),
            debug_message = self.get_debug_message(),
            version = env!("CARGO_PKG_VERSION"),
            arch = std::env::consts::ARCH,
            os = std::env::consts::OS,
        );

        let encoded: String = form_urlencoded::Serializer::new(String::new())
            .append_pair("issue[title]", &self.title)
            .append_pair("issue[description]", &description)
            .finish();

        Url::parse(&format!(
            "https://gitlab.com/df_storyteller/df-storyteller/issues/new?{}",
            encoded
        ))
        .unwrap()
    }
}

impl<T: Serialize> Default for GitIssue<T> {
    fn default() -> Self {
        Self {
            title: String::new(),
            message: String::new(),
            labels: Vec::new(),
            debug_info_string: None,
            debug_info_json: None,
            add_steps: true,
            ask_add_files: false,
            include_backtrace: false,
        }
    }
}

/// Displays a shortened backtrace to see where the call came from.
pub fn print_backtrace() -> String {
    let bt = Backtrace::new();
    let frames = bt.frames();
    let mut backtrace_string = String::new();
    let max_line = 11;
    for (frame, line) in frames.iter().zip(0..max_line) {
        let symbol = frame.symbols().get(0).unwrap();
        let mut name = String::new();
        if let Some(name_value) = &symbol.name() {
            let full_name = name_value.to_string();
            let parts: Vec<&str> = full_name.split("::").collect();
            if parts.len() >= 3 {
                name = format!(
                    "{}::{}",
                    parts.get(parts.len() - 3).unwrap_or(&""),
                    parts.get(parts.len() - 2).unwrap_or(&""),
                );
            }
        }
        // Will not be set on some systems.
        // see: https://docs.rs/backtrace/latest/backtrace/struct.Symbol.html
        let mut filepath = String::new();
        if let Some(filename) = &symbol.filename() {
            let line_nr = &symbol.lineno().unwrap_or_default();
            let path = filename.to_str().unwrap();
            let (_, path) = path.split_at(path.find("df_st").unwrap_or(0));
            if !path.starts_with("/rustc/") {
                filepath = format!(" => {}:{}", path, line_nr);
            }
        }
        if line != 0 {
            backtrace_string = format!("{}{}: {}{}\n", backtrace_string, line, name, filepath);
        }
    }
    backtrace_string
}