Skip to main content

charon_error/submit/
gitlab_error_report.rs

1//! GitLab issue submission for error reports.
2//!
3//! Generates pre-filled GitLab issue URLs from [`ErrorReport`]s and
4//! progressively trims content to stay within URL length limits.
5//! Configure via [`GitLabERGlobalSettings`].
6
7use crate::{
8    ErrorFmt, ErrorFmtLoD, ErrorFmtSettings, ErrorReport, GlobalSettings, IndentationStyle,
9    LinkDebugIde, ResultExt, StringError, SubmitErrorReport,
10};
11use colored::Colorize;
12use std::sync::{OnceLock, RwLock, RwLockReadGuard};
13use url::{Url, form_urlencoded};
14
15/// GitLab implementation of [`SubmitErrorReport`].
16///
17/// Generates pre-filled GitLab issue URLs from error reports. Before use,
18/// call [`GlobalSettings::set_global_settings`] once at application startup.
19///
20/// # Example
21///
22/// ```rust,no_run
23/// use charon_error::prelude::*;
24/// use charon_error::prelude::gitlab_er::*;
25///
26/// GitLabERGlobalSettings::set_global_settings(GitLabERGlobalSettings {
27///     domain: "gitlab.com".to_owned(),
28///     project_path: "my-group/my-project".to_owned(),
29///     ..Default::default()
30/// }).unwrap_error();
31/// ```
32///
33/// Example Output:
34/// ```text
35/// PANIC: A panic occurred during execution.
36/// * Message: 'Error: `Called `ResultExt::unwrap_error()` on an `Err` value`'
37/// * Path: 'src/main.rs:72:21'
38/// PANIC: The application encountered an error it could not recover from.
39/// {
40///   last_error: 'Error: `Called `ResultExt::unwrap_error()` on an `Err` value`',
41///   unique_id: '1nVBRnzAXYpzYCgAUp1i',
42///   frames: [
43///     {
44///       message: 'No such file or directory (os error 2)',
45///       source_location: src/cli/config.rs:15:10,
46///       span_trace: [
47///         charon_demo::cli::config::read_config at (src/cli/config.rs:7:0),
48///         charon_demo::cli::start_server at (src/cli.rs:82:0),
49///         charon_demo::cli::start at (src/cli.rs:35:0),
50///       ],
51///       attachments: {},
52///       date_time: 2026-03-01T00:11:13.137896643+00:00,
53///     },
54///     {
55///       message: 'The config file failed to load correctly.',
56///       source_location: src/cli/config.rs:15:10,
57///       span_trace: [
58///         charon_demo::cli::config::read_config at (src/cli/config.rs:7:0),
59///         charon_demo::cli::start_server at (src/cli.rs:82:0),
60///         charon_demo::cli::start at (src/cli.rs:35:0),
61///       ],
62///       attachments: {
63///         file_path: '../config.toml',
64///       },
65///       date_time: 2026-03-01T00:11:13.137998242+00:00,
66///     },
67///     {
68///       message: 'Error: `Called `ResultExt::unwrap_error()` on an `Err` value`',
69///       source_location: src/main.rs:72:21,
70///       span_trace: [],
71///       attachments: {},
72///       date_time: 2026-03-01T00:11:13.138030402+00:00,
73///     },
74///   ],
75/// }
76/// ┌──────────────────────────┐
77/// │  Open/Submit Bug Report │
78/// └──────────────────────────┘
79/// ```
80/// For release build see: [Panic Output in Release](docs/images/panic_output_release.png)
81#[derive(Debug, Clone)]
82pub struct GitLabErrorReport<'a> {
83    /// Reference to the error report being submitted.
84    pub error_report: &'a ErrorReport,
85}
86
87impl<'a> SubmitErrorReport<'a> for GitLabErrorReport<'a> {
88    fn new(error: &'a ErrorReport) -> Self {
89        GitLabErrorReport {
90            error_report: error,
91        }
92    }
93
94    fn get_error_report(&self) -> &ErrorReport {
95        self.error_report
96    }
97
98    fn get_title(&self) -> String {
99        self.error_report.get_last_error_title()
100    }
101
102    fn create_message(&self) -> Result<String, ErrorReport> {
103        let step1 = "Step 1".yellow();
104        let step2 = "Step 2".yellow();
105        let bug_report_stdout = {
106            let settings = GitLabERGlobalSettings::get_global_settings()?;
107            let display_parts = ReportPartEnabled::display_report();
108            let format_settings = settings.release_format_settings_stderr;
109
110            self.create_new_bug_report(&settings, &display_parts, &format_settings)
111        }?;
112        Ok(format!(
113            "Panic Info:\n{}\n\
114            Please report this error. Issue title: `{}`\n\
115            \t- {step1}: Check if error report already exists: {}\n\
116            \t- {step2}: Report issue (if it does not already exists):\n\
117            \t- Open Pre-created Report Link: \n---\n{}\n----\n",
118            bug_report_stdout,
119            self.get_title().cyan().bold(),
120            self.check_existing_reports()?.to_string().green(),
121            self.create_submit_url()?.to_string().green(),
122        ))
123    }
124
125    fn create_bug_report(&self) -> Result<String, ErrorReport> {
126        let settings = GitLabERGlobalSettings::get_global_settings()?;
127        let display_parts = ReportPartEnabled::display_report();
128        let format_settings = ErrorFmtSettings {
129            level_of_detail: ErrorFmtLoD::SubmitReport,
130            frame_limit: None,
131            enable_color: false,
132            link_format: LinkDebugIde::NoLink,
133            indentation_style: IndentationStyle::Tab,
134            skip_first_indentations: 2,
135            ..Default::default()
136        };
137
138        self.create_new_bug_report(&settings, &display_parts, &format_settings)
139    }
140
141    fn create_submit_url_limited(&self, max_length: usize) -> Result<Url, ErrorReport> {
142        let settings = GitLabERGlobalSettings::get_global_settings()?;
143        let url_char_limit = std::cmp::min(max_length, settings.url_char_limit);
144
145        // Get size of smallest report.
146        let minimal_link_parts = ReportPartEnabled::disable_all();
147        let minimal_url = self.create_new_submit_url(&settings, &minimal_link_parts)?;
148        if minimal_url.to_string().len() > url_char_limit {
149            tracing::warn!(
150                limit = url_char_limit,
151                size = minimal_url.to_string().len(),
152                "Minimal URL is already bigger then URL Limit",
153            );
154            return Ok(minimal_url);
155        }
156
157        // Start with all parts enabled (and remove if to long)
158        let mut link_parts = ReportPartEnabled::default();
159
160        // Section 1: All
161        let mut url = self.create_new_submit_url(&settings, &link_parts)?;
162        if url.to_string().len() <= url_char_limit {
163            return Ok(url);
164        } else {
165            // Remove part of message
166            tracing::trace!(
167                url_length = url.to_string().len(),
168                limit = url_char_limit,
169                "URL to Long, Removing 'Steps' from Error Report URL."
170            );
171            link_parts.add_steps = false;
172        }
173
174        // Section 2: All - Steps
175        url = self.create_new_submit_url(&settings, &link_parts)?;
176        if url.to_string().len() <= url_char_limit {
177            return Ok(url);
178        } else {
179            // Remove part of message
180            tracing::trace!(
181                url_length = url.to_string().len(),
182                limit = url_char_limit,
183                "URL to Long, Removing 'Backtrace' from Error Report URL."
184            );
185            link_parts.add_backtrace = false;
186        }
187
188        // Section 3: All - Steps - Backtrace
189        url = self.create_new_submit_url(&settings, &link_parts)?;
190        if url.to_string().len() <= url_char_limit {
191            return Ok(url);
192        }
193
194        // Section 4: All - Steps - Backtrace - Remove Some ErrorFrames
195        let mut error_frame_count = std::cmp::min(20, self.error_report.frames.len() - 1);
196        while error_frame_count > 0 && url.to_string().len() > url_char_limit {
197            link_parts.frame_limit = Some(error_frame_count);
198            url = self.create_new_submit_url(&settings, &link_parts)?;
199            tracing::trace!(
200                url_length = url.to_string().len(),
201                limit = url_char_limit,
202                error_frame_count = error_frame_count,
203                "URL to Long, Removing Some ErrorFrames from Error Report URL."
204            );
205            // URL is small enough
206            if url.to_string().len() <= url_char_limit {
207                return Ok(url);
208            }
209            error_frame_count -= 1;
210        }
211
212        // Section 5: Just display 1 Frame.
213        link_parts.frame_limit = Some(1);
214        let url = self.create_new_submit_url(&settings, &link_parts)?;
215        if url.to_string().len() > url_char_limit {
216            tracing::error!(
217                url_length = url.to_string().len(),
218                limit = url_char_limit,
219                "ErrorReport even adding 1 ErrorFrame to URL will go over URL Limit."
220            );
221        }
222        Ok(url)
223    }
224
225    fn create_submit_url(&self) -> Result<Url, ErrorReport> {
226        self.create_submit_url_limited(usize::MAX)
227    }
228
229    fn check_existing_reports(&self) -> Result<Url, ErrorReport> {
230        let settings = GitLabERGlobalSettings::get_global_settings()?;
231        let search_title: String = form_urlencoded::Serializer::new(String::new())
232            .append_pair(
233                "search",
234                &Self::truncate_string(self.get_title(), settings.title_char_limit),
235            )
236            .finish();
237        Ok(Url::parse(&format!(
238            "https://{domain}/{project_path}/-/issues/?sort=created_date&state=all&in=TITLE&{search_title}",
239            domain = settings.domain,
240            project_path = settings.project_path,
241            search_title = search_title,
242        ))?)
243    }
244
245    fn validate_settings(&self) -> Result<(), ErrorReport> {
246        if !GitLabERGlobalSettings::is_set() {
247            Err(StringError::new(
248                "GitLabERGlobalSettings has not been set, this could create runtime issues.",
249            )
250            .into())
251        } else {
252            Ok(())
253        }
254    }
255}
256
257/// Controls which sections are included in a GitLab issue report.
258///
259/// Used internally to progressively strip content when the URL exceeds
260/// length limits.
261#[derive(Debug, Clone)]
262pub struct ReportPartEnabled {
263    /// Include "Steps to reproduce" section.
264    pub add_steps: bool,
265    /// Include the backtrace section.
266    pub add_backtrace: bool,
267    /// Include the error summary section.
268    pub add_summary: bool,
269    /// Limit the number of error frames (None = unlimited).
270    pub frame_limit: Option<usize>,
271    /// Wrap content in GitLab-specific HTML (details/summary tags, comments).
272    pub enable_gitlab_syntax: bool,
273}
274
275impl ReportPartEnabled {
276    fn disable_all() -> Self {
277        Self {
278            add_steps: false,
279            add_backtrace: false,
280            add_summary: false,
281            frame_limit: Some(0),
282            enable_gitlab_syntax: true,
283        }
284    }
285
286    fn display_report() -> Self {
287        Self {
288            add_steps: false,
289            add_backtrace: true,
290            add_summary: true,
291            frame_limit: None,
292            enable_gitlab_syntax: false,
293        }
294    }
295}
296
297impl Default for ReportPartEnabled {
298    fn default() -> Self {
299        Self {
300            add_steps: true,
301            add_backtrace: true,
302            add_summary: true,
303            frame_limit: None,
304            enable_gitlab_syntax: true,
305        }
306    }
307}
308
309impl<'a> GitLabErrorReport<'a> {
310    fn gitlab_syntax<S: Into<String> + Default>(text: S, enabled_parts: &ReportPartEnabled) -> S {
311        if enabled_parts.enable_gitlab_syntax {
312            text
313        } else {
314            S::default()
315        }
316    }
317
318    fn create_new_bug_report(
319        &self,
320        settings: &RwLockReadGuard<'static, GitLabERGlobalSettings>,
321        enabled_parts: &ReportPartEnabled,
322        format_settings: &ErrorFmtSettings,
323    ) -> Result<String, ErrorReport> {
324        let reproduce = if enabled_parts.add_steps {
325            "## Reproduce:\n\
326            Steps to recreate this issue:\n\
327            1. ...\n\
328            2. ...\n\n"
329        } else {
330            ""
331        };
332        let backtrace = if enabled_parts.add_backtrace {
333            let backtrace_string = self
334                .error_report
335                .frames
336                .last()
337                .unwrap_error()
338                .create_backtrace_string();
339            format!(
340                "### Backtrace:\n\
341                {start_summary}\
342                ```\n\
343                {backtrace}\n\
344                ```\n\n\
345                {end_summary}",
346                start_summary = Self::gitlab_syntax(
347                    "<details><summary markdown=\"span\">Backtrace</summary>\n\n",
348                    enabled_parts
349                ),
350                end_summary = Self::gitlab_syntax("</details>\n\n", enabled_parts),
351                backtrace = backtrace_string,
352            )
353        } else {
354            "".to_owned()
355        };
356
357        let summary = if enabled_parts.add_summary {
358            let mut format_settings = *format_settings;
359            format_settings.frame_limit = enabled_parts.frame_limit;
360            format!(
361                "```hcl\n\
362                {message}\n\
363                ```\n\n",
364                message = self.error_report.stringify(format_settings)?
365            )
366        } else {
367            "".to_owned()
368        };
369
370        Ok(format!(
371            "{header}\
372            ## Summary:\n\
373            {summary}\
374            {reproduce}\
375            ## Debug info:\n\
376            {backtrace}\
377            ### System:\n\
378            * Application version: {version}\n\
379            * System: {os} {arch}\n\
380            {start_footer}\
381            {labels}",
382            header = Self::gitlab_syntax(
383                "<!-- Please check above for similar title.\n\
384                Someone might have already reported this.-->\n",
385                enabled_parts
386            ),
387            summary = summary,
388            reproduce = reproduce,
389            backtrace = backtrace,
390            version = env!("CARGO_PKG_VERSION"),
391            arch = std::env::consts::ARCH,
392            os = std::env::consts::OS,
393            start_footer = Self::gitlab_syntax("<!-- Leave below untouched! -->\n", enabled_parts),
394            labels = Self::gitlab_syntax(self.get_labels(settings), enabled_parts),
395        ))
396    }
397
398    fn create_new_submit_url(
399        &self,
400        settings: &RwLockReadGuard<'static, GitLabERGlobalSettings>,
401        enabled_parts: &ReportPartEnabled,
402    ) -> Result<Url, ErrorReport> {
403        let format_settings = ErrorFmtSettings {
404            level_of_detail: ErrorFmtLoD::SubmitReport,
405            frame_limit: None,
406            enable_color: false,
407            link_format: LinkDebugIde::NoLink,
408            indentation_style: IndentationStyle::Tab,
409            skip_first_indentations: 2,
410            ..Default::default()
411        };
412        let description = self.create_new_bug_report(settings, enabled_parts, &format_settings)?;
413
414        let encoded: String = form_urlencoded::Serializer::new(String::new())
415            .append_pair(
416                "issue[title]",
417                &Self::truncate_string(self.get_title(), settings.title_char_limit),
418            )
419            .append_pair(
420                "issue[description]",
421                &Self::truncate_string(description, settings.description_char_limit),
422            )
423            .finish();
424
425        // Create URL from encoded message
426        Ok(Url::parse(&format!(
427            "https://{domain}/{project_path}/issues/new?{encoded_message}",
428            domain = settings.domain,
429            project_path = settings.project_path,
430            encoded_message = encoded,
431        ))?)
432    }
433
434    fn get_labels(&self, settings: &RwLockReadGuard<'static, GitLabERGlobalSettings>) -> String {
435        let mut labels_string = "/label".to_owned();
436        for label in &settings.labels {
437            labels_string = format!("{labels_string} ~\"{label}\"");
438        }
439        labels_string
440    }
441}
442
443/// Global configuration for GitLab issue submission.
444///
445/// Set once at application startup via
446/// [`GlobalSettings::set_global_settings`].
447#[derive(Debug, Clone)]
448pub struct GitLabERGlobalSettings {
449    /// GitLab domain (e.g. `"gitlab.com"` or `"gitlab.example.com"`).
450    pub domain: String,
451    /// Project path (e.g. `"my-group/my-project"`).
452    pub project_path: String,
453    /// Labels to apply to created issues.
454    pub labels: Vec<String>,
455    /// Limit that will disable parts of the report in order to stay under this limit (if possible)
456    /// Default: 2085
457    pub url_char_limit: usize,
458    /// Hard limit for title, title will be cut off on or before this limit.
459    /// This limit takes into account character boundaries.
460    /// Default: 1024
461    pub title_char_limit: usize,
462    /// Hard limit for description, description will be cut off on or before this limit.
463    /// This limit takes into account character boundaries.
464    /// Default: 1_048_576 (1MB)
465    pub description_char_limit: usize,
466    /// Formatter for bug report printed to terminal in stderr
467    /// This is the exact same message as is included in reporting link (before reducing because of char_limit).
468    ///
469    /// Because it is printed to standard error it can contain color or be otherwise easier to read.
470    /// These settings do not apply to output in debug builds or to the output in the link.
471    ///
472    /// Default settings is same as format in Link
473    pub release_format_settings_stderr: ErrorFmtSettings,
474}
475
476impl GlobalSettings for GitLabERGlobalSettings {
477    type Setting = GitLabERGlobalSettings;
478
479    fn once_lock() -> &'static OnceLock<RwLock<Self::Setting>> {
480        &GITLAB_SUBMIT_REPORT_GLOBAL_CONFIG
481    }
482    fn get_setting_object_name() -> &'static str {
483        "GITLAB_SUBMIT_REPORT_GLOBAL_CONFIG"
484    }
485}
486
487static GITLAB_SUBMIT_REPORT_GLOBAL_CONFIG: OnceLock<RwLock<GitLabERGlobalSettings>> =
488    OnceLock::new();
489
490impl Default for GitLabERGlobalSettings {
491    fn default() -> Self {
492        Self {
493            domain: "gitlab.com".to_owned(),
494            project_path: "<not set>".to_owned(),
495            labels: vec!["Auto Generated Issue".to_owned()],
496            url_char_limit: 2085,
497            title_char_limit: 1024,
498            description_char_limit: 1_048_576, // 1MB
499            release_format_settings_stderr: ErrorFmtSettings {
500                level_of_detail: ErrorFmtLoD::SubmitReport,
501                frame_limit: None,
502                enable_color: false,
503                link_format: LinkDebugIde::NoLink,
504                indentation_style: IndentationStyle::Tab,
505                skip_first_indentations: 2,
506                ..Default::default()
507            },
508        }
509    }
510}