use std::fmt; use std::panic::Location; use dioxus::prelude::*; use serde::{Deserialize, Serialize}; /// A location in the source code /// A thin wrapper over `std::panic::Location`, which isn't `Serialize` or `Deserialize` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ErrorLocation { file: String, line: u32, } impl ErrorLocation { /// Creates a new `ErrorLocation` with the file and line number of the caller. #[track_caller] pub fn here() -> Self { let location = Location::caller(); ErrorLocation { file: location.file().to_string(), line: location.line(), } } /// Get a link to the source code based on the repository URL from Cargo and the Git commit /// from the build script. Uses a format supported by GitHub, Gitea, and GitLab. pub fn source_link(&self) -> String { format!( "{}/blob/{}/{}#L{}", env!("CARGO_PKG_REPOSITORY"), env!("GIT_REV"), self.file, self.line ) } } impl fmt::Display for ErrorLocation { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}:{}", self.file, self.line) } } #[derive(Debug, Clone, Deserialize, Serialize, thiserror::Error)] pub struct Error { #[source] /// The error type and data source: ErrorType, /// The location where the error was created from: ErrorLocation, /// Context added to the error, and location where it was added context: Vec<(ErrorLocation, String)>, } impl Error { /// Creates a new `Error` at this location with the given error type #[track_caller] pub fn new_here(source: ErrorType) -> Self { Error::new(source, ErrorLocation::here()) } /// Creates a new `Error` with the given location and error type pub fn new(source: ErrorType, from: ErrorLocation) -> Self { Error { source, from, context: Vec::new(), } } /// Adds a context message to the error #[track_caller] pub fn with_context(mut self, context: impl Into) -> Self { self.context.push((ErrorLocation::here(), context.into())); self } /// Retrieve the "top" message of the error. Uses either the most recent context entry, or the /// source message if no context has been added. pub fn top_message(&self) -> String { if let Some((_location, message)) = self.context.last() { message.clone() } else { self.source.to_string() } } /// Display this error as a modal dialog activated by a checkbox with the given id pub fn as_modal(&self, id: String) -> Element { rsx! { input { r#type: "checkbox", class: "modal-toggle", id: &id, } div { class: "modal", role: "dialog", div { class: "modal-box border border-error bg-soft-error max-w-200", h2 { class: "flex items-center gap-3 text-lg", lucide_dioxus::CircleAlert { class: "shrink-0", } {self.top_message()} } p { class: "text-base-content/70 py-3", "Details" } div { class: "md:grid md:grid-cols-[fit-content(calc(var(--spacing)*30))_auto] md:gap-1 md:gap-x-2 mb-6", for (location, message) in self.context.iter().rev().chain(std::iter::once(&( self.from.clone(), self.source.to_string(), ))) { a { class: "text-base-content/80 interact underline", target: "_blank", href: location.source_link(), {location.to_string()} } p { class: "ml-3 md:ml-0", {message.to_string()} } } } a { class: "text-base-content/50 interact underline", target: "_blank", href: format!("{}/issues/new", env!("CARGO_PKG_REPOSITORY")), "Report an issue" } label { class: "absolute right-1 top-1 interact hover:bg-base-100/70 p-1 rounded-full", r#for: &id, lucide_dioxus::X { class: "size-7 md:size-5", } } } label { class: "modal-backdrop cursor-pointer", r#for: id, "Close", } } } } /// Convert this error to a toast message, which opens a modal when clicked pub fn as_toast(&self) -> Element { // Generate a random string to use as an id for the modal // This allows multiple toast/modal to be present let modal_id = { use rand::RngExt; let mut rng = rand::rng(); let random_str = (0..5) .map(|_| rng.sample(rand::distr::Alphanumeric) as char) .collect::(); format!("err-modal-{random_str}") }; rsx! { {self.as_modal(modal_id.clone())} div { class: "toast", label { class: "alert alert-error alert-soft cursor-pointer", role: "alert", r#for: modal_id, lucide_dioxus::CircleAlert {} p { class: "max-w-120 text-ellipsis line-clamp-3", {self.top_message()} } } } } } } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // Write the error type and its context writeln!(f, "Error: {}", self.top_message())?; write!(f, "Context:")?; for (location, message) in self.context.iter().rev().chain(std::iter::once(&( self.from.clone(), self.source.to_string(), ))) { write!(f, "\n - {location}: {message}")?; } Ok(()) } } pub trait Contextualize { /// Add context to the `Result` if it is an `Err`. #[track_caller] fn err_context(self, context: impl Into) -> R; } impl> Contextualize> for Result { #[track_caller] fn err_context(self, context: impl Into) -> Result { // Closures can't (currently) `track_caller`, so a simple map_err doesn't work // See https://github.com/rust-lang/rust/issues/87417 match self { Ok(e) => Ok(e), Err(e) => Err(e.into().with_context(context)), } } } impl Contextualize> for Option { #[track_caller] fn err_context(self, context: impl Into) -> Result { self.ok_or(Error::new_here(ErrorType::Error(context.into()))) } } impl> Contextualize> for E { #[track_caller] fn err_context(self, context: impl Into) -> Result { Err(self.into().with_context(context)) } } #[derive(Debug, Clone, thiserror::Error, Deserialize, Serialize)] pub enum ErrorType { // Using string to represent Diesel errors, because Diesel's Error type is not `Serialize`, // and Diesel is only available on the server #[error("Database error: {0}")] Database(String), #[error("{0}")] Error(String), #[error("Server function error: {0}")] ServerFnError(ServerFnError), } impl From for Error { #[track_caller] fn from(err: ErrorType) -> Self { Error::new_here(err) } } #[cfg(feature = "server")] impl From for Error { #[track_caller] fn from(err: diesel::result::Error) -> Self { Error::new_here(ErrorType::Database(format!("{err}"))) } }