use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum ExitCode {
Success = 0,
Runtime = 1,
Usage = 2,
NotAStore = 3,
Policy = 4,
Collision = 5,
ValidationFailed = 6,
NotImplemented = 64,
}
impl ExitCode {
pub fn code(self) -> i32 {
self as i32
}
}
#[derive(Debug, Clone)]
pub struct CliError {
pub exit: ExitCode,
pub code: &'static str,
pub message: String,
pub hint: Option<String>,
pub details: Option<serde_json::Value>,
}
impl CliError {
pub fn new(exit: ExitCode, code: &'static str, message: impl Into<String>) -> Self {
Self {
exit,
code,
message: message.into(),
hint: None,
details: None,
}
}
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.details = Some(details);
self
}
pub fn not_implemented(subcommand: &str) -> Self {
Self::new(
ExitCode::NotImplemented,
"NOT_IMPLEMENTED",
format!("`dbmd {subcommand}` is not implemented yet"),
)
.with_hint("this subcommand is recognized but its body is not implemented in this build")
}
pub fn runtime(message: impl Into<String>) -> Self {
Self::new(ExitCode::Runtime, "RUNTIME_ERROR", message)
}
pub fn to_json(&self) -> serde_json::Value {
let mut obj = serde_json::Map::new();
obj.insert(
"code".to_string(),
serde_json::Value::String(self.code.to_string()),
);
obj.insert(
"message".to_string(),
serde_json::Value::String(self.message.clone()),
);
if let Some(hint) = &self.hint {
obj.insert("hint".to_string(), serde_json::Value::String(hint.clone()));
}
if let Some(details) = &self.details {
obj.insert("details".to_string(), details.clone());
}
serde_json::json!({ "error": serde_json::Value::Object(obj) })
}
}
impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)?;
if let Some(hint) = &self.hint {
write!(f, "\n hint: {hint}")?;
}
Ok(())
}
}
impl std::error::Error for CliError {}
impl From<dbmd_core::Error> for CliError {
fn from(err: dbmd_core::Error) -> Self {
match err {
dbmd_core::Error::NotAStore(_) => {
CliError::new(ExitCode::NotAStore, "NOT_A_STORE", err.to_string())
.with_hint("run `dbmd` from inside a db.md store, or pass the store path")
}
dbmd_core::Error::Policy { code, message } => {
CliError::new(ExitCode::Policy, code, message)
}
dbmd_core::Error::Store(_) => {
CliError::new(ExitCode::Runtime, "STORE_ERROR", err.to_string())
}
dbmd_core::Error::Parse(_) => {
CliError::new(ExitCode::Runtime, "PARSE_ERROR", err.to_string())
}
dbmd_core::Error::Io(_) => {
CliError::new(ExitCode::Runtime, "IO_ERROR", err.to_string())
}
}
}
}
impl From<std::io::Error> for CliError {
fn from(err: std::io::Error) -> Self {
CliError::new(ExitCode::Runtime, "IO_ERROR", err.to_string())
}
}
impl From<dbmd_core::linkmd::LinkError> for CliError {
fn from(err: dbmd_core::linkmd::LinkError) -> Self {
use dbmd_core::linkmd::LinkError as L;
let message = err.to_string();
match err {
L::NoHub => CliError::new(ExitCode::Runtime, "NO_HUB", message),
L::NoCredential => CliError::new(ExitCode::Runtime, "NO_CREDENTIAL", message),
L::BadKey => CliError::new(ExitCode::Runtime, "BAD_CREDENTIAL", message),
L::UnboundCredential => CliError::new(ExitCode::Runtime, "UNBOUND_CREDENTIAL", message),
L::UnsafeHub { .. } => CliError::new(ExitCode::Runtime, "HUB_NOT_HTTPS", message),
L::Transport { .. } => CliError::new(ExitCode::Runtime, "HUB_UNREACHABLE", message),
L::Http { code, details, .. } => {
let mut e = match code.as_deref() {
Some("validation_refused") => {
CliError::new(ExitCode::ValidationFailed, "VALIDATION_REFUSED", message)
}
Some("authorization_refused") => {
CliError::new(ExitCode::Policy, "AUTHORIZATION_REFUSED", message)
}
_ => CliError::new(ExitCode::Runtime, "HUB_ERROR", message),
};
if let Some(details) = details {
e = e.with_details(details);
}
match code.as_deref() {
Some(c) if e.code == "HUB_ERROR" => e.with_hint(format!("hub error code: {c}")),
Some("validation_refused") if e.details.is_some() => {
e.with_hint("fix the permission-filtered issues in error.details and retry")
}
_ => e,
}
}
L::NotJson { .. } => CliError::new(ExitCode::Runtime, "HUB_NOT_JSON", message),
L::ResponseTooLarge { .. } => {
CliError::new(ExitCode::Runtime, "RESPONSE_TOO_LARGE", message)
}
L::BadAddress { .. } => CliError::new(ExitCode::Runtime, "BAD_ADDRESS", message)
.with_hint(
"addresses are `@brain`, `@brain/<record-id>`, or `@brain/<store-path>.md`",
),
L::BadGrantId { .. } => CliError::new(ExitCode::Runtime, "BAD_GRANT_ID", message)
.with_hint("copy the id from `dbmd grant list <brain>`"),
L::UnsafePath { .. } => CliError::new(ExitCode::Runtime, "UNSAFE_PATH", message),
L::PushTooLarge { .. } => CliError::new(ExitCode::Runtime, "PUSH_TOO_LARGE", message),
L::ProposeTooLarge { .. } => {
CliError::new(ExitCode::Runtime, "PROPOSE_TOO_LARGE", message)
}
L::NotUtf8 { .. } => CliError::new(ExitCode::Runtime, "NOT_UTF8", message),
L::InvalidPack { .. } => CliError::new(ExitCode::Runtime, "INVALID_PACK", message),
L::InvalidFeed { .. } => CliError::new(ExitCode::Runtime, "INVALID_FEED", message),
L::Conflict { .. } => CliError::new(ExitCode::Runtime, "SYNC_CONFLICT", message),
L::LocalPolicyTransition { .. } => {
CliError::new(ExitCode::Policy, "LOCAL_POLICY_RELAXED", message)
}
L::ScopedProjectionModified => {
CliError::new(ExitCode::Policy, "SCOPED_PROJECTION_MODIFIED", message)
}
L::ScopedViewChanged => CliError::new(ExitCode::Policy, "SCOPED_VIEW_CHANGED", message),
L::BrainUnavailable => CliError::new(ExitCode::Policy, "BRAIN_UNAVAILABLE", message),
L::RemoteAdvancedDuringSync => {
CliError::new(ExitCode::Runtime, "REMOTE_ADVANCED_DURING_SYNC", message)
}
L::UnsupportedPlatform { .. } => {
CliError::new(ExitCode::Runtime, "UNSUPPORTED_PLATFORM", message)
}
L::BadAgentKey { .. } => CliError::new(ExitCode::Runtime, "BAD_AGENT_KEY", message)
.with_hint("mint a key with `dbmd key generate --out <file>`"),
L::Io(_) => CliError::new(ExitCode::Runtime, "IO_ERROR", message),
L::Store(_) => CliError::new(ExitCode::Runtime, "STORE_ERROR", message),
}
}
}
pub type CliResult = std::result::Result<(), CliError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn permission_filtered_hub_validation_details_keep_a_stable_contract() {
let details = serde_json::json!({
"issues": [{
"code": "SCHEMA_MISSING_REQUIRED",
"file": "records/contacts/new.md",
"key": "email"
}]
});
let error = CliError::from(dbmd_core::linkmd::LinkError::Http {
what: "v2 sync push",
status: 422,
message: "mutation introduces db.md validation errors".to_string(),
code: Some("validation_refused".to_string()),
details: Some(details.clone()),
});
assert_eq!(error.exit, ExitCode::ValidationFailed);
assert_eq!(error.code, "VALIDATION_REFUSED");
assert_eq!(error.to_json()["error"]["details"], details);
assert!(error.hint.as_deref().unwrap().contains("error.details"));
}
#[test]
fn hub_authorization_refusal_is_a_policy_exit() {
let error = CliError::from(dbmd_core::linkmd::LinkError::Http {
what: "v2 sync push",
status: 403,
message: "mutation authority refused".to_string(),
code: Some("authorization_refused".to_string()),
details: None,
});
assert_eq!(error.exit, ExitCode::Policy);
assert_eq!(error.code, "AUTHORIZATION_REFUSED");
}
}