1#![cfg_attr(
28 not(test),
29 deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
30)]
31
32use std::collections::BTreeMap;
33
34use serde::Deserialize;
35use serde_json::Value;
36
37use pmcp_workbook_runtime::{
38 is_computed, is_strict_constant, CellMap, CellRole, CellValue, Dtype, InputTier, Manifest, Role,
39};
40
41use super::error::WorkbookToolError;
42
43#[derive(Debug, Clone, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct CalculateInput {
49 #[serde(default)]
53 pub inputs: BTreeMap<String, Value>,
54 #[serde(default)]
57 pub overrides: BTreeMap<String, Value>,
58}
59
60#[derive(Debug, Clone)]
65pub struct ValidatedInput {
66 pub seeds: BTreeMap<String, Value>,
68 pub accepted_overrides: Vec<String>,
70 pub canonical_dto: Value,
74}
75
76#[allow(clippy::result_large_err)]
91pub fn validate_input(
92 args: Value,
93 manifest: &Manifest,
94 cell_map: &CellMap,
95) -> Result<ValidatedInput, WorkbookToolError> {
96 let input: CalculateInput = serde_json::from_value(args)
97 .map_err(|e| WorkbookToolError::invalid_input(format!("invalid arguments: {e}")))?;
98
99 let mut seeds = seed_tier_defaults(manifest);
102
103 seed_supplied_inputs(&input.inputs, manifest, cell_map, &mut seeds)?;
105
106 let accepted_overrides = seed_accepted_overrides(&input.overrides, manifest, &mut seeds)?;
108
109 let canonical_dto = serde_json::json!({
110 "inputs": &input.inputs,
111 "overrides": &input.overrides,
112 });
113
114 Ok(ValidatedInput {
115 seeds,
116 accepted_overrides,
117 canonical_dto,
118 })
119}
120
121fn seed_tier_defaults(manifest: &Manifest) -> BTreeMap<String, Value> {
124 let mut seeds: BTreeMap<String, Value> = BTreeMap::new();
125 for role in &manifest.cells {
126 if matches!(role.role, Role::Input) {
127 if let Some(default) = tier_default(role) {
128 seeds.insert(role.cell.clone(), default);
129 }
130 }
131 }
132 seeds
133}
134
135#[allow(clippy::result_large_err)]
142fn seed_supplied_inputs(
143 inputs: &BTreeMap<String, Value>,
144 manifest: &Manifest,
145 cell_map: &CellMap,
146 seeds: &mut BTreeMap<String, Value>,
147) -> Result<(), WorkbookToolError> {
148 for (key, value) in inputs {
149 let entry = cell_map
150 .inputs
151 .iter()
152 .find(|e| &e.json_key == key)
153 .ok_or_else(|| {
154 WorkbookToolError::invalid_input_field(key.clone(), known_input_keys(cell_map))
155 })?;
156 let role =
157 pmcp_workbook_runtime::role_for_cell(manifest, &entry.seed_coord).ok_or_else(|| {
158 WorkbookToolError::invalid_input(format!(
159 "internal: input '{key}' maps to {} which has no manifest role",
160 entry.seed_coord
161 ))
162 })?;
163 check_value_dtype(role, key, value)?;
164 seeds.insert(entry.seed_coord.clone(), value.clone());
165 }
166 Ok(())
167}
168
169#[allow(clippy::result_large_err)]
175fn seed_accepted_overrides(
176 overrides: &BTreeMap<String, Value>,
177 manifest: &Manifest,
178 seeds: &mut BTreeMap<String, Value>,
179) -> Result<Vec<String>, WorkbookToolError> {
180 let mut accepted_overrides = Vec::new();
181 for (key, value) in overrides {
182 let role = classify_override(manifest, key)?;
183 check_value_dtype(role, key, value)?;
184 seeds.insert(role.cell.clone(), value.clone());
185 accepted_overrides.push(key.clone());
186 }
187 Ok(accepted_overrides)
188}
189
190#[allow(clippy::result_large_err)]
201fn classify_override<'a>(
202 manifest: &'a Manifest,
203 key: &str,
204) -> Result<&'a CellRole, WorkbookToolError> {
205 match find_role_by_key(manifest, key) {
206 Some(r) if is_strict_constant(r) => Err(WorkbookToolError::strict_constant_override(
207 key.to_string(),
208 variable_tier_keys(manifest),
209 )),
210 Some(r) if is_computed(r) => Err(WorkbookToolError::unsupported_option(
211 key.to_string(),
212 variable_tier_keys(manifest),
213 )),
214 Some(r) => Ok(r),
215 None => Err(WorkbookToolError::unsupported_option(
216 key.to_string(),
217 variable_tier_keys(manifest),
218 )),
219 }
220}
221
222fn tier_default(role: &CellRole) -> Option<Value> {
224 match &role.tier {
225 Some(InputTier::Variable { default })
226 | Some(InputTier::BoundedVariable { default, .. }) => cell_value_to_json(default),
227 None => None,
228 }
229}
230
231fn cell_value_to_json(v: &CellValue) -> Option<Value> {
233 match v {
234 CellValue::Number(n) => serde_json::Number::from_f64(*n).map(Value::Number),
235 CellValue::Text(s) => Some(Value::String(s.clone())),
236 CellValue::Bool(b) => Some(Value::Bool(*b)),
237 CellValue::Empty => Some(Value::Null),
238 CellValue::Error(_) => None,
239 }
240}
241
242#[allow(clippy::result_large_err)]
250fn check_value_dtype(role: &CellRole, field: &str, value: &Value) -> Result<(), WorkbookToolError> {
251 if value.is_null() {
252 return Ok(());
253 }
254 let ok = match role.dtype {
255 Dtype::Number => value.is_number(),
256 Dtype::Text => value.is_string(),
257 Dtype::Bool => value.is_boolean(),
258 };
259 if !ok {
260 let expected = super::schema::dtype_json_type(role.dtype);
261 return Err(WorkbookToolError::invalid_input(format!(
262 "input '{field}' must be a {expected} (cell {} is declared {expected})",
263 role.cell
264 )));
265 }
266 if let Some(allowed) = &role.allowed_values {
270 let is_member = value
271 .as_str()
272 .is_some_and(|s| allowed.iter().any(|a| a == s));
273 if !is_member {
274 return Err(WorkbookToolError::invalid_enum(
275 field,
276 allowed.clone(),
277 format!(
278 "input '{field}' must be one of the allowed values \
279 (cell {} is a closed enum)",
280 role.cell
281 ),
282 ));
283 }
284 }
285 Ok(())
286}
287
288fn find_role_by_key<'a>(manifest: &'a Manifest, key: &str) -> Option<&'a CellRole> {
291 manifest
292 .cells
293 .iter()
294 .find(|r| r.name.as_deref() == Some(key) || r.cell == key)
295}
296
297pub(crate) fn variable_tier_keys(manifest: &Manifest) -> Vec<String> {
305 manifest
306 .cells
307 .iter()
308 .filter(|r| !is_strict_constant(r) && !is_computed(r))
309 .filter_map(|r| r.name.clone().or_else(|| Some(r.cell.clone())))
310 .collect()
311}
312
313fn known_input_keys(cell_map: &CellMap) -> Vec<String> {
315 cell_map.inputs.iter().map(|e| e.json_key.clone()).collect()
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use pmcp_workbook_runtime::{CellEntry, CellMap, Tool};
322 use proptest::prelude::*;
323 use serde_json::json;
324
325 fn input_role(
329 cell: &str,
330 dtype: Dtype,
331 name: &str,
332 tier: Option<InputTier>,
333 allowed: Option<Vec<String>>,
334 ) -> CellRole {
335 CellRole {
336 cell: cell.to_string(),
337 role: Role::Input,
338 name: Some(name.to_string()),
339 unit: None,
340 meaning: None,
341 dtype,
342 colour_evidence: None,
343 source: "test".to_string(),
344 notes: None,
345 tier,
346 allowed_values: allowed,
347 }
348 }
349
350 fn manifest() -> Manifest {
351 Manifest {
352 schema_version: 1,
353 workflow: "tax-calc".to_string(),
354 workbook_hash: None,
355 ratified: true,
356 ratified_by: None,
357 ratified_at: None,
358 cells: vec![
359 input_role(
360 "1_Inputs!B2",
361 Dtype::Number,
362 "gross_income",
363 Some(InputTier::Variable {
364 default: CellValue::Number(0.0),
365 }),
366 None,
367 ),
368 input_role(
369 "1_Inputs!B3",
370 Dtype::Text,
371 "filing_status",
372 Some(InputTier::Variable {
373 default: CellValue::Text("single".to_string()),
374 }),
375 Some(vec![
376 "single".to_string(),
377 "married_joint".to_string(),
378 "head_of_household".to_string(),
379 ]),
380 ),
381 CellRole {
383 cell: "2_Rates!B2".to_string(),
384 role: Role::Constant,
385 name: Some("const_rate".to_string()),
386 unit: None,
387 meaning: None,
388 dtype: Dtype::Number,
389 colour_evidence: None,
390 source: "test".to_string(),
391 notes: None,
392 tier: None,
393 allowed_values: None,
394 },
395 ],
396 loop_block: None,
397 governed_data: vec![],
398 changelog: vec![],
399 capability_calls: vec![],
400 annotations: vec![],
401 }
402 }
403
404 fn cell_map() -> CellMap {
405 CellMap {
406 inputs: vec![
407 CellEntry {
408 json_key: "gross_income".to_string(),
409 seed_coord: "1_Inputs!B2".to_string(),
410 unit: Some("USD".to_string()),
411 },
412 CellEntry {
413 json_key: "filing_status".to_string(),
414 seed_coord: "1_Inputs!B3".to_string(),
415 unit: None,
416 },
417 ],
418 tools: vec![Tool {
419 name: "calculate".to_string(),
420 description: None,
421 input_keys: Vec::new(),
422 outputs: vec![CellEntry {
423 json_key: "tax_owed".to_string(),
424 seed_coord: "3_Outputs!B3".to_string(),
425 unit: Some("USD".to_string()),
426 }],
427 oracle: std::collections::BTreeMap::new(),
428 }],
429 }
430 }
431
432 #[test]
433 fn valid_inputs_seed_their_cells() {
434 let args = json!({ "inputs": { "gross_income": 60000.0 } });
435 let v = validate_input(args, &manifest(), &cell_map()).expect("valid");
436 assert_eq!(v.seeds.get("1_Inputs!B2"), Some(&json!(60000.0)));
437 }
438
439 #[test]
440 fn unknown_top_level_field_is_rejected() {
441 let args = json!({ "bogus": 1 });
442 let err = validate_input(args, &manifest(), &cell_map())
443 .expect_err("unknown top-level field rejected (deny_unknown_fields)");
444 assert_eq!(err.code, "invalid_input");
445 }
446
447 #[test]
448 fn unknown_input_key_is_invalid_input_with_allowed() {
449 let args = json!({ "inputs": { "not_a_real_input": 1 } });
451 let err = validate_input(args, &manifest(), &cell_map())
452 .expect_err("an unknown input key is rejected (WR-05)");
453 assert_eq!(err.code, "invalid_input");
454 assert_eq!(err.field.as_deref(), Some("not_a_real_input"));
455 assert!(err.allowed.is_some(), "carries the known input keys");
456 }
457
458 #[test]
459 fn cell_map_entry_without_manifest_role_is_rejected_fail_closed() {
460 let mut cm = cell_map();
463 cm.inputs.push(CellEntry {
464 json_key: "orphan".to_string(),
465 seed_coord: "9_Nowhere!Z99".to_string(),
466 unit: None,
467 });
468 let args = json!({ "inputs": { "orphan": "oops" } });
469 let err = validate_input(args, &manifest(), &cm)
470 .expect_err("a cell_map entry with no manifest role is rejected (WR-05)");
471 assert_eq!(err.code, "invalid_input");
472 assert!(
473 err.reason.contains("no manifest role") && err.reason.contains("9_Nowhere!Z99"),
474 "the error names the internal-consistency failure: {}",
475 err.reason
476 );
477 }
478
479 #[test]
480 fn non_numeric_value_for_number_cell_is_rejected() {
481 let args = json!({ "inputs": { "gross_income": "oops" } });
482 let err = validate_input(args, &manifest(), &cell_map())
483 .expect_err("a non-numeric value for a numeric input is rejected (WR-01)");
484 assert_eq!(err.code, "invalid_input");
485 assert!(err.reason.contains("number"), "names the expected type");
486 }
487
488 #[test]
489 fn out_of_enum_value_is_rejected_with_allowed() {
490 let args = json!({ "inputs": { "filing_status": "alien" } });
491 let err = validate_input(args, &manifest(), &cell_map())
492 .expect_err("an out-of-enum value is rejected (WR-02)");
493 assert_eq!(err.code, "invalid_input");
494 assert_eq!(err.field.as_deref(), Some("filing_status"));
495 assert_eq!(
496 err.allowed,
497 Some(vec![
498 "single".to_string(),
499 "married_joint".to_string(),
500 "head_of_household".to_string(),
501 ]),
502 "the allowed enum members live in the error"
503 );
504 }
505
506 #[test]
507 fn in_enum_value_passes_the_gate() {
508 for legal in ["single", "married_joint", "head_of_household"] {
509 let args = json!({ "inputs": { "filing_status": legal } });
510 let v = validate_input(args, &manifest(), &cell_map())
511 .expect("an in-enum value passes the membership gate");
512 assert_eq!(v.seeds.get("1_Inputs!B3"), Some(&json!(legal)));
513 }
514 }
515
516 #[test]
517 fn non_string_value_on_string_enum_is_rejected_fail_closed() {
518 let mut m = manifest();
521 m.cells[1].dtype = Dtype::Number;
523 let args = json!({ "inputs": { "filing_status": 42 } });
524 let err = validate_input(args, &m, &cell_map())
525 .expect_err("a non-string value on a string-enum input is rejected (WR-02)");
526 assert_eq!(err.code, "invalid_input");
527 assert_eq!(err.field.as_deref(), Some("filing_status"));
528 assert!(
529 err.allowed.is_some(),
530 "still carries the allowed repair field"
531 );
532 }
533
534 #[test]
535 fn strict_constant_override_is_rejected() {
536 let args = json!({ "overrides": { "const_rate": 0.40 } });
538 let err = validate_input(args, &manifest(), &cell_map())
539 .expect_err("a strict-constant override is rejected (V4)");
540 assert_eq!(err.code, "strict_constant_override");
541 assert_eq!(err.field.as_deref(), Some("const_rate"));
542 assert!(err.allowed.is_some(), "carries variable-tier alternatives");
543 }
544
545 #[test]
546 fn override_naming_no_cell_is_unsupported_option() {
547 let args = json!({ "overrides": { "ghost_param": 1 } });
548 let err = validate_input(args, &manifest(), &cell_map())
549 .expect_err("an override naming no manifest cell is unsupported_option");
550 assert_eq!(err.code, "unsupported_option");
551 }
552
553 fn manifest_with_computed_cells() -> Manifest {
557 let mut m = manifest();
558 for (cell, role, name) in [
559 ("3_Outputs!B3", Role::Output, "tax_owed"),
560 ("3_Outputs!B2", Role::Formula, "taxable_income"),
561 ] {
562 m.cells.push(CellRole {
563 role,
564 unit: Some("USD".to_string()),
565 ..input_role(cell, Dtype::Number, name, None, None)
566 });
567 }
568 m
569 }
570
571 #[test]
572 fn override_on_computed_cell_is_rejected_unsupported_option() {
573 for key in ["tax_owed", "3_Outputs!B3", "taxable_income", "3_Outputs!B2"] {
578 let args = json!({ "overrides": { key: 999.0 } });
579 let err = validate_input(args, &manifest_with_computed_cells(), &cell_map())
580 .expect_err("a computed-cell override is rejected (WR-02)");
581 assert_eq!(err.code, "unsupported_option", "key {key} rejected");
582 let allowed = err
584 .allowed
585 .clone()
586 .expect("carries the variable-tier allowed-list");
587 assert!(
588 !allowed.iter().any(|k| {
589 ["tax_owed", "3_Outputs!B3", "taxable_income", "3_Outputs!B2"]
590 .contains(&k.as_str())
591 }),
592 "a computed key is never offered as an allowed override (key {key}): {allowed:?}"
593 );
594 }
595 }
596
597 #[test]
600 fn empty_string_for_enum_input_is_rejected() {
601 let args = json!({ "inputs": { "filing_status": "" } });
604 let err = validate_input(args, &manifest(), &cell_map())
605 .expect_err("an empty string for an enum input is rejected");
606 assert_eq!(err.code, "invalid_input");
607 assert_eq!(err.field.as_deref(), Some("filing_status"));
608 }
609
610 #[test]
611 fn null_for_required_input_is_handled_by_empty_cell_semantics() {
612 let args = json!({ "inputs": { "gross_income": null } });
616 let v = validate_input(args, &manifest(), &cell_map())
617 .expect("null passes the gate (empty-cell semantics)");
618 assert_eq!(v.seeds.get("1_Inputs!B2"), Some(&Value::Null));
619 }
620
621 #[test]
622 fn null_for_enum_input_is_not_silently_coerced() {
623 let args = json!({ "inputs": { "filing_status": null } });
626 let v = validate_input(args, &manifest(), &cell_map())
627 .expect("null on an enum input passes (empty-cell semantics)");
628 assert_eq!(v.seeds.get("1_Inputs!B3"), Some(&Value::Null));
629 }
630
631 fn arb_json_value() -> impl Strategy<Value = Value> {
637 prop_oneof![
638 Just(Value::Null),
639 Just(json!("")),
640 any::<bool>().prop_map(Value::Bool),
641 any::<f64>()
642 .prop_filter("finite", |n| n.is_finite())
643 .prop_map(|n| json!(n)),
644 ".*".prop_map(Value::String),
645 prop::collection::vec(any::<i64>(), 0..4).prop_map(|v| json!(v)),
646 ]
647 }
648
649 proptest! {
650 #![proptest_config(ProptestConfig::with_cases(512))]
651
652 #[test]
657 fn prop_validate_input_total(
658 keys in prop::collection::vec(
659 prop_oneof![
660 Just("gross_income".to_string()),
661 Just("filing_status".to_string()),
662 Just("const_rate".to_string()),
663 "[a-z_]{1,12}",
664 ],
665 0..5,
666 ),
667 vals in prop::collection::vec(arb_json_value(), 0..5),
668 use_overrides in any::<bool>(),
669 ) {
670 let mut map = serde_json::Map::new();
671 for (k, v) in keys.iter().zip(vals.iter()) {
672 map.insert(k.clone(), v.clone());
673 }
674 let bucket = if use_overrides { "overrides" } else { "inputs" };
675 let args = json!({ bucket: Value::Object(map) });
676
677 match validate_input(args, &manifest(), &cell_map()) {
679 Ok(_) | Err(_) => {},
680 }
681 }
682
683 #[test]
687 fn prop_excel_edge_cases_are_total(
688 edge in prop_oneof![Just(json!("")), Just(Value::Null)],
689 on_enum in any::<bool>(),
690 ) {
691 let key = if on_enum { "filing_status" } else { "gross_income" };
692 let args = json!({ "inputs": { key: edge } });
693 match validate_input(args, &manifest(), &cell_map()) {
694 Ok(_) | Err(_) => {},
695 }
696 }
697 }
698}