use std::fmt;
#[derive(Debug, Clone)]
pub struct SsError {
pub code: &'static str,
pub message: String,
pub suggestion: Option<String>,
pub docs_url: Option<String>,
exit: Option<i32>,
}
impl SsError {
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
suggestion: None,
docs_url: None,
exit: 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 with_exit_code(mut self, code: i32) -> Self {
self.exit = Some(code);
self
}
pub fn bug_report(message: impl Into<String>) -> Self {
let msg = message.into();
let url = format!(
"https://github.com/OpenLatch/saferskills/issues/new?title={}&body={}",
percent_encode(&msg),
percent_encode("Version: [auto]\nOS: [auto]\n\nDescription:\n"),
);
Self {
code: ERR_BUG,
message: msg,
suggestion: Some("This is a bug. Please report it.".into()),
docs_url: Some(url),
exit: Some(1),
}
}
pub fn exit_code(&self) -> i32 {
if let Some(c) = self.exit {
return c;
}
match self.numeric() {
Some(n) if (1100..1200).contains(&n) => 6,
Some(n) if (1200..1300).contains(&n) => 3,
_ => 1,
}
}
fn numeric(&self) -> Option<u32> {
self.code.strip_prefix("SS-E-").and_then(|s| s.parse().ok())
}
}
impl fmt::Display for SsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} ({})", self.message, self.code)?;
if self.suggestion.is_some() || self.docs_url.is_some() {
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 SsError {}
impl miette::Diagnostic for SsError {
fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
Some(Box::new(self.code))
}
fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
self.suggestion
.as_ref()
.map(|s| Box::new(s.clone()) as Box<dyn fmt::Display>)
}
fn url<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
self.docs_url
.as_ref()
.map(|u| Box::new(u.clone()) as Box<dyn fmt::Display>)
}
}
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_INVALID_CONFIG: &str = "SS-E-1000";
pub const ERR_CONFIG_WRITE_FAILED: &str = "SS-E-1001";
pub const ERR_STATE_CORRUPT: &str = "SS-E-1002";
pub const ERR_STATE_WRITE_FAILED: &str = "SS-E-1003";
pub const ERR_PERMISSION: &str = "SS-E-1004";
pub const ERR_NETWORK: &str = "SS-E-1100";
pub const ERR_API_STATUS: &str = "SS-E-1101";
pub const ERR_RATE_LIMITED: &str = "SS-E-1102";
pub const ERR_API_DECODE: &str = "SS-E-1103";
pub const ERR_ITEM_NOT_FOUND: &str = "SS-E-1200";
pub const ERR_CONFLICT: &str = "SS-E-1300";
pub const ERR_GATE_CANCELLED: &str = "SS-E-1301";
pub const ERR_NEEDS_FLAG: &str = "SS-E-1302";
pub const ERR_NO_AGENTS: &str = "SS-E-1400";
pub const ERR_UNKNOWN_AGENT: &str = "SS-E-1401";
pub const ERR_WRITE_ROLLBACK: &str = "SS-E-1500";
pub const ERR_WRITER_UNSUPPORTED: &str = "SS-E-1501";
pub const ERR_SCAN_SUBMIT: &str = "SS-E-1600";
pub const ERR_SCAN_TIMEOUT: &str = "SS-E-1601";
pub const ERR_POW_FAILED: &str = "SS-E-1602";
pub const ERR_SCAN_TARGET: &str = "SS-E-1603";
pub const ERR_PACK_SIGNATURE: &str = "SS-E-1604";
pub const ERR_AGENT_SCAN_FAILED: &str = "SS-E-1605";
pub const ERR_FAIL_ON_PARSE: &str = "SS-E-1606";
pub const ERR_BUG: &str = "SS-E-9999";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_full_format() {
let err = SsError::new(ERR_ITEM_NOT_FOUND, "Item not found in catalog: \"x\"")
.with_suggestion("Try: saferskills capability <github-url>")
.with_docs("https://saferskills.ai/docs/errors/SS-E-1200");
let out = format!("{err}");
assert!(out.starts_with("Item not found in catalog: \"x\" (SS-E-1200)"));
assert!(out.contains("Suggestion: Try: saferskills capability"));
assert!(out.contains("Docs: https://saferskills.ai"));
}
#[test]
fn display_no_optional_fields() {
let err = SsError::new(ERR_INVALID_CONFIG, "bad value");
assert_eq!(format!("{err}"), "bad value (SS-E-1000)");
}
#[test]
fn exit_code_derives_from_range() {
assert_eq!(SsError::new(ERR_NETWORK, "x").exit_code(), 6);
assert_eq!(SsError::new(ERR_RATE_LIMITED, "x").exit_code(), 6);
assert_eq!(SsError::new(ERR_ITEM_NOT_FOUND, "x").exit_code(), 3);
assert_eq!(SsError::new(ERR_INVALID_CONFIG, "x").exit_code(), 1);
}
#[test]
fn exit_code_override_wins() {
let err = SsError::new(ERR_PERMISSION, "denied").with_exit_code(4);
assert_eq!(err.exit_code(), 4);
}
#[test]
fn bug_report_sets_code_and_url() {
let err = SsError::bug_report("unexpected");
assert_eq!(err.code, ERR_BUG);
assert!(err
.docs_url
.unwrap()
.contains("OpenLatch/saferskills/issues/new"));
}
#[test]
fn percent_encode_escapes_structure() {
assert_eq!(percent_encode("a b"), "a%20b");
assert_eq!(percent_encode("x\ny"), "x%0Ay");
}
#[test]
fn implements_std_error() {
let err = SsError::new(ERR_BUG, "x");
let _boxed: Box<dyn std::error::Error> = Box::new(err);
}
}