use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use thiserror::Error;
use trusty_common::uds::server::{CODE_INTERNAL_ERROR, CODE_INVALID_PARAMS, RpcError};
pub const CODE_NOT_FOUND: i64 = -32004;
pub const CODE_UNAVAILABLE: i64 = -32002;
pub const CODE_FORBIDDEN: i64 = -32003;
pub const CODE_UNPROCESSABLE: i64 = -32006;
pub const CODE_CONFLICT: i64 = -32009;
pub const CODE_UPSTREAM_FAILED: i64 = -32007;
pub const CODE_GONE: i64 = -32008;
pub const CODE_WORKSPACE_GONE: i64 = -32023;
pub const CODE_PANE_GONE: i64 = -32024;
pub fn rpc_code_for_status(status: u16) -> i64 {
match status {
400 => CODE_INVALID_PARAMS,
403 => CODE_FORBIDDEN,
404 => CODE_NOT_FOUND,
409 => CODE_CONFLICT,
422 => CODE_UNPROCESSABLE,
502 => CODE_UPSTREAM_FAILED,
503 => CODE_UNAVAILABLE,
_ => CODE_INTERNAL_ERROR,
}
}
#[derive(Debug, Error)]
pub enum DaemonError {
#[error("session not found: {id}")]
SessionNotFound {
id: String,
},
#[error("session not active: {id} (status: {status})")]
SessionNotActive {
id: String,
status: String,
},
#[error("overseer blocked: {reason}")]
OverseerBlocked {
reason: String,
},
#[error("tmux unavailable: {0}")]
TmuxUnavailable(String),
#[error("invalid request: {0}")]
InvalidRequest(String),
#[error("checkpoint not found: {id}")]
CheckpointNotFound {
id: String,
},
#[error("deliverable not found: {id}")]
DeliverableNotFound {
id: String,
},
#[error("milestone not found: {id}")]
MilestoneNotFound {
id: String,
},
#[error("no registered project matches repo_url {repo_url:?}; cannot scope deliverable")]
ProjectNotFoundForRepoUrl {
repo_url: String,
},
#[error(
"invalid status transition {from} \u{2192} {to}; legal next states from {from}: [{}]",
allowed.join(", ")
)]
InvalidTransition {
from: String,
to: String,
allowed: Vec<String>,
},
#[error("pair code invalid or expired")]
InvalidPairCode,
#[error("internal error: {0}")]
Internal(String),
#[error("service unavailable: {0}")]
ServiceUnavailable(String),
#[error("forbidden: {0}")]
Forbidden(String),
#[error("not found: {0}")]
NotFound(String),
#[error("unprocessable: {0}")]
Unprocessable(String),
#[error("upstream failed: {0}")]
UpstreamFailed(String),
}
impl DaemonError {
pub fn status(&self) -> StatusCode {
match self {
Self::SessionNotFound { .. }
| Self::CheckpointNotFound { .. }
| Self::DeliverableNotFound { .. }
| Self::MilestoneNotFound { .. }
| Self::ProjectNotFoundForRepoUrl { .. }
| Self::NotFound(_) => StatusCode::NOT_FOUND,
Self::SessionNotActive { .. } | Self::InvalidTransition { .. } => StatusCode::CONFLICT,
Self::OverseerBlocked { .. } | Self::Forbidden(_) => StatusCode::FORBIDDEN,
Self::InvalidRequest(_) | Self::InvalidPairCode => StatusCode::BAD_REQUEST,
Self::TmuxUnavailable(_) | Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
Self::Unprocessable(_) => StatusCode::UNPROCESSABLE_ENTITY,
Self::UpstreamFailed(_) => StatusCode::BAD_GATEWAY,
}
}
pub fn detail(&self) -> String {
match self {
Self::TmuxUnavailable(m)
| Self::InvalidRequest(m)
| Self::Internal(m)
| Self::ServiceUnavailable(m)
| Self::Forbidden(m)
| Self::NotFound(m)
| Self::Unprocessable(m)
| Self::UpstreamFailed(m) => m.clone(),
other => other.to_string(),
}
}
}
impl IntoResponse for DaemonError {
fn into_response(self) -> Response {
let status = self.status();
let body = match &self {
Self::InvalidTransition { from, to, allowed } => Json(serde_json::json!({
"error": self.to_string(),
"from": from,
"to": to,
"allowed_next": allowed,
})),
_ => Json(serde_json::json!({ "error": self.to_string() })),
};
(status, body).into_response()
}
}
impl From<DaemonError> for RpcError {
fn from(e: DaemonError) -> Self {
RpcError::new(rpc_code_for_status(e.status().as_u16()), e.to_string())
}
}
impl From<std::io::Error> for DaemonError {
fn from(e: std::io::Error) -> Self {
Self::Internal(e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_status_codes_map() {
assert_eq!(
DaemonError::SessionNotFound { id: "x".into() }.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
DaemonError::CheckpointNotFound { id: "x".into() }.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
DaemonError::SessionNotActive {
id: "x".into(),
status: "stopped".into(),
}
.status(),
StatusCode::CONFLICT
);
assert_eq!(
DaemonError::OverseerBlocked { reason: "x".into() }.status(),
StatusCode::FORBIDDEN
);
assert_eq!(
DaemonError::InvalidRequest("x".into()).status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
DaemonError::InvalidPairCode.status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
DaemonError::TmuxUnavailable("x".into()).status(),
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(
DaemonError::Internal("x".into()).status(),
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(
DaemonError::ServiceUnavailable("x".into()).status(),
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(
DaemonError::Forbidden("x".into()).status(),
StatusCode::FORBIDDEN
);
assert_eq!(
DaemonError::Unprocessable("x".into()).status(),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(
DaemonError::UpstreamFailed("x".into()).status(),
StatusCode::BAD_GATEWAY
);
assert_eq!(
DaemonError::DeliverableNotFound { id: "x".into() }.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
DaemonError::MilestoneNotFound { id: "x".into() }.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
DaemonError::ProjectNotFoundForRepoUrl {
repo_url: "x".into()
}
.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
DaemonError::InvalidTransition {
from: "proposed".into(),
to: "complete".into(),
allowed: vec!["in-progress".into()],
}
.status(),
StatusCode::CONFLICT
);
}
#[test]
fn rpc_error_codes_track_http_statuses() {
let cases: Vec<(DaemonError, i64)> = vec![
(
DaemonError::SessionNotFound { id: "x".into() },
CODE_NOT_FOUND,
),
(DaemonError::NotFound("x".into()), CODE_NOT_FOUND),
(
DaemonError::CheckpointNotFound { id: "x".into() },
CODE_NOT_FOUND,
),
(DaemonError::InvalidRequest("x".into()), CODE_INVALID_PARAMS),
(DaemonError::Forbidden("x".into()), CODE_FORBIDDEN),
(
DaemonError::SessionNotActive {
id: "x".into(),
status: "stopped".into(),
},
CODE_CONFLICT,
),
(DaemonError::Unprocessable("x".into()), CODE_UNPROCESSABLE),
(
DaemonError::UpstreamFailed("x".into()),
CODE_UPSTREAM_FAILED,
),
(
DaemonError::ServiceUnavailable("x".into()),
CODE_UNAVAILABLE,
),
(DaemonError::Internal("x".into()), CODE_INTERNAL_ERROR),
(
DaemonError::TmuxUnavailable("x".into()),
CODE_INTERNAL_ERROR,
),
];
for (err, expected) in cases {
let message = err.to_string();
let rpc: RpcError = err.into();
assert_eq!(rpc.code, expected, "wrong code for {message:?}");
assert_eq!(
rpc.message, message,
"the RPC message must be the HTTP body's message verbatim"
);
}
}
#[test]
fn not_found_variant_is_404() {
assert_eq!(
DaemonError::NotFound("profile `nope`".into()).status(),
StatusCode::NOT_FOUND
);
}
#[test]
fn invalid_transition_message_lists_allowed() {
let e = DaemonError::InvalidTransition {
from: "proposed".into(),
to: "complete".into(),
allowed: vec!["in-progress".into()],
};
let msg = e.to_string();
assert!(msg.contains("proposed"), "{msg}");
assert!(msg.contains("complete"), "{msg}");
assert!(msg.contains("in-progress"), "{msg}");
}
#[test]
fn detail_drops_the_display_prefix() {
let e = DaemonError::NotFound("project foo not found".into());
assert_eq!(e.to_string(), "not found: project foo not found");
assert_eq!(e.detail(), "project foo not found");
let e = DaemonError::SessionNotFound { id: "abc".into() };
assert_eq!(e.detail(), e.to_string());
}
#[test]
fn error_messages_include_context() {
let e = DaemonError::SessionNotFound {
id: "tmpm-blue-fox".into(),
};
assert!(e.to_string().contains("tmpm-blue-fox"));
let e = DaemonError::SessionNotActive {
id: "abc".into(),
status: "stopped".into(),
};
assert!(e.to_string().contains("abc"));
assert!(e.to_string().contains("stopped"));
}
#[test]
fn project_not_found_message_names_repo_url_not_deliverable() {
let e = DaemonError::ProjectNotFoundForRepoUrl {
repo_url: "/local/path/checkout".into(),
};
let msg = e.to_string();
assert!(msg.contains("/local/path/checkout"), "{msg}");
assert!(msg.contains("project"), "{msg}");
assert!(
!msg.contains("deliverable not found"),
"must not read as a deliverable-not-found error: {msg}"
);
}
}