#![cfg_attr(
not(test),
deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
)]
use serde_json::{json, Map, Value};
use super::ProvStamp;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct WorkbookToolError {
pub code: String,
pub field: Option<String>,
pub reason: String,
pub allowed: Option<Vec<String>>,
pub range: Option<(Value, Value)>,
pub required: Option<Vec<String>>,
}
impl WorkbookToolError {
#[must_use]
pub fn invalid_input(reason: impl Into<String>) -> Self {
Self::bare("invalid_input", reason)
}
#[must_use]
pub fn invalid_input_field(field: impl Into<String>, allowed: Vec<String>) -> Self {
let field = field.into();
let reason = format!("'{field}' is not a known input field");
Self::invalid_enum(field, allowed, reason)
}
#[must_use]
pub fn invalid_enum(
field: impl Into<String>,
allowed: Vec<String>,
reason: impl Into<String>,
) -> Self {
Self {
code: "invalid_input".to_string(),
reason: reason.into(),
field: Some(field.into()),
allowed: Some(allowed),
range: None,
required: None,
}
}
#[must_use]
pub fn missing_field(field: impl Into<String>, required: Vec<String>) -> Self {
Self {
code: "missing_field".to_string(),
field: Some(field.into()),
reason: "a required input is missing".to_string(),
allowed: None,
range: None,
required: Some(required),
}
}
#[must_use]
pub fn unsupported_option(field: impl Into<String>, allowed: Vec<String>) -> Self {
Self {
code: "unsupported_option".to_string(),
field: Some(field.into()),
reason: "the supplied override is not a known variable-tier parameter".to_string(),
allowed: Some(allowed),
range: None,
required: None,
}
}
#[must_use]
pub fn strict_constant_override(
field: impl Into<String>,
allowed_variable_keys: Vec<String>,
) -> Self {
let field = field.into();
Self {
code: "strict_constant_override".to_string(),
reason: format!(
"'{field}' is a BA-governed strict constant and cannot be overridden \
per-call; set a variable-tier parameter instead"
),
field: Some(field),
allowed: Some(allowed_variable_keys),
range: None,
required: None,
}
}
#[must_use]
pub fn unmappable_tool_name(raw: impl Into<String>) -> Self {
let raw = raw.into();
Self {
code: "invalid_tool_name".to_string(),
reason: format!(
"output Table name '{raw}' has no characters mappable to the MCP \
tool-name charset [a-z0-9_-]; give it at least one alphanumeric"
),
field: Some(raw),
allowed: None,
range: None,
required: None,
}
}
fn bare(code: &str, reason: impl Into<String>) -> Self {
Self {
code: code.to_string(),
field: None,
reason: reason.into(),
allowed: None,
range: None,
required: None,
}
}
}
#[must_use]
pub fn to_iserror_result(err: &WorkbookToolError, stamp: &ProvStamp) -> Value {
let mut obj = Map::new();
obj.insert("isError".to_string(), json!(true));
obj.insert("code".to_string(), json!(err.code));
obj.insert("reason".to_string(), json!(err.reason));
obj.insert("provenance".to_string(), stamp.to_json());
if let Some(field) = &err.field {
obj.insert("field".to_string(), json!(field));
}
if let Some(allowed) = &err.allowed {
obj.insert("allowed".to_string(), json!(allowed));
}
if let Some((min, max)) = &err.range {
obj.insert("range".to_string(), json!([min, max]));
}
if let Some(required) = &err.required {
obj.insert("required".to_string(), json!(required));
}
Value::Object(obj)
}
#[cfg(test)]
mod tests {
use super::*;
fn stamp() -> ProvStamp {
ProvStamp {
bundle_id: "tax-calc".to_string(),
version: "1.1.0".to_string(),
combined_hash: "a".repeat(64),
}
}
#[test]
fn iserror_envelope_carries_flag_code_and_provenance() {
let err = WorkbookToolError::invalid_input("bad number");
let v = to_iserror_result(&err, &stamp());
assert_eq!(
v["isError"],
json!(true),
"isError:true rides in the payload"
);
assert_eq!(v["code"], json!("invalid_input"));
assert_eq!(v["provenance"]["bundle_id"], json!("tax-calc"));
assert_eq!(v["provenance"]["version"], json!("1.1.0"));
assert_eq!(
v["provenance"]["combined_hash"].as_str().map(str::len),
Some(64)
);
let forbidden_key = ["work", "book_", "hash"].concat();
assert!(
v["provenance"].get(&forbidden_key).is_none(),
"the stamp must never carry the source-workbook hash key"
);
let prov = v["provenance"]
.as_object()
.expect("provenance is an object");
assert_eq!(
prov.len(),
3,
"stamp has exactly bundle_id/version/combined_hash"
);
}
#[test]
fn strict_constant_override_carries_allowed_alternatives() {
let err = WorkbookToolError::strict_constant_override(
"const_rate",
vec!["gross_income".to_string(), "deductions".to_string()],
);
let v = to_iserror_result(&err, &stamp());
assert_eq!(v["code"], json!("strict_constant_override"));
assert_eq!(v["field"], json!("const_rate"));
assert_eq!(v["allowed"], json!(["gross_income", "deductions"]));
}
#[test]
fn missing_field_carries_required() {
let err =
WorkbookToolError::missing_field("gross_income", vec!["gross_income".to_string()]);
let v = to_iserror_result(&err, &stamp());
assert_eq!(v["code"], json!("missing_field"));
assert_eq!(v["required"], json!(["gross_income"]));
}
#[test]
fn invalid_enum_carries_allowed_members() {
let err = WorkbookToolError::invalid_enum(
"filing_status",
vec!["single".to_string(), "married_joint".to_string()],
"not a member",
);
let v = to_iserror_result(&err, &stamp());
assert_eq!(v["code"], json!("invalid_input"));
assert_eq!(v["field"], json!("filing_status"));
assert_eq!(v["allowed"], json!(["single", "married_joint"]));
}
#[test]
fn optional_repair_fields_are_omitted_when_absent() {
let v = to_iserror_result(&WorkbookToolError::invalid_input("x"), &stamp());
assert!(v.get("field").is_none());
assert!(v.get("allowed").is_none());
assert!(v.get("range").is_none());
assert!(v.get("required").is_none());
}
#[test]
fn every_documented_code_is_an_emittable_stable_string() {
let cases = [
WorkbookToolError::invalid_input("x"),
WorkbookToolError::missing_field("f", vec![]),
WorkbookToolError::unsupported_option("f", vec![]),
WorkbookToolError::strict_constant_override("f", vec![]),
];
let expected_codes = [
"invalid_input",
"missing_field",
"unsupported_option",
"strict_constant_override",
];
for (err, expected) in cases.iter().zip(expected_codes) {
let v = to_iserror_result(err, &stamp());
assert_eq!(v["isError"], json!(true));
assert_eq!(v["code"], json!(expected), "code is a stable string");
}
}
}