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("NOT_FOUND") => {
CliError::new(ExitCode::Runtime, "NOT_FOUND", message)
}
Some("validation_refused") => {
CliError::new(ExitCode::ValidationFailed, "VALIDATION_REFUSED", message)
}
Some("authorization_refused") => {
CliError::new(ExitCode::Policy, "AUTHORIZATION_REFUSED", message)
}
Some("source_coordinate_used") => CliError::new(
ExitCode::Policy,
"SOURCE_COORDINATE_USED",
message,
)
.with_hint(
"restore the exact historical source coordinate; do not re-append it as new evidence",
),
Some("v1_migration_required") => {
CliError::new(ExitCode::Policy, "V1_MIGRATION_REQUIRED", message)
.with_hint("an owner or brain administrator must complete the explicit v2 migration")
}
Some("v2_sync_required") => {
CliError::new(ExitCode::Policy, "V2_SYNC_REQUIRED", message)
.with_hint("retry through dbmd sync, which negotiates link.md v2")
}
_ => 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::AliasRebindRequired { alias, from, to } => CliError::new(
ExitCode::Policy,
"ALIAS_REBIND_REQUIRED",
message,
)
.with_hint(format!(
"after verifying both canonical ids, run `dbmd sync {alias} rebind --from {from} --to {to}`"
))
.with_details(serde_json::json!({
"alias": alias,
"from": from,
"to": to,
})),
L::Conflict { .. } => CliError::new(ExitCode::Runtime, "SYNC_CONFLICT", message),
L::ConflictBundle { bundle, paths } => CliError::new(
ExitCode::Runtime,
"SYNC_CONFLICT",
message,
)
.with_hint(
"inspect .dbmd/conflicts/<bundle>/plan.json, then run `dbmd sync resolve <bundle> --keep-local`, `--take-remote`, or `--from <safe-file>`",
)
.with_details(serde_json::json!({
"class": "content_resolution_required",
"bundle": bundle,
"paths": paths,
})),
L::LocalPolicyTransition { .. } => {
CliError::new(ExitCode::Policy, "LOCAL_POLICY_RELAXED", message)
}
L::AssetWithdrawalRequired { paths } => CliError::new(
ExitCode::Policy,
"ASSET_WITHDRAWAL_REQUIRED",
message,
)
.with_hint(
"review custody for every listed path, then retry with --withdraw-from-hosting <path> and --withdraw-reason <company audit reason>",
)
.with_details(serde_json::json!({ "paths": paths })),
L::BulkPreviewRequired { preview } => CliError::new(
ExitCode::Policy,
"BULK_PREVIEW_REQUIRED",
message,
)
.with_hint(
"review error.details, then retry the same sync with --confirm-bulk <bulk_preview_id>:<bulk_preview_digest>",
)
.with_details(preview),
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 withdrawn_source_reuse_is_a_typed_policy_refusal() {
let error = CliError::from(dbmd_core::linkmd::LinkError::Http {
what: "v2 sync",
status: 409,
message: "a withdrawn source coordinate may only be restored from exact history".into(),
code: Some("source_coordinate_used".into()),
details: None,
});
assert_eq!(error.exit, ExitCode::Policy);
assert_eq!(error.code, "SOURCE_COORDINATE_USED");
assert!(error.hint.as_deref().unwrap().contains("exact historical"));
}
#[test]
fn hosted_asset_withdrawal_requires_explicit_audited_intent() {
let paths = vec!["sources/private/customer-export.csv".to_string()];
let error = CliError::from(dbmd_core::linkmd::LinkError::AssetWithdrawalRequired {
paths: paths.clone(),
});
assert_eq!(error.exit, ExitCode::Policy);
assert_eq!(error.code, "ASSET_WITHDRAWAL_REQUIRED");
assert_eq!(
error.to_json()["error"]["details"]["paths"],
serde_json::json!(paths)
);
assert!(error.hint.as_deref().unwrap().contains("--withdraw-reason"));
}
#[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");
}
#[test]
fn profile_cutover_refusals_have_stable_agent_codes() {
for (hub_code, cli_code) in [
("v1_migration_required", "V1_MIGRATION_REQUIRED"),
("v2_sync_required", "V2_SYNC_REQUIRED"),
] {
let error = CliError::from(dbmd_core::linkmd::LinkError::Http {
what: "sync push",
status: 426,
message: "profile upgrade required".to_string(),
code: Some(hub_code.to_string()),
details: None,
});
assert_eq!(error.exit, ExitCode::Policy);
assert_eq!(error.code, cli_code);
assert!(error.hint.is_some());
}
}
#[test]
fn link_not_found_has_a_stable_machine_code_without_scraping_prose() {
let error = CliError::from(dbmd_core::linkmd::LinkError::Http {
what: "resolve",
status: 404,
message: "record not found".to_string(),
code: Some("NOT_FOUND".to_string()),
details: None,
});
assert_eq!(error.exit, ExitCode::Runtime);
assert_eq!(error.code, "NOT_FOUND");
assert!(error.hint.is_none());
}
#[test]
fn bulk_preview_refusal_preserves_the_exact_structured_receipt() {
let preview = serde_json::json!({
"v": 2,
"code": "bulk_preview_created",
"bulk_preview_id": "01arz3ndektsv4rrffq69g5fav",
"bulk_preview_digest": "a".repeat(64),
"impact": { "deletes": 26 }
});
let error = CliError::from(dbmd_core::linkmd::LinkError::BulkPreviewRequired {
preview: preview.clone(),
});
assert_eq!(error.exit, ExitCode::Policy);
assert_eq!(error.code, "BULK_PREVIEW_REQUIRED");
assert_eq!(error.to_json()["error"]["details"], preview);
assert!(error.hint.as_deref().unwrap().contains("--confirm-bulk"));
}
}