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#[derive(Debug)]
14pub enum AppError {
15 UserFacing(UserFacingError),
17 Unexpected(Report),
19}
20
21pub type Result<T, E = AppError> = std::result::Result<T, E>;
23
24pub 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 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 let (panic_tx, mut panic_rx) = mpsc::channel(1);
53
54 eyre_hook.install()?;
56 panic::set_hook(Box::new(move |panic_info| {
57 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 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 process::exit(1);
82 }
83 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 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#[derive(Debug, strum::Display)]
123pub enum UserFacingError {
124 #[strum(to_string = "Invalid regex pattern")]
126 InvalidRegex,
127 #[strum(to_string = "Invalid fuzzy search")]
129 InvalidFuzzy,
130 #[strum(to_string = "Command cannot be empty")]
132 EmptyCommand,
133 #[strum(to_string = "Command is already bookmarked")]
135 CommandAlreadyExists,
136 #[strum(to_string = "Value already exists")]
138 VariableValueAlreadyExists,
139 #[strum(to_string = "Variable completion already exists")]
141 CompletionAlreadyExists,
142 #[strum(to_string = "Completion command can contain only alphanumeric characters or hyphen")]
144 CompletionInvalidCommand,
145 #[strum(to_string = "Completion variable cannot be empty")]
147 CompletionEmptyVariable,
148 #[strum(to_string = "Completion variable can't contain pipe, colon or braces")]
150 CompletionInvalidVariable,
151 #[strum(to_string = "Completion provider cannot be empty")]
153 CompletionEmptySuggestionsProvider,
154 #[strum(to_string = "Invalid completion format: {0}")]
156 ImportCompletionInvalidFormat(String),
157 #[strum(to_string = "Import path must be a file; directories and symlinks are not supported")]
159 ImportLocationNotAFile,
160 #[strum(to_string = "File not found")]
162 ImportFileNotFound,
163 #[strum(to_string = "The path already exists and it's not a file")]
165 ExportLocationNotAFile,
166 #[strum(to_string = "Destination directory does not exist")]
168 ExportFileParentNotFound,
169 #[strum(to_string = "Cannot export to a gist revision, provide a gist without a revision")]
171 ExportGistLocationHasSha,
172 #[strum(to_string = "GitHub token required for Gist export, set GIST_TOKEN env var or update config")]
174 ExportGistMissingToken,
175 #[strum(to_string = "Cannot access the file, check {0} permissions")]
177 FileNotAccessible(&'static str),
178 #[strum(to_string = "broken pipe")]
180 FileBrokenPipe,
181 #[strum(to_string = "Invalid URL, please provide a valid HTTP/S address")]
183 HttpInvalidUrl,
184 #[strum(to_string = "HTTP request failed: {0}")]
186 HttpRequestFailed(String),
187 #[strum(to_string = "Gist ID is missing, provide it as an argument or in the config file")]
189 GistMissingId,
190 #[strum(to_string = "The provided gist is not valid, please provide a valid id or URL")]
192 GistInvalidLocation,
193 #[strum(to_string = "File not found within the specified Gist")]
195 GistFileNotFound,
196 #[strum(to_string = "Gist request failed: {0}")]
198 GistRequestFailed(String),
199 #[strum(to_string = "Could not determine home directory")]
201 HistoryHomeDirNotFound,
202 #[strum(to_string = "History file not found at: {0}")]
204 HistoryFileNotFound(String),
205 #[strum(to_string = "Atuin not found, make sure it is installed and in your PATH")]
207 HistoryAtuinNotFound,
208 #[strum(to_string = "Error running atuin, maybe it is an old version")]
210 HistoryAtuinFailed,
211 #[strum(to_string = "AI feature is disabled, enable it in the config file to use this functionality")]
213 AiRequired,
214 #[strum(to_string = "A command must be provided")]
216 AiEmptyCommand,
217 #[strum(to_string = "API key in '{0}' env variable is missing, invalid, or lacks permissions")]
219 AiMissingOrInvalidApiKey(String),
220 #[strum(to_string = "Request to AI provider timed out")]
222 AiRequestTimeout,
223 #[strum(to_string = "AI provider responded with status 503 Service Unavailable")]
225 AiUnavailable,
226 #[strum(to_string = "AI request failed: {0}")]
228 AiRequestFailed(String),
229 #[strum(to_string = "AI request rate-limited, try again later")]
231 AiRateLimit,
232}
233
234impl AppError {
235 pub fn into_report(self) -> Report {
237 match self {
238 AppError::UserFacing(err) => Report::msg(err),
239 AppError::Unexpected(report) => report,
240 }
241 }
242}
243impl From<UserFacingError> for AppError {
244 fn from(err: UserFacingError) -> Self {
245 Self::UserFacing(err)
246 }
247}
248impl<T: Into<Report>> From<T> for AppError {
249 fn from(err: T) -> Self {
250 Self::Unexpected(err.into())
251 }
252}
253
254#[macro_export]
260macro_rules! trace_dbg {
261 (target: $target:expr, level: $level:expr, $ex:expr) => {
262 {
263 match $ex {
264 value => {
265 tracing::event!(target: $target, $level, ?value, stringify!($ex));
266 value
267 }
268 }
269 }
270 };
271 (level: $level:expr, $ex:expr) => {
272 $crate::trace_dbg!(target: module_path!(), level: $level, $ex)
273 };
274 (target: $target:expr, $ex:expr) => {
275 $crate::trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex)
276 };
277 ($ex:expr) => {
278 $crate::trace_dbg!(level: tracing::Level::DEBUG, $ex)
279 };
280}