#![cfg_attr(
not(test),
deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
)]
use std::collections::BTreeMap;
use serde::Deserialize;
use serde_json::Value;
use pmcp_workbook_runtime::{
is_computed, is_strict_constant, CellMap, CellRole, CellValue, Dtype, InputTier, Manifest, Role,
};
use super::error::WorkbookToolError;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CalculateInput {
#[serde(default)]
pub inputs: BTreeMap<String, Value>,
#[serde(default)]
pub overrides: BTreeMap<String, Value>,
}
#[derive(Debug, Clone)]
pub struct ValidatedInput {
pub seeds: BTreeMap<String, Value>,
pub accepted_overrides: Vec<String>,
pub canonical_dto: Value,
}
#[allow(clippy::result_large_err)]
pub fn validate_input(
args: Value,
manifest: &Manifest,
cell_map: &CellMap,
) -> Result<ValidatedInput, WorkbookToolError> {
let input: CalculateInput = serde_json::from_value(args)
.map_err(|e| WorkbookToolError::invalid_input(format!("invalid arguments: {e}")))?;
let mut seeds = seed_tier_defaults(manifest);
seed_supplied_inputs(&input.inputs, manifest, cell_map, &mut seeds)?;
let accepted_overrides = seed_accepted_overrides(&input.overrides, manifest, &mut seeds)?;
let canonical_dto = serde_json::json!({
"inputs": &input.inputs,
"overrides": &input.overrides,
});
Ok(ValidatedInput {
seeds,
accepted_overrides,
canonical_dto,
})
}
fn seed_tier_defaults(manifest: &Manifest) -> BTreeMap<String, Value> {
let mut seeds: BTreeMap<String, Value> = BTreeMap::new();
for role in &manifest.cells {
if matches!(role.role, Role::Input) {
if let Some(default) = tier_default(role) {
seeds.insert(role.cell.clone(), default);
}
}
}
seeds
}
#[allow(clippy::result_large_err)]
fn seed_supplied_inputs(
inputs: &BTreeMap<String, Value>,
manifest: &Manifest,
cell_map: &CellMap,
seeds: &mut BTreeMap<String, Value>,
) -> Result<(), WorkbookToolError> {
for (key, value) in inputs {
let entry = cell_map
.inputs
.iter()
.find(|e| &e.json_key == key)
.ok_or_else(|| {
WorkbookToolError::invalid_input_field(key.clone(), known_input_keys(cell_map))
})?;
let role =
pmcp_workbook_runtime::role_for_cell(manifest, &entry.seed_coord).ok_or_else(|| {
WorkbookToolError::invalid_input(format!(
"internal: input '{key}' maps to {} which has no manifest role",
entry.seed_coord
))
})?;
check_value_dtype(role, key, value)?;
seeds.insert(entry.seed_coord.clone(), value.clone());
}
Ok(())
}
#[allow(clippy::result_large_err)]
fn seed_accepted_overrides(
overrides: &BTreeMap<String, Value>,
manifest: &Manifest,
seeds: &mut BTreeMap<String, Value>,
) -> Result<Vec<String>, WorkbookToolError> {
let mut accepted_overrides = Vec::new();
for (key, value) in overrides {
let role = classify_override(manifest, key)?;
check_value_dtype(role, key, value)?;
seeds.insert(role.cell.clone(), value.clone());
accepted_overrides.push(key.clone());
}
Ok(accepted_overrides)
}
#[allow(clippy::result_large_err)]
fn classify_override<'a>(
manifest: &'a Manifest,
key: &str,
) -> Result<&'a CellRole, WorkbookToolError> {
match find_role_by_key(manifest, key) {
Some(r) if is_strict_constant(r) => Err(WorkbookToolError::strict_constant_override(
key.to_string(),
variable_tier_keys(manifest),
)),
Some(r) if is_computed(r) => Err(WorkbookToolError::unsupported_option(
key.to_string(),
variable_tier_keys(manifest),
)),
Some(r) => Ok(r),
None => Err(WorkbookToolError::unsupported_option(
key.to_string(),
variable_tier_keys(manifest),
)),
}
}
fn tier_default(role: &CellRole) -> Option<Value> {
match &role.tier {
Some(InputTier::Variable { default })
| Some(InputTier::BoundedVariable { default, .. }) => cell_value_to_json(default),
None => None,
}
}
fn cell_value_to_json(v: &CellValue) -> Option<Value> {
match v {
CellValue::Number(n) => serde_json::Number::from_f64(*n).map(Value::Number),
CellValue::Text(s) => Some(Value::String(s.clone())),
CellValue::Bool(b) => Some(Value::Bool(*b)),
CellValue::Empty => Some(Value::Null),
CellValue::Error(_) => None,
}
}
#[allow(clippy::result_large_err)]
fn check_value_dtype(role: &CellRole, field: &str, value: &Value) -> Result<(), WorkbookToolError> {
if value.is_null() {
return Ok(());
}
let ok = match role.dtype {
Dtype::Number => value.is_number(),
Dtype::Text => value.is_string(),
Dtype::Bool => value.is_boolean(),
};
if !ok {
let expected = super::schema::dtype_json_type(role.dtype);
return Err(WorkbookToolError::invalid_input(format!(
"input '{field}' must be a {expected} (cell {} is declared {expected})",
role.cell
)));
}
if let Some(allowed) = &role.allowed_values {
let is_member = value
.as_str()
.is_some_and(|s| allowed.iter().any(|a| a == s));
if !is_member {
return Err(WorkbookToolError::invalid_enum(
field,
allowed.clone(),
format!(
"input '{field}' must be one of the allowed values \
(cell {} is a closed enum)",
role.cell
),
));
}
}
Ok(())
}
fn find_role_by_key<'a>(manifest: &'a Manifest, key: &str) -> Option<&'a CellRole> {
manifest
.cells
.iter()
.find(|r| r.name.as_deref() == Some(key) || r.cell == key)
}
pub(crate) fn variable_tier_keys(manifest: &Manifest) -> Vec<String> {
manifest
.cells
.iter()
.filter(|r| !is_strict_constant(r) && !is_computed(r))
.filter_map(|r| r.name.clone().or_else(|| Some(r.cell.clone())))
.collect()
}
fn known_input_keys(cell_map: &CellMap) -> Vec<String> {
cell_map.inputs.iter().map(|e| e.json_key.clone()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use pmcp_workbook_runtime::{CellEntry, CellMap, Tool};
use proptest::prelude::*;
use serde_json::json;
fn input_role(
cell: &str,
dtype: Dtype,
name: &str,
tier: Option<InputTier>,
allowed: Option<Vec<String>>,
) -> CellRole {
CellRole {
cell: cell.to_string(),
role: Role::Input,
name: Some(name.to_string()),
unit: None,
meaning: None,
dtype,
colour_evidence: None,
source: "test".to_string(),
notes: None,
tier,
allowed_values: allowed,
}
}
fn manifest() -> Manifest {
Manifest {
schema_version: 1,
workflow: "tax-calc".to_string(),
workbook_hash: None,
ratified: true,
ratified_by: None,
ratified_at: None,
cells: vec![
input_role(
"1_Inputs!B2",
Dtype::Number,
"gross_income",
Some(InputTier::Variable {
default: CellValue::Number(0.0),
}),
None,
),
input_role(
"1_Inputs!B3",
Dtype::Text,
"filing_status",
Some(InputTier::Variable {
default: CellValue::Text("single".to_string()),
}),
Some(vec![
"single".to_string(),
"married_joint".to_string(),
"head_of_household".to_string(),
]),
),
CellRole {
cell: "2_Rates!B2".to_string(),
role: Role::Constant,
name: Some("const_rate".to_string()),
unit: None,
meaning: None,
dtype: Dtype::Number,
colour_evidence: None,
source: "test".to_string(),
notes: None,
tier: None,
allowed_values: None,
},
],
loop_block: None,
governed_data: vec![],
changelog: vec![],
capability_calls: vec![],
annotations: vec![],
}
}
fn cell_map() -> CellMap {
CellMap {
inputs: vec![
CellEntry {
json_key: "gross_income".to_string(),
seed_coord: "1_Inputs!B2".to_string(),
unit: Some("USD".to_string()),
},
CellEntry {
json_key: "filing_status".to_string(),
seed_coord: "1_Inputs!B3".to_string(),
unit: None,
},
],
tools: vec![Tool {
name: "calculate".to_string(),
description: None,
input_keys: Vec::new(),
outputs: vec![CellEntry {
json_key: "tax_owed".to_string(),
seed_coord: "3_Outputs!B3".to_string(),
unit: Some("USD".to_string()),
}],
oracle: std::collections::BTreeMap::new(),
}],
}
}
#[test]
fn valid_inputs_seed_their_cells() {
let args = json!({ "inputs": { "gross_income": 60000.0 } });
let v = validate_input(args, &manifest(), &cell_map()).expect("valid");
assert_eq!(v.seeds.get("1_Inputs!B2"), Some(&json!(60000.0)));
}
#[test]
fn unknown_top_level_field_is_rejected() {
let args = json!({ "bogus": 1 });
let err = validate_input(args, &manifest(), &cell_map())
.expect_err("unknown top-level field rejected (deny_unknown_fields)");
assert_eq!(err.code, "invalid_input");
}
#[test]
fn unknown_input_key_is_invalid_input_with_allowed() {
let args = json!({ "inputs": { "not_a_real_input": 1 } });
let err = validate_input(args, &manifest(), &cell_map())
.expect_err("an unknown input key is rejected (WR-05)");
assert_eq!(err.code, "invalid_input");
assert_eq!(err.field.as_deref(), Some("not_a_real_input"));
assert!(err.allowed.is_some(), "carries the known input keys");
}
#[test]
fn cell_map_entry_without_manifest_role_is_rejected_fail_closed() {
let mut cm = cell_map();
cm.inputs.push(CellEntry {
json_key: "orphan".to_string(),
seed_coord: "9_Nowhere!Z99".to_string(),
unit: None,
});
let args = json!({ "inputs": { "orphan": "oops" } });
let err = validate_input(args, &manifest(), &cm)
.expect_err("a cell_map entry with no manifest role is rejected (WR-05)");
assert_eq!(err.code, "invalid_input");
assert!(
err.reason.contains("no manifest role") && err.reason.contains("9_Nowhere!Z99"),
"the error names the internal-consistency failure: {}",
err.reason
);
}
#[test]
fn non_numeric_value_for_number_cell_is_rejected() {
let args = json!({ "inputs": { "gross_income": "oops" } });
let err = validate_input(args, &manifest(), &cell_map())
.expect_err("a non-numeric value for a numeric input is rejected (WR-01)");
assert_eq!(err.code, "invalid_input");
assert!(err.reason.contains("number"), "names the expected type");
}
#[test]
fn out_of_enum_value_is_rejected_with_allowed() {
let args = json!({ "inputs": { "filing_status": "alien" } });
let err = validate_input(args, &manifest(), &cell_map())
.expect_err("an out-of-enum value is rejected (WR-02)");
assert_eq!(err.code, "invalid_input");
assert_eq!(err.field.as_deref(), Some("filing_status"));
assert_eq!(
err.allowed,
Some(vec![
"single".to_string(),
"married_joint".to_string(),
"head_of_household".to_string(),
]),
"the allowed enum members live in the error"
);
}
#[test]
fn in_enum_value_passes_the_gate() {
for legal in ["single", "married_joint", "head_of_household"] {
let args = json!({ "inputs": { "filing_status": legal } });
let v = validate_input(args, &manifest(), &cell_map())
.expect("an in-enum value passes the membership gate");
assert_eq!(v.seeds.get("1_Inputs!B3"), Some(&json!(legal)));
}
}
#[test]
fn non_string_value_on_string_enum_is_rejected_fail_closed() {
let mut m = manifest();
m.cells[1].dtype = Dtype::Number;
let args = json!({ "inputs": { "filing_status": 42 } });
let err = validate_input(args, &m, &cell_map())
.expect_err("a non-string value on a string-enum input is rejected (WR-02)");
assert_eq!(err.code, "invalid_input");
assert_eq!(err.field.as_deref(), Some("filing_status"));
assert!(
err.allowed.is_some(),
"still carries the allowed repair field"
);
}
#[test]
fn strict_constant_override_is_rejected() {
let args = json!({ "overrides": { "const_rate": 0.40 } });
let err = validate_input(args, &manifest(), &cell_map())
.expect_err("a strict-constant override is rejected (V4)");
assert_eq!(err.code, "strict_constant_override");
assert_eq!(err.field.as_deref(), Some("const_rate"));
assert!(err.allowed.is_some(), "carries variable-tier alternatives");
}
#[test]
fn override_naming_no_cell_is_unsupported_option() {
let args = json!({ "overrides": { "ghost_param": 1 } });
let err = validate_input(args, &manifest(), &cell_map())
.expect_err("an override naming no manifest cell is unsupported_option");
assert_eq!(err.code, "unsupported_option");
}
fn manifest_with_computed_cells() -> Manifest {
let mut m = manifest();
for (cell, role, name) in [
("3_Outputs!B3", Role::Output, "tax_owed"),
("3_Outputs!B2", Role::Formula, "taxable_income"),
] {
m.cells.push(CellRole {
role,
unit: Some("USD".to_string()),
..input_role(cell, Dtype::Number, name, None, None)
});
}
m
}
#[test]
fn override_on_computed_cell_is_rejected_unsupported_option() {
for key in ["tax_owed", "3_Outputs!B3", "taxable_income", "3_Outputs!B2"] {
let args = json!({ "overrides": { key: 999.0 } });
let err = validate_input(args, &manifest_with_computed_cells(), &cell_map())
.expect_err("a computed-cell override is rejected (WR-02)");
assert_eq!(err.code, "unsupported_option", "key {key} rejected");
let allowed = err
.allowed
.clone()
.expect("carries the variable-tier allowed-list");
assert!(
!allowed.iter().any(|k| {
["tax_owed", "3_Outputs!B3", "taxable_income", "3_Outputs!B2"]
.contains(&k.as_str())
}),
"a computed key is never offered as an allowed override (key {key}): {allowed:?}"
);
}
}
#[test]
fn empty_string_for_enum_input_is_rejected() {
let args = json!({ "inputs": { "filing_status": "" } });
let err = validate_input(args, &manifest(), &cell_map())
.expect_err("an empty string for an enum input is rejected");
assert_eq!(err.code, "invalid_input");
assert_eq!(err.field.as_deref(), Some("filing_status"));
}
#[test]
fn null_for_required_input_is_handled_by_empty_cell_semantics() {
let args = json!({ "inputs": { "gross_income": null } });
let v = validate_input(args, &manifest(), &cell_map())
.expect("null passes the gate (empty-cell semantics)");
assert_eq!(v.seeds.get("1_Inputs!B2"), Some(&Value::Null));
}
#[test]
fn null_for_enum_input_is_not_silently_coerced() {
let args = json!({ "inputs": { "filing_status": null } });
let v = validate_input(args, &manifest(), &cell_map())
.expect("null on an enum input passes (empty-cell semantics)");
assert_eq!(v.seeds.get("1_Inputs!B3"), Some(&Value::Null));
}
fn arb_json_value() -> impl Strategy<Value = Value> {
prop_oneof![
Just(Value::Null),
Just(json!("")),
any::<bool>().prop_map(Value::Bool),
any::<f64>()
.prop_filter("finite", |n| n.is_finite())
.prop_map(|n| json!(n)),
".*".prop_map(Value::String),
prop::collection::vec(any::<i64>(), 0..4).prop_map(|v| json!(v)),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
#[test]
fn prop_validate_input_total(
keys in prop::collection::vec(
prop_oneof![
Just("gross_income".to_string()),
Just("filing_status".to_string()),
Just("const_rate".to_string()),
"[a-z_]{1,12}",
],
0..5,
),
vals in prop::collection::vec(arb_json_value(), 0..5),
use_overrides in any::<bool>(),
) {
let mut map = serde_json::Map::new();
for (k, v) in keys.iter().zip(vals.iter()) {
map.insert(k.clone(), v.clone());
}
let bucket = if use_overrides { "overrides" } else { "inputs" };
let args = json!({ bucket: Value::Object(map) });
match validate_input(args, &manifest(), &cell_map()) {
Ok(_) | Err(_) => {},
}
}
#[test]
fn prop_excel_edge_cases_are_total(
edge in prop_oneof![Just(json!("")), Just(Value::Null)],
on_enum in any::<bool>(),
) {
let key = if on_enum { "filing_status" } else { "gross_income" };
let args = json!({ "inputs": { key: edge } });
match validate_input(args, &manifest(), &cell_map()) {
Ok(_) | Err(_) => {},
}
}
}
}