use std::borrow::Cow;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{Error, ValidationKind};
const fn slug_or(over: Option<&'static str>, default: &'static str) -> &'static str {
match over {
Some(s) => s,
None => default,
}
}
const fn validation_slug(kind: ValidationKind) -> &'static str {
match kind {
ValidationKind::EmptyLpnId => {
slug_or(option_env!("NSIP_SLUG_EMPTY_LPN_ID"), "cli/empty-lpn-id")
},
ValidationKind::InvalidBreedId => slug_or(
option_env!("NSIP_SLUG_INVALID_BREED_ID"),
"cli/invalid-breed-id",
),
ValidationKind::PageRange => slug_or(option_env!("NSIP_SLUG_PAGE_RANGE"), "cli/page-range"),
ValidationKind::EmptySearch => {
slug_or(option_env!("NSIP_SLUG_EMPTY_SEARCH"), "cli/empty-search")
},
ValidationKind::CompareArity => {
slug_or(option_env!("NSIP_SLUG_COMPARE_ARITY"), "cli/compare-arity")
},
ValidationKind::MissingArgument => slug_or(
option_env!("NSIP_SLUG_MISSING_ARGUMENT"),
"mcp/missing-argument",
),
ValidationKind::UnknownResource => slug_or(
option_env!("NSIP_SLUG_UNKNOWN_RESOURCE"),
"mcp/unknown-resource",
),
ValidationKind::InvalidCursor => slug_or(
option_env!("NSIP_SLUG_INVALID_CURSOR"),
"mcp/invalid-cursor",
),
ValidationKind::UnknownTransport => slug_or(
option_env!("NSIP_SLUG_UNKNOWN_TRANSPORT"),
"cli/unknown-transport",
),
ValidationKind::Other => slug_or(option_env!("NSIP_SLUG_VALIDATION"), "cli/validation"),
}
}
const fn validation_title(kind: ValidationKind) -> &'static str {
match kind {
ValidationKind::EmptyLpnId => "LPN ID must not be empty",
ValidationKind::InvalidBreedId => "Breed ID is invalid",
ValidationKind::PageRange => "Pagination parameter out of range",
ValidationKind::EmptySearch => "Search request has no filter",
ValidationKind::CompareArity => "Comparison requires 2 to 5 animals",
ValidationKind::MissingArgument => "Required argument is missing",
ValidationKind::UnknownResource => "Unknown resource URI",
ValidationKind::InvalidCursor => "Invalid pagination cursor",
ValidationKind::UnknownTransport => "Unknown MCP transport",
ValidationKind::Other => "Invalid input parameters",
}
}
fn validation_fix(kind: ValidationKind, message: &str) -> String {
match kind {
ValidationKind::EmptyLpnId => "provide a non-empty LPN ID".to_owned(),
ValidationKind::InvalidBreedId => {
"provide a positive integer breed id (see the breed_groups tool)".to_owned()
},
ValidationKind::PageRange => "use page >= 1 and page_size between 1 and 100".to_owned(),
ValidationKind::EmptySearch => {
"provide a non-empty query (an LPN ID or name) or at least one search filter".to_owned()
},
ValidationKind::CompareArity => "pass between 2 and 5 LPN IDs".to_owned(),
ValidationKind::MissingArgument => format!("provide the required argument: {message}"),
ValidationKind::UnknownResource => {
"use a documented nsip:// resource URI (see nsip://glossary)".to_owned()
},
ValidationKind::InvalidCursor => {
"restart pagination without a cursor (begin from the first page)".to_owned()
},
ValidationKind::UnknownTransport => "use --transport stdio or --transport http".to_owned(),
ValidationKind::Other => format!("correct the input and retry: {message}"),
}
}
const TYPE_URI_BASE: &str = env!("NSIP_ERROR_TYPE_URI_BASE");
const MAX_DETAIL_LEN: usize = 480;
fn truncate_detail(detail: &str) -> String {
if detail.len() <= MAX_DETAIL_LEN {
return detail.to_owned();
}
let mut end = MAX_DETAIL_LEN;
while !detail.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &detail[..end])
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProblemDetails {
#[serde(rename = "type")]
pub type_uri: String,
pub title: String,
pub status: u16,
pub detail: String,
pub instance: String,
pub exit_code: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggested_fix: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub code_actions: Vec<CodeAction>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_after: Option<RetryAfter>,
pub docs_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeAction {
pub title: String,
pub kind: Cow<'static, str>,
pub edit: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_preferred: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum RetryAfter {
Seconds(u32),
Timestamp(String),
}
impl Error {
#[must_use]
pub const fn slug_path(&self) -> &'static str {
match self {
Self::Validation { kind, .. } => validation_slug(*kind),
Self::Api { .. } => slug_or(option_env!("NSIP_SLUG_API_ERROR"), "api/error"),
Self::NotFound(_) => slug_or(option_env!("NSIP_SLUG_API_NOT_FOUND"), "api/not-found"),
Self::Timeout { .. } => slug_or(option_env!("NSIP_SLUG_API_TIMEOUT"), "api/timeout"),
Self::Connection { .. } => {
slug_or(option_env!("NSIP_SLUG_API_CONNECTION"), "api/connection")
},
Self::Parse { .. } => slug_or(
option_env!("NSIP_SLUG_API_UPSTREAM_PARSE"),
"api/upstream-parse",
),
}
}
#[must_use]
pub fn type_uri(&self) -> String {
format!("{TYPE_URI_BASE}/{}.md", self.slug_path())
}
#[must_use]
pub const fn title(&self) -> &'static str {
match self {
Self::Validation { kind, .. } => validation_title(*kind),
Self::Api { .. } => "Upstream API returned an error",
Self::NotFound(_) => "Requested resource was not found",
Self::Timeout { .. } => "Request to the NSIP API timed out",
Self::Connection { .. } => "Could not connect to the NSIP API",
Self::Parse { .. } => "Could not parse the NSIP API response",
}
}
#[must_use]
pub const fn exit_code(&self) -> i32 {
self.exit_and_status().0
}
#[must_use]
pub const fn status_code(&self) -> u16 {
self.exit_and_status().1
}
const fn exit_and_status(&self) -> (i32, u16) {
match self {
Self::Validation { .. } => (1, 400),
Self::NotFound(_) => (1, 404),
Self::Parse { .. } => (3, 502),
Self::Timeout { .. } => (75, 504),
Self::Connection { .. } => (75, 503),
Self::Api { status, .. } => {
if *status == 429 || *status >= 500 {
(75, *status)
} else {
(1, *status)
}
},
}
}
#[must_use]
pub fn suggested_fix(&self) -> Option<String> {
let s = match self {
Self::Validation { kind, message } => return Some(validation_fix(*kind, message)),
Self::NotFound(_) => {
"verify the identifier exists in the NSIP database (try `nsip search`)".to_owned()
},
Self::Timeout { .. } => {
"retry the request; increase the client timeout if this persists".to_owned()
},
Self::Connection { .. } => {
"check network connectivity to nsipsearch.nsip.org and retry".to_owned()
},
Self::Api { status, .. } if *status == 429 => {
"wait for the retry_after interval before retrying".to_owned()
},
Self::Api { status, .. } if *status >= 500 => {
"the NSIP API is failing; retry after a short delay".to_owned()
},
_ => return None,
};
Some(s)
}
#[must_use]
pub fn retry_after(&self) -> Option<RetryAfter> {
match self {
Self::Api { retry_after, .. }
| Self::Timeout { retry_after, .. }
| Self::Connection { retry_after, .. } => retry_after.clone(),
Self::Validation { .. } | Self::NotFound(_) | Self::Parse { .. } => None,
}
}
#[must_use]
pub fn to_problem_details(&self, command: &str) -> ProblemDetails {
let type_uri = self.type_uri();
let instance = format!("urn:nsip:{command}:{}", Uuid::new_v4());
ProblemDetails {
docs_url: type_uri.clone(),
type_uri,
title: self.title().to_owned(),
status: self.status_code(),
detail: truncate_detail(&self.to_string()),
instance,
exit_code: self.exit_code(),
suggested_fix: self.suggested_fix(),
code_actions: Vec::new(),
retry_after: self.retry_after(),
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "tests may panic on setup failure"
)]
mod tests {
use super::*;
fn all_variants() -> Vec<Error> {
vec![
Error::validation("bad breed_id"),
Error::api(404, "missing"),
Error::api(429, "slow down"),
Error::api(503, "upstream down"),
Error::not_found("LPN 999"),
Error::timeout("30s exceeded"),
Error::connection("refused"),
Error::parse("bad json"),
]
}
#[test]
fn envelope_populated_for_every_variant() {
let mut seen = std::collections::HashSet::new();
for err in all_variants() {
let pd = err.to_problem_details("test");
assert!(
pd.type_uri.starts_with(
"https://github.com/zircote/nsip/blob/main/docs/reference/errors/"
),
"type_uri: {}",
pd.type_uri
);
assert_eq!(&pd.type_uri[pd.type_uri.len() - 3..], ".md");
assert!(!pd.title.is_empty());
assert!(pd.status >= 400);
assert!(!pd.detail.is_empty());
assert!(pd.instance.starts_with("urn:nsip:test:"));
assert!(pd.exit_code > 0);
assert_eq!(pd.docs_url, pd.type_uri);
assert!(
seen.insert(pd.instance.clone()),
"instance URN must be unique per call"
);
}
}
#[test]
fn exit_and_status_map_matches_catalog() {
assert_eq!(Error::validation("x").exit_code(), 1);
assert_eq!(Error::validation("x").status_code(), 400);
assert_eq!(Error::not_found("x").status_code(), 404);
assert_eq!(Error::parse("x").exit_code(), 3);
assert_eq!(Error::parse("x").status_code(), 502);
assert_eq!(Error::timeout("x").exit_code(), 75);
assert_eq!(Error::timeout("x").status_code(), 504);
assert_eq!(Error::connection("x").exit_code(), 75);
assert_eq!(Error::connection("x").status_code(), 503);
assert_eq!(Error::api(400, "x").exit_code(), 1);
assert_eq!(Error::api(429, "x").exit_code(), 75);
assert_eq!(Error::api(503, "x").exit_code(), 75);
assert_eq!(Error::api(418, "x").status_code(), 418);
}
#[test]
fn retry_after_only_on_transient() {
let transient = Error::Api {
status: 429,
message: "rate limited".to_owned(),
retry_after: Some(RetryAfter::Seconds(30)),
source: None,
};
let pd = transient.to_problem_details("search");
assert_eq!(pd.retry_after, Some(RetryAfter::Seconds(30)));
assert!(
Error::validation("x")
.to_problem_details("x")
.retry_after
.is_none()
);
assert!(
Error::not_found("x")
.to_problem_details("x")
.retry_after
.is_none()
);
assert!(
Error::parse("x")
.to_problem_details("x")
.retry_after
.is_none()
);
}
#[test]
fn json_is_compact() {
for err in all_variants() {
let pd = err.to_problem_details("cmd");
let json = serde_json::to_string(&pd).expect("serialize");
assert!(
!json.contains("\"code_actions\""),
"empty code_actions should be omitted: {json}"
);
assert!(
json.len() <= 1024,
"payload {} bytes exceeds 1 KB cap",
json.len()
);
}
}
#[test]
fn cause_chain_preserved() {
let io = std::io::Error::new(std::io::ErrorKind::TimedOut, "underlying");
let err = Error::Parse {
message: "failed to parse response".to_owned(),
source: Some(Box::new(io)),
};
let cause = std::error::Error::source(&err);
assert!(cause.is_some(), "Parse with source must expose source()");
assert!(cause.unwrap().to_string().contains("underlying"));
assert!(std::error::Error::source(&Error::validation("x")).is_none());
}
#[test]
fn every_validation_kind_maps_cleanly() {
let kinds = [
ValidationKind::EmptyLpnId,
ValidationKind::InvalidBreedId,
ValidationKind::PageRange,
ValidationKind::EmptySearch,
ValidationKind::CompareArity,
ValidationKind::MissingArgument,
ValidationKind::UnknownResource,
ValidationKind::InvalidCursor,
ValidationKind::UnknownTransport,
ValidationKind::Other,
];
let mut slugs = std::collections::HashSet::new();
for kind in kinds {
let err = Error::validation_kind(kind, "field");
let pd = err.to_problem_details("op");
let slug = err.slug_path();
assert!(
slug.starts_with("cli/") || slug.starts_with("mcp/"),
"{kind:?} slug: {slug}"
);
assert!(slugs.insert(slug), "duplicate slug for {kind:?}: {slug}");
assert_eq!(pd.status, 400);
assert_eq!(pd.exit_code, 1);
assert!(!pd.title.is_empty());
assert!(pd.suggested_fix.is_some(), "{kind:?} must have a fix");
assert!(pd.retry_after.is_none());
}
}
#[test]
fn retry_after_serde_forms() {
let secs = serde_json::to_string(&RetryAfter::Seconds(12)).unwrap();
assert_eq!(secs, "12");
let ts =
serde_json::to_string(&RetryAfter::Timestamp("2026-06-01T00:00:00Z".into())).unwrap();
assert_eq!(ts, "\"2026-06-01T00:00:00Z\"");
}
#[test]
fn type_uri_base_resolves_from_build_metadata() {
assert_eq!(
TYPE_URI_BASE,
"https://github.com/zircote/nsip/blob/main/docs/reference/errors"
);
}
#[test]
fn slug_or_prefers_override_then_default() {
assert_eq!(
slug_or(Some("errors/custom"), "api/timeout"),
"errors/custom"
);
assert_eq!(slug_or(None, "api/timeout"), "api/timeout");
}
#[test]
fn detail_truncated_keeps_envelope_under_cap() {
let err = Error::Api {
status: 500,
message: "x".repeat(5000),
retry_after: None,
source: None,
};
let pd = err.to_problem_details("date-updated");
assert!(
pd.detail.len() <= MAX_DETAIL_LEN + 4,
"detail not truncated: {} bytes",
pd.detail.len()
);
assert!(
pd.detail.ends_with('…'),
"truncated detail should end with an ellipsis: {}",
pd.detail
);
let json = serde_json::to_string(&pd).expect("serialize");
assert!(
json.len() <= 1024,
"envelope {} bytes exceeds 1 KB cap",
json.len()
);
}
#[test]
fn miette_url_matches_envelope_type_uri() {
use miette::Diagnostic as _;
for err in [
Error::api(500, "x"),
Error::not_found("x"),
Error::timeout("x"),
Error::connection("x"),
Error::parse("x"),
] {
let url = err.url().map(|u| u.to_string());
assert_eq!(
url.as_deref(),
Some(err.type_uri().as_str()),
"miette url and envelope type diverged for {err:?}"
);
}
let empty = Error::empty_lpn_id();
assert_eq!(
empty.url().map(|u| u.to_string()).as_deref(),
Some(
"https://github.com/zircote/nsip/blob/main/docs/reference/errors/cli/validation.md"
)
);
assert!(empty.type_uri().ends_with("/cli/empty-lpn-id.md"));
}
}