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 #[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 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
211struct 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(¤t_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
281fn 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#[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}