Skip to main content

blue_build/commands/
bug_report.rs

1use blue_build_recipe::Recipe;
2use blue_build_template::{GithubIssueTemplate, Template};
3use blue_build_utils::{
4    constants::{
5        BUG_REPORT_WARNING_MESSAGE, GITHUB_CHAR_LIMIT, LC_TERMINAL, LC_TERMINAL_VERSION,
6        TERM_PROGRAM, TERM_PROGRAM_VERSION, UNKNOWN_SHELL, UNKNOWN_TERMINAL, UNKNOWN_VERSION,
7    },
8    get_env_var,
9};
10use bon::Builder;
11use clap::Args;
12use clap_complete::Shell;
13use colored::Colorize;
14use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
15use log::{debug, error, trace};
16use miette::{IntoDiagnostic, Result};
17use requestty::question::{Completions, completions};
18use std::time::Duration;
19
20use super::BlueBuildCommand;
21
22use crate::shadow;
23
24#[derive(Default, Debug, Clone, Builder, Args)]
25pub struct BugReportRecipe {
26    recipe_dir: Option<String>,
27    recipe_path: Option<String>,
28}
29
30#[derive(Debug, Clone, Args, Builder)]
31pub struct BugReportCommand {
32    /// Path to the recipe file
33    #[arg(short, long)]
34    recipe_path: Option<String>,
35}
36
37impl BlueBuildCommand for BugReportCommand {
38    fn try_run(&mut self) -> Result<()> {
39        debug!("Generating bug report for hash: {}\n", shadow::COMMIT_HASH);
40        debug!("Shadow Versioning:\n{}", shadow::VERSION.trim());
41
42        self.create_bugreport()
43    }
44}
45
46impl BugReportCommand {
47    /// Create a pre-populated GitHub issue with information about your configuration
48    ///
49    /// # Errors
50    ///
51    /// This function will return an error if it fails to open the issue in your browser.
52    /// If this happens, you can copy the generated report and open an issue manually.
53    ///
54    /// # Panics
55    ///
56    /// This function will panic if it fails to get the current shell or terminal version.
57    pub fn create_bugreport(&self) -> Result<()> {
58        let os_info = os_info::get();
59        let recipe = self.get_recipe();
60
61        let environment = Environment {
62            os_type: os_info.os_type(),
63            shell_info: get_shell_info(),
64            terminal_info: get_terminal_info(),
65            os_version: os_info.version().clone(),
66        };
67
68        let issue_body = match generate_github_issue(&environment, recipe.as_ref()) {
69            Ok(body) => body,
70            Err(e) => {
71                println!("{}: {e}", "Failed to generate bug report".bright_red());
72                return Err(e);
73            }
74        };
75
76        println!(
77            "\n{}\n{}\n",
78            "Generated bug report:".bright_green(),
79            issue_body.on_bright_black().bright_white()
80        );
81
82        let question = requestty::Question::confirm("anonymous")
83            .message(
84                "Forward the pre-filled report above to GitHub in your browser?"
85                    .bright_yellow()
86                    .to_string(),
87            )
88            .default(true)
89            .build();
90
91        println!(
92            "{} To avoid any sensitive data from being exposed, please review the included information before proceeding.",
93            "Warning:".on_bright_red().bright_white()
94        );
95        println!(
96            "Data forwarded to GitHub is subject to GitHub's privacy policy. For more information, see https://docs.github.com/en/github/site-policy/github-privacy-statement.\n"
97        );
98        match requestty::prompt_one(question) {
99            Ok(answer) => {
100                if answer.as_bool().unwrap() {
101                    let link = make_github_issue_link(&issue_body);
102                    if let Err(e) = open::that(&link) {
103                        println!("Failed to open issue report in your browser: {e}");
104                        println!(
105                            "Please copy the above report and open an issue manually, or try opening the following link:\n{link}"
106                        );
107                        return Err(e).into_diagnostic();
108                    }
109                } else {
110                    println!("{BUG_REPORT_WARNING_MESSAGE}");
111                }
112            }
113            Err(_) => {
114                println!("Will not open an issue in your browser! {BUG_REPORT_WARNING_MESSAGE}");
115            }
116        }
117
118        println!(
119            "\n{}",
120            "Thanks for using the BlueBuild bug report tool!".bright_cyan()
121        );
122
123        Ok(())
124    }
125
126    fn get_recipe(&self) -> Option<Recipe> {
127        let recipe_path = self.recipe_path.clone().unwrap_or_else(|| {
128            get_config_file("recipe", "Enter path to recipe file").unwrap_or_else(|_| {
129                trace!("Failed to get recipe");
130                String::new()
131            })
132        });
133
134        Recipe::parse(&recipe_path).ok()
135    }
136}
137
138fn get_config_file(title: &str, message: &str) -> Result<String> {
139    use std::path::Path;
140
141    let question = requestty::Question::input(title)
142        .message(message)
143        .auto_complete(|p, _| auto_complete(p))
144        .validate(|p, _| {
145            if (p.as_ref() as &Path).exists() {
146                Ok(())
147            } else if p.is_empty() {
148                Err("No file specified. Please enter a file path".to_string())
149            } else {
150                Err(format!("file `{p}` doesn't exist"))
151            }
152        })
153        .build();
154
155    match requestty::prompt_one(question) {
156        Ok(requestty::Answer::String(path)) => Ok(path),
157        Ok(_) => unreachable!(),
158        Err(e) => {
159            trace!("Failed to get file: {e}");
160            Err(e).into_diagnostic()
161        }
162    }
163}
164
165fn auto_complete(p: String) -> Completions<String> {
166    use std::path::Path;
167
168    let current: &Path = p.as_ref();
169    let (mut dir, last) = if p.ends_with('/') {
170        (current, "")
171    } else {
172        let dir = current.parent().unwrap_or_else(|| "/".as_ref());
173        let last = current
174            .file_name()
175            .and_then(std::ffi::OsStr::to_str)
176            .unwrap_or("");
177        (dir, last)
178    };
179
180    if dir.to_str().unwrap().is_empty() {
181        dir = ".".as_ref();
182    }
183
184    let mut files: Completions<_> = match dir.read_dir() {
185        Ok(files) => files
186            .flatten()
187            .filter_map(|file| {
188                let path = file.path();
189                let is_dir = path.is_dir();
190                match path.into_os_string().into_string() {
191                    Ok(s) if is_dir => Some(s + "/"),
192                    Ok(s) => Some(s),
193                    Err(_) => None,
194                }
195            })
196            .collect(),
197        Err(_) => {
198            return completions![p];
199        }
200    };
201
202    if files.is_empty() {
203        return completions![p];
204    }
205
206    let fuzzer = SkimMatcherV2::default();
207    files.sort_by_cached_key(|file| fuzzer.fuzzy_match(file, last).unwrap_or(i64::MAX));
208    files
209}
210
211// ============================================================================= //
212
213struct Environment {
214    shell_info: ShellInfo,
215    os_type: os_info::Type,
216    terminal_info: TerminalInfo,
217    os_version: os_info::Version,
218}
219
220#[derive(Debug)]
221struct TerminalInfo {
222    name: String,
223    version: String,
224}
225
226fn get_terminal_info() -> TerminalInfo {
227    let terminal = get_env_var(TERM_PROGRAM)
228        .or_else(|_| get_env_var(LC_TERMINAL))
229        .unwrap_or_else(|_| UNKNOWN_TERMINAL.to_string());
230
231    let version = get_env_var(TERM_PROGRAM_VERSION)
232        .or_else(|_| get_env_var(LC_TERMINAL_VERSION))
233        .unwrap_or_else(|_| UNKNOWN_VERSION.to_string());
234
235    TerminalInfo {
236        name: terminal,
237        version,
238    }
239}
240
241#[derive(Debug)]
242struct ShellInfo {
243    name: String,
244    version: String,
245}
246
247fn get_shell_info() -> ShellInfo {
248    let failure_shell_info = ShellInfo {
249        name: UNKNOWN_SHELL.to_string(),
250        version: UNKNOWN_VERSION.to_string(),
251    };
252
253    let current_shell = match Shell::from_env() {
254        Some(shell) => shell.to_string(),
255        None => return failure_shell_info,
256    };
257
258    let version = get_shell_version(&current_shell);
259
260    ShellInfo {
261        version,
262        name: current_shell,
263    }
264}
265
266fn get_shell_version(shell: &str) -> String {
267    let time_limit = Duration::from_millis(500);
268    match shell {
269        "powershecll" => {
270            error!("Powershell is not supported.");
271            None
272        }
273        _ => blue_build_utils::exec_cmd(shell, &["--version"], time_limit),
274    }
275    .map_or_else(
276        || UNKNOWN_VERSION.to_string(),
277        |output| output.stdout.trim().to_string(),
278    )
279}
280
281// ============================================================================= //
282// Git
283// ============================================================================= //
284
285fn get_pkg_branch_tag() -> String {
286    format!("{} ({})", shadow::BRANCH, shadow::LAST_TAG)
287}
288
289fn generate_github_issue(environment: &Environment, recipe: Option<&Recipe>) -> Result<String> {
290    let recipe = serde_yaml::to_string(&recipe).into_diagnostic()?;
291
292    let github_template = GithubIssueTemplate::builder()
293        .bb_version(shadow::PKG_VERSION)
294        .build_rust_channel(shadow::BUILD_RUST_CHANNEL)
295        .build_time(shadow::BUILD_TIME)
296        .git_commit_hash(shadow::COMMIT_HASH)
297        .os_name(format!("{}", environment.os_type))
298        .os_version(format!("{}", environment.os_version))
299        .pkg_branch_tag(get_pkg_branch_tag())
300        .recipe(recipe)
301        .rust_channel(shadow::RUST_CHANNEL)
302        .rust_version(shadow::RUST_VERSION)
303        .shell_name(environment.shell_info.name.clone())
304        .shell_version(environment.shell_info.version.clone())
305        .terminal_name(environment.terminal_info.name.clone())
306        .terminal_version(environment.terminal_info.version.clone())
307        .build();
308
309    github_template.render().into_diagnostic()
310}
311
312fn make_github_issue_link(body: &str) -> String {
313    let escaped = urlencoding::encode(body).replace("%20", "+");
314
315    format!(
316        "https://github.com/blue-build/cli/issues/new?template={}&body={}",
317        urlencoding::encode("Bug_report.md"),
318        escaped
319    )
320    .chars()
321    .take(GITHUB_CHAR_LIMIT)
322    .collect()
323}
324
325// ============================================================================= //
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use std::env;
331
332    #[test]
333    fn test_make_github_link() {
334        let environment = Environment {
335            os_type: os_info::Type::Linux,
336            os_version: os_info::Version::Semantic(1, 2, 3),
337            shell_info: ShellInfo {
338                version: "2.3.4".to_string(),
339                name: "test_shell".to_string(),
340            },
341            terminal_info: TerminalInfo {
342                name: "test_terminal".to_string(),
343                version: "5.6.7".to_string(),
344            },
345        };
346
347        let recipe = Recipe::default();
348        let body = generate_github_issue(&environment, Some(&recipe)).unwrap();
349        let link = make_github_issue_link(&body);
350
351        assert!(link.contains(clap::crate_version!()));
352        assert!(link.contains("Linux"));
353        assert!(link.contains("1.2.3"));
354        assert!(link.contains("test_shell"));
355        assert!(link.contains("2.3.4"));
356    }
357}