intelli_shell/
errors.rs

1use std::{
2    env,
3    panic::{self, UnwindSafe},
4    path::PathBuf,
5    process,
6};
7
8use color_eyre::{Report, Section, config::HookBuilder, owo_colors::style};
9use futures_util::FutureExt;
10use tokio::sync::mpsc;
11
12/// A top-level error enum for the entire application
13#[derive(Debug)]
14pub enum AppError {
15    /// A controlled, expected error that can be safely displayed to the end-user
16    UserFacing(UserFacingError),
17    /// An unexpected, internal error
18    Unexpected(Report),
19}
20
21/// A specialized `Result` type for this application
22pub type Result<T, E = AppError> = std::result::Result<T, E>;
23
24/// Initializes error and panics handling
25pub async fn init<F>(log_path: Option<PathBuf>, fut: F) -> Result<(), Report>
26where
27    F: Future<Output = Result<(), Report>> + UnwindSafe,
28{
29    tracing::trace!("Initializing error handlers");
30    // Initialize hooks
31    let panic_section = if let Some(log_path) = log_path {
32        format!(
33            "This is a bug. Consider reporting it at {}\nLogs can be found at {}",
34            env!("CARGO_PKG_REPOSITORY"),
35            log_path.display()
36        )
37    } else {
38        format!(
39            "This is a bug. Consider reporting it at {}\nLogs were not generated, consider enabling them on the \
40             config or running with INTELLI_LOG=debug.",
41            env!("CARGO_PKG_REPOSITORY")
42        )
43    };
44    let (panic_hook, eyre_hook) = HookBuilder::default()
45        .panic_section(panic_section.clone())
46        .display_env_section(false)
47        .display_location_section(true)
48        .capture_span_trace_by_default(true)
49        .into_hooks();
50
51    // Initialize panic notifier
52    let (panic_tx, mut panic_rx) = mpsc::channel(1);
53
54    // Install both hooks
55    eyre_hook.install()?;
56    panic::set_hook(Box::new(move |panic_info| {
57        // At this point the TUI might still be in raw mode, so we can't print to stderr here
58        // Instead, we're sending the report through a channel, to be handled after the main future is dropped
59        let panic_report = panic_hook.panic_report(panic_info).to_string();
60        tracing::error!("Error: {}", strip_ansi_escapes::strip_str(&panic_report));
61        if panic_tx.try_send(panic_report).is_err() {
62            tracing::error!("Error sending panic report",);
63            process::exit(2);
64        }
65    }));
66
67    tokio::select! {
68        biased;
69        // Wait for a panic to be notified
70        panic_report = panic_rx.recv().fuse() => {
71            if let Some(report) = panic_report {
72                eprintln!("{report}");
73            } else {
74                eprintln!(
75                    "{}\n\n{panic_section}",
76                    style().bright_red().style("A panic occurred, but the detailed report could not be captured.")
77                );
78                tracing::error!("A panic occurred, but the detailed report could not be captured.");
79            }
80            // Exit with a non-zero status code
81            process::exit(1);
82        }
83        // Or for the main future to finish, catching unwinding panics
84        res = Box::pin(fut).catch_unwind() => {
85            match res {
86                Ok(r) => r
87                    .with_section(move || panic_section)
88                    .inspect_err(|err| tracing::error!("Error: {}", strip_ansi_escapes::strip_str(format!("{err:?}")))),
89                Err(err) => {
90                    if let Ok(report) = panic_rx.try_recv() {
91                        eprintln!("{report}");
92                    } else if let Some(err) = err.downcast_ref::<&str>() {
93                        print_panic_msg(err, panic_section);
94                    } else if let Some(err) = err.downcast_ref::<String>() {
95                        print_panic_msg(err, panic_section);
96                    } else {
97                        eprintln!(
98                            "{}\n\n{panic_section}",
99                            style().bright_red().style("An unexpected panic happened")
100                        );
101                        tracing::error!("An unexpected panic happened");
102                    }
103                    // Exit with a non-zero status code
104                    process::exit(1);
105                }
106            }
107        }
108    }
109}
110
111fn print_panic_msg(err: impl AsRef<str>, panic_section: String) {
112    let err = err.as_ref();
113    eprintln!(
114        "{}\nMessage: {}\n\n{panic_section}",
115        style().bright_red().style("The application panicked (crashed)."),
116        style().blue().style(err)
117    );
118    tracing::error!("Panic: {err}");
119}
120
121/// Represents all possible errors that are meant to be displayed to the end-user
122#[derive(Debug, strum::Display)]
123pub enum UserFacingError {
124    /// The regex pattern provided for a search is invalid
125    #[strum(to_string = "Invalid regex pattern")]
126    InvalidRegex,
127    /// A fuzzy search was attempted without providing a valid search term
128    #[strum(to_string = "Invalid fuzzy search")]
129    InvalidFuzzy,
130    /// An attempt was made to save an empty command
131    #[strum(to_string = "Command cannot be empty")]
132    EmptyCommand,
133    /// The user tried to save a command that is already bookmarked
134    #[strum(to_string = "Command is already bookmarked")]
135    CommandAlreadyExists,
136    /// The user tried to save a variable value that already exists
137    #[strum(to_string = "Value already exists")]
138    VariableValueAlreadyExists,
139    /// The path for an import operation points to a directory or symlink, not a regular file
140    #[strum(to_string = "Import path must be a file; directories and symlinks are not supported")]
141    ImportLocationNotAFile,
142    /// The file specified for an import operation could not be found
143    #[strum(to_string = "File not found")]
144    ImportFileNotFound,
145    /// The path for an export operation already exists and is not a regular file
146    #[strum(to_string = "The path already exists and it's not a file")]
147    ExportLocationNotAFile,
148    /// The parent directory for a file to be exported does not exist
149    #[strum(to_string = "Destination directory does not exist")]
150    ExportFileParentNotFound,
151    /// An attempt was made to export to a specific Gist revision (SHA), which is not allowed
152    #[strum(to_string = "Cannot export to a gist revision, provide a gist without a revision")]
153    ExportGistLocationHasSha,
154    /// A GitHub personal access token is required for exporting to a Gist but was not found
155    #[strum(to_string = "GitHub token required for Gist export, set GIST_TOKEN env var or update config")]
156    ExportGistMissingToken,
157    /// The application lacks the necessary permissions to read from or write to a file
158    #[strum(to_string = "Cannot access the file, check {0} permissions")]
159    FileNotAccessible(&'static str),
160    /// A "broken pipe" error occurred while writing to a file
161    #[strum(to_string = "broken pipe")]
162    FileBrokenPipe,
163    /// The URL provided for an HTTP operation is malformed
164    #[strum(to_string = "Invalid URL, please provide a valid HTTP/S address")]
165    HttpInvalidUrl,
166    /// An HTTP request to a remote URL has failed
167    #[strum(to_string = "HTTP request failed: {0}")]
168    HttpRequestFailed(String),
169    /// A required GitHub Gist ID was not provided via arguments or configuration
170    #[strum(to_string = "Gist ID is missing, provide it as an argument or in the config file")]
171    GistMissingId,
172    /// The provided Gist identifier (ID or URL) is malformed or invalid
173    #[strum(to_string = "The provided gist is not valid, please provide a valid id or URL")]
174    GistInvalidLocation,
175    /// The specified file within the target GitHub Gist could not be found
176    #[strum(to_string = "File not found within the specified Gist")]
177    GistFileNotFound,
178    /// A request to the GitHub Gist API has failed
179    #[strum(to_string = "Gist request failed: {0}")]
180    GistRequestFailed(String),
181    /// The user's home directory could not be determined, preventing access to shell history
182    #[strum(to_string = "Could not determine home directory")]
183    HistoryHomeDirNotFound,
184    /// The history file for the specified shell could not be found
185    #[strum(to_string = "History file not found at: {0}")]
186    HistoryFileNotFound(String),
187    /// The `atuin` command is required for importing history but was not found in the system's PATH
188    #[strum(to_string = "Atuin not found, make sure it is installed and in your PATH")]
189    HistoryAtuinNotFound,
190    /// The `atuin` command failed to execute
191    #[strum(to_string = "Error running atuin, maybe it is an old version")]
192    HistoryAtuinFailed,
193    /// An AI-related feature was used, but AI is not enabled in the configuration
194    #[strum(to_string = "AI feature is disabled, enable it in the config file to use this functionality")]
195    AiRequired,
196    /// The command is missing or empty
197    #[strum(to_string = "A command must be provided")]
198    AiEmptyCommand,
199    /// The API key for the AI service is either missing, invalid, or lacks necessary permissions
200    #[strum(to_string = "API key in '{0}' env variable is missing, invalid, or lacks permissions")]
201    AiMissingOrInvalidApiKey(String),
202    /// The request to the AI provider timed out while waiting for a response
203    #[strum(to_string = "Request to AI provider timed out")]
204    AiRequestTimeout,
205    /// A generic error occurred while making a request to the AI provider's API
206    #[strum(to_string = "AI request failed: {0}")]
207    AiRequestFailed(String),
208    /// The request was rejected by the AI provider due to rate limiting
209    #[strum(to_string = "AI request rate-limited, try again later")]
210    AiRateLimit,
211}
212
213impl AppError {
214    /// Converts this error into a [Report]
215    pub fn into_report(self) -> Report {
216        match self {
217            AppError::UserFacing(err) => Report::msg(err),
218            AppError::Unexpected(report) => report,
219        }
220    }
221}
222impl From<UserFacingError> for AppError {
223    fn from(err: UserFacingError) -> Self {
224        Self::UserFacing(err)
225    }
226}
227impl<T: Into<Report>> From<T> for AppError {
228    fn from(err: T) -> Self {
229        Self::Unexpected(err.into())
230    }
231}
232
233/// Similar to the `std::dbg!` macro, but generates `tracing` events rather
234/// than printing to stdout.
235///
236/// By default, the verbosity level for the generated events is `DEBUG`, but
237/// this can be customized.
238#[macro_export]
239macro_rules! trace_dbg {
240    (target: $target:expr, level: $level:expr, $ex:expr) => {
241        {
242            match $ex {
243                value => {
244                    tracing::event!(target: $target, $level, ?value, stringify!($ex));
245                    value
246                }
247            }
248        }
249    };
250    (level: $level:expr, $ex:expr) => {
251        trace_dbg!(target: module_path!(), level: $level, $ex)
252    };
253    (target: $target:expr, $ex:expr) => {
254        trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex)
255    };
256    ($ex:expr) => {
257        trace_dbg!(level: tracing::Level::DEBUG, $ex)
258    };
259}