use std::fmt;
#[derive(Debug, Clone)]
pub struct OlError {
pub code: &'static str,
pub message: String,
pub suggestion: Option<String>,
pub docs_url: Option<String>,
}
impl OlError {
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
suggestion: None,
docs_url: None,
}
}
pub fn with_suggestion(mut self, s: impl Into<String>) -> Self {
self.suggestion = Some(s.into());
self
}
pub fn with_docs(mut self, url: impl Into<String>) -> Self {
self.docs_url = Some(url.into());
self
}
pub fn bug_report(message: impl Into<String>) -> Self {
let msg = message.into();
let url = format!(
"https://github.com/OpenLatch/openlatch-client/issues/new?title={}&body={}",
percent_encode(&msg),
percent_encode("Version: [auto]\nOS: [auto]\n\nDescription:\n"),
);
Self {
code: "OL-9999",
message: msg,
suggestion: Some("This is a bug. Please report it.".into()),
docs_url: Some(url),
}
}
}
impl fmt::Display for OlError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Error: {} ({})", self.message, self.code)?;
let has_suggestion = self.suggestion.is_some();
let has_docs = self.docs_url.is_some();
if has_suggestion || has_docs {
writeln!(f)?;
writeln!(f)?;
if let Some(ref s) = self.suggestion {
writeln!(f, " Suggestion: {s}")?;
}
if let Some(ref url) = self.docs_url {
write!(f, " Docs: {url}")?;
}
}
Ok(())
}
}
impl std::error::Error for OlError {}
fn percent_encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for c in input.chars() {
match c {
' ' => out.push_str("%20"),
'\n' => out.push_str("%0A"),
'\r' => out.push_str("%0D"),
'&' => out.push_str("%26"),
'=' => out.push_str("%3D"),
'#' => out.push_str("%23"),
other => out.push(other),
}
}
out
}
pub const ERR_EVENT_TOO_LARGE: &str = "OL-1002";
pub const ERR_EVENT_DEDUPED: &str = "OL-1003";
pub const ERR_INVALID_REGEX: &str = "OL-1100";
pub const ERR_CLOUD_UNREACHABLE: &str = "OL-1200";
pub const ERR_CLOUD_AUTH_FAILED: &str = "OL-1201";
pub const ERR_CLOUD_RATE_LIMITED: &str = "OL-1202";
pub const ERR_INVALID_CONFIG: &str = "OL-1300";
pub const ERR_MISSING_CONFIG_FIELD: &str = "OL-1301";
pub const ERR_HOOK_AGENT_NOT_FOUND: &str = "OL-1400";
pub const ERR_HOOK_WRITE_FAILED: &str = "OL-1401";
pub const ERR_HOOK_MALFORMED_JSONC: &str = "OL-1402";
pub const ERR_HOOK_CONFLICT: &str = "OL-1403";
pub const ERR_PORT_IN_USE: &str = "OL-1500";
pub const ERR_ALREADY_RUNNING: &str = "OL-1501";
pub const ERR_DAEMON_START_FAILED: &str = "OL-1502";
pub const ERR_VERSION_OUTDATED: &str = "OL-1503";
pub const ERR_NO_CREDENTIALS: &str = "OL-1600";
pub const ERR_TOKEN_EXPIRED: &str = "OL-1601";
pub const ERR_KEYCHAIN_UNAVAILABLE: &str = "OL-1602";
pub const ERR_KEYCHAIN_PERMISSION: &str = "OL-1603";
pub const ERR_FILE_FALLBACK_ERROR: &str = "OL-1604";
pub const ERR_AUTH_TIMEOUT: &str = "OL-1605";
pub const ERR_AUTH_FLOW_FAILED: &str = "OL-1606";
pub const ERR_AUTH_REVOCATION_FAILED: &str = "OL-1607";
pub fn keychain_suggestion() -> String {
if cfg!(target_os = "linux") {
"Install and start gnome-keyring or KWallet, or set OPENLATCH_API_KEY env var as fallback."
.into()
} else if cfg!(target_os = "windows") {
"Check Windows Credential Manager in Control Panel, or set OPENLATCH_API_KEY env var."
.into()
} else if cfg!(target_os = "macos") {
"Check Keychain Access.app permissions, or set OPENLATCH_API_KEY env var.".into()
} else {
"Set OPENLATCH_API_KEY env var as an alternative to OS keychain.".into()
}
}
pub const ERR_TELEMETRY_CONFIG_CORRUPT: &str = "OL-1700";
pub const ERR_TELEMETRY_WRITE_FAILED: &str = "OL-1701";
pub const ERR_TELEMETRY_POST_FAILED: &str = "OL-1702";
pub const ERR_TELEMETRY_INIT_FAILED: &str = "OL-1703";
pub const ERR_DOCTOR_JOURNAL_CORRUPT: &str = "OL-1800";
pub const ERR_DOCTOR_RESTORE_NO_JOURNAL: &str = "OL-1801";
pub const ERR_DOCTOR_RESCUE_WRITE_FAILED: &str = "OL-1802";
pub const ERR_DOCTOR_RESCUE_PARTIAL: &str = "OL-1803";
pub const ERR_HMAC_KEY_UNAVAILABLE: &str = "OL-1900";
pub const ERR_STATE_FILE_CORRUPT: &str = "OL-1901";
pub const ERR_STATE_FILE_WRITE_FAILED: &str = "OL-1902";
pub const ERR_MARKER_SCHEMA_UNSUPPORTED: &str = "OL-1903";
pub const ERR_CANONICALIZATION_FAILED: &str = "OL-1904";
pub const ERR_LEGACY_MARKER_DETECTED: &str = "OL-1905";
pub const ERR_BUG: &str = "OL-9999";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ol_error_display_full_format() {
let err = OlError::new(ERR_EVENT_TOO_LARGE, "Event body exceeds 1 MB limit")
.with_suggestion("Split the payload into smaller events")
.with_docs("https://docs.openlatch.ai/errors/OL-1002");
let output = format!("{err}");
assert!(
output.starts_with("Error: Event body exceeds 1 MB limit (OL-1002)"),
"Expected error header, got: {output}"
);
assert!(
output.contains("Suggestion: Split the payload"),
"Missing suggestion"
);
assert!(
output.contains("Docs: https://docs.openlatch.ai"),
"Missing docs URL"
);
}
#[test]
fn test_ol_error_display_no_suggestion() {
let err = OlError::new(ERR_EVENT_TOO_LARGE, "Event body exceeds 1 MB limit")
.with_docs("https://docs.openlatch.ai/errors/OL-1002");
let output = format!("{err}");
assert!(
!output.contains("Suggestion:"),
"Should not contain suggestion line: {output}"
);
assert!(
output.contains("Docs:"),
"Should still contain docs line: {output}"
);
}
#[test]
fn test_ol_error_display_no_docs_url() {
let err = OlError::new(ERR_INVALID_REGEX, "Invalid regex pattern")
.with_suggestion("Fix the regex in your config");
let output = format!("{err}");
assert!(
!output.contains("Docs:"),
"Should not contain docs line: {output}"
);
assert!(
output.contains("Suggestion:"),
"Should still contain suggestion: {output}"
);
}
#[test]
fn test_ol_error_display_no_optional_fields() {
let err = OlError::new(ERR_PORT_IN_USE, "Port 7443 is already in use");
let output = format!("{err}");
assert_eq!(output, "Error: Port 7443 is already in use (OL-1500)");
}
#[test]
fn test_error_code_constants_exist() {
assert_eq!(ERR_EVENT_TOO_LARGE, "OL-1002");
assert_eq!(ERR_INVALID_REGEX, "OL-1100");
assert_eq!(ERR_INVALID_CONFIG, "OL-1300");
assert_eq!(ERR_MISSING_CONFIG_FIELD, "OL-1301");
assert_eq!(ERR_EVENT_DEDUPED, "OL-1003");
assert_eq!(ERR_HOOK_CONFLICT, "OL-1403");
assert_eq!(ERR_PORT_IN_USE, "OL-1500");
assert_eq!(ERR_ALREADY_RUNNING, "OL-1501");
assert_eq!(ERR_DAEMON_START_FAILED, "OL-1502");
assert_eq!(ERR_VERSION_OUTDATED, "OL-1503");
assert_eq!(ERR_CLOUD_UNREACHABLE, "OL-1200");
assert_eq!(ERR_CLOUD_AUTH_FAILED, "OL-1201");
assert_eq!(ERR_CLOUD_RATE_LIMITED, "OL-1202");
assert_eq!(ERR_NO_CREDENTIALS, "OL-1600");
assert_eq!(ERR_TOKEN_EXPIRED, "OL-1601");
assert_eq!(ERR_KEYCHAIN_UNAVAILABLE, "OL-1602");
assert_eq!(ERR_KEYCHAIN_PERMISSION, "OL-1603");
assert_eq!(ERR_FILE_FALLBACK_ERROR, "OL-1604");
assert_eq!(ERR_HMAC_KEY_UNAVAILABLE, "OL-1900");
assert_eq!(ERR_STATE_FILE_CORRUPT, "OL-1901");
assert_eq!(ERR_STATE_FILE_WRITE_FAILED, "OL-1902");
assert_eq!(ERR_MARKER_SCHEMA_UNSUPPORTED, "OL-1903");
assert_eq!(ERR_CANONICALIZATION_FAILED, "OL-1904");
assert_eq!(ERR_LEGACY_MARKER_DETECTED, "OL-1905");
}
#[test]
fn test_keychain_suggestion_returns_non_empty_string() {
let suggestion = keychain_suggestion();
assert!(
!suggestion.is_empty(),
"keychain_suggestion must not be empty"
);
#[cfg(target_os = "windows")]
assert!(
suggestion.contains("Credential Manager") || suggestion.contains("OPENLATCH_API_KEY"),
"Windows suggestion must mention Credential Manager or env var: {suggestion}"
);
#[cfg(target_os = "macos")]
assert!(
suggestion.contains("Keychain") || suggestion.contains("OPENLATCH_API_KEY"),
"macOS suggestion must mention Keychain: {suggestion}"
);
#[cfg(target_os = "linux")]
assert!(
suggestion.contains("gnome-keyring") || suggestion.contains("OPENLATCH_API_KEY"),
"Linux suggestion must mention gnome-keyring or env var: {suggestion}"
);
}
#[test]
fn test_ol_error_implements_std_error() {
let err = OlError::new(ERR_EVENT_TOO_LARGE, "test");
let _boxed: Box<dyn std::error::Error> = Box::new(err);
}
#[test]
fn test_bug_report_sets_code_ol_9999() {
let err = OlError::bug_report("Unexpected panic in envelope module");
assert_eq!(err.code, "OL-9999");
assert!(err.suggestion.is_some());
assert!(err.docs_url.is_some());
let url = err.docs_url.unwrap();
assert!(url.contains("github.com/OpenLatch/openlatch-client/issues/new"));
}
#[test]
fn test_percent_encode_spaces_and_newlines() {
assert_eq!(percent_encode("hello world"), "hello%20world");
assert_eq!(percent_encode("line1\nline2"), "line1%0Aline2");
}
}