1#![cfg_attr(
18 not(test),
19 deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
20)]
21
22use std::collections::BTreeMap;
23use std::sync::Arc;
24
25use async_trait::async_trait;
26use pmcp::types::ToolInfo;
27use pmcp::{RequestHandlerExtra, ToolHandler};
28use serde_json::{json, Value};
29
30use pmcp_workbook_runtime::{run_executor, CellEnv, CellValue, RunResult, Tool};
31
32use super::error::{to_iserror_result, WorkbookToolError};
33use super::input::validate_input;
34use super::render_uri;
35use super::schema::{
36 diff_version_output_schema, empty_input_schema, explain_output_schema,
37 get_manifest_output_schema, input_schema_for_manifest, input_schema_for_tool,
38 output_schema_for_tool, render_workbook_output_schema,
39};
40use super::{ProvStamp, WorkbookBundle, WORKBOOK_TOOL_UI};
41
42#[allow(clippy::result_large_err)]
50pub(crate) fn run_bundle(
51 bundle: &WorkbookBundle,
52 seeds: BTreeMap<String, Value>,
53) -> Result<RunResult, WorkbookToolError> {
54 let mut env = CellEnv::new();
55 for (key, value) in seeds {
56 env = env.with_value(key, value);
57 }
58 run_executor(&bundle.ir, &bundle.dag, &env).map_err(|f| {
59 WorkbookToolError::invalid_input(format!("executor failed: {} ({})", f.message, f.rule))
60 })
61}
62
63#[allow(clippy::result_large_err)]
70pub(crate) fn project_tool_outputs(
71 tool: &Tool,
72 run: &RunResult,
73) -> Result<Value, WorkbookToolError> {
74 let mut outputs = serde_json::Map::new();
75 for entry in &tool.outputs {
76 let Some(value) = run.computed.get(&entry.seed_coord) else {
77 return Err(WorkbookToolError::invalid_input(format!(
78 "internal: declared output '{}' ({}) was not computed by the bundle IR",
79 entry.json_key, entry.seed_coord
80 )));
81 };
82 let projected = finite_output_value(value, &entry.seed_coord, &entry.json_key)?;
83 outputs.insert(
84 entry.json_key.clone(),
85 json!({ "value": projected, "unit": entry.unit }),
86 );
87 }
88 Ok(Value::Object(outputs))
89}
90
91#[allow(clippy::result_large_err)]
94fn finite_output_value(
95 value: &CellValue,
96 seed_coord: &str,
97 json_key: &str,
98) -> Result<Value, WorkbookToolError> {
99 match value {
100 CellValue::Number(n) if n.is_finite() => Ok(json!(n)),
101 CellValue::Number(_) => Err(WorkbookToolError::invalid_input(format!(
102 "output cell {seed_coord} ({json_key}) did not compute to a finite number"
103 ))),
104 CellValue::Text(s) => Ok(json!(s)),
105 CellValue::Bool(b) => Ok(json!(b)),
106 CellValue::Empty => Ok(Value::Null),
107 CellValue::Error(e) => Err(WorkbookToolError::invalid_input(format!(
108 "output cell {seed_coord} ({json_key}) computed to an error: {e:?}"
109 ))),
110 }
111}
112
113pub(crate) fn with_provenance(mut payload: Value, stamp: &ProvStamp) -> Value {
115 if let Some(obj) = payload.as_object_mut() {
116 obj.insert("provenance".to_string(), stamp.to_json());
117 }
118 payload
119}
120
121#[allow(clippy::result_large_err)]
125pub(crate) fn render_at_boundary(
126 result: Result<Value, WorkbookToolError>,
127 stamp: &ProvStamp,
128) -> Value {
129 result.unwrap_or_else(|e| to_iserror_result(&e, stamp))
130}
131
132#[allow(clippy::result_large_err)]
146pub fn sanitize_tool_name(raw: &str) -> Result<String, WorkbookToolError> {
147 pmcp_workbook_runtime::sanitize_tool_name(raw).map_err(WorkbookToolError::unmappable_tool_name)
148}
149
150pub struct WorkbookToolHandler {
158 bundle: Arc<WorkbookBundle>,
159 tool: Tool,
160 stamp: ProvStamp,
161}
162
163impl WorkbookToolHandler {
164 #[must_use]
166 pub fn new(bundle: Arc<WorkbookBundle>, tool: Tool) -> Self {
167 let stamp = ProvStamp::from_bundle(&bundle);
168 Self {
169 bundle,
170 tool,
171 stamp,
172 }
173 }
174
175 #[allow(clippy::result_large_err)]
181 pub fn registered_name(&self) -> Result<String, WorkbookToolError> {
182 sanitize_tool_name(&self.tool.name)
183 }
184
185 fn description(&self) -> String {
188 self.tool.description.clone().unwrap_or_else(|| {
189 format!(
190 "Compute the '{}' workbook outputs from the declared inputs by re-running \
191 the compiled workbook IR. Returns each output as a units-bearing \
192 {{ value, unit }} projection plus a provenance stamp. Strict \
193 (BA-governed) constants cannot be overridden.",
194 self.tool.name
195 )
196 })
197 }
198
199 #[allow(clippy::result_large_err)]
202 fn compute(&self, args: Value) -> Result<Value, WorkbookToolError> {
203 let validated = validate_input(args, &self.bundle.manifest, &self.bundle.cell_map)?;
204 let run = run_bundle(&self.bundle, validated.seeds)?;
205 let outputs = project_tool_outputs(&self.tool, &run)?;
206 let payload = json!({
207 "outputs": outputs,
208 "accepted_overrides": validated.accepted_overrides,
209 });
210 Ok(with_provenance(payload, &self.stamp))
211 }
212}
213
214#[async_trait]
215impl ToolHandler for WorkbookToolHandler {
216 async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
217 Ok(render_at_boundary(self.compute(args), &self.stamp))
218 }
219
220 fn metadata(&self) -> Option<ToolInfo> {
221 let name = self
225 .registered_name()
226 .unwrap_or_else(|_| self.tool.name.clone());
227 Some(
228 ToolInfo::with_ui(
229 name,
230 Some(self.description()),
231 input_schema_for_tool(&self.bundle.manifest, &self.bundle.cell_map, &self.tool),
232 WORKBOOK_TOOL_UI,
233 )
234 .with_output_schema(output_schema_for_tool(&self.bundle.manifest, &self.tool)),
235 )
236 }
237}
238
239fn cell_value_display(v: &CellValue) -> Value {
243 match v {
244 CellValue::Number(n) => json!(n),
245 CellValue::Text(s) => json!(s),
246 CellValue::Bool(b) => json!(b),
247 CellValue::Empty => Value::Null,
248 CellValue::Error(e) => json!(format!("{e:?}")),
249 }
250}
251
252pub struct ExplainHandler {
258 bundle: Arc<WorkbookBundle>,
259 stamp: ProvStamp,
260}
261
262impl ExplainHandler {
263 pub const NAME: &str = "explain";
265
266 #[must_use]
268 pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
269 let stamp = ProvStamp::from_bundle(&bundle);
270 Self { bundle, stamp }
271 }
272
273 #[allow(clippy::result_large_err)]
276 fn compute(&self, args: Value) -> Result<Value, WorkbookToolError> {
277 let validated = validate_input(args, &self.bundle.manifest, &self.bundle.cell_map)?;
278 let run = run_bundle(&self.bundle, validated.seeds)?;
279 let steps = self.render_steps(&run);
280 let payload = json!({
281 "steps": steps,
282 "annotations": self.manifest_annotations(),
283 });
284 Ok(with_provenance(payload, &self.stamp))
285 }
286
287 fn render_steps(&self, run: &RunResult) -> Vec<Value> {
291 let mut entries: Vec<_> = run.traces.iter().collect();
292 entries.sort_by(|a, b| a.0.cmp(b.0));
293 let mut steps = Vec::with_capacity(entries.len());
294 for (key, trace) in entries {
295 steps.push(json!({
296 "step": "derivation",
297 "cell": key,
298 "meaning": self.meaning_for(key),
299 "formula": trace.formula,
300 "dispatched_fn": trace.dispatched_fn,
301 "resolved_refs": trace.resolved_refs.iter().map(|(k, v)| json!({
302 "cell": k,
303 "value": cell_value_display(v),
304 })).collect::<Vec<_>>(),
305 "result": run.computed.get(key).map(cell_value_display),
306 }));
307 }
308 steps
309 }
310
311 fn manifest_annotations(&self) -> Value {
316 let mut obj = serde_json::Map::new();
317 for ann in &self.bundle.manifest.annotations {
318 obj.insert(
319 ann.name.clone(),
320 json!({ "target": ann.target, "meaning": ann.meaning }),
321 );
322 }
323 Value::Object(obj)
324 }
325
326 fn meaning_for(&self, key: &str) -> Option<String> {
328 pmcp_workbook_runtime::role_for_cell(&self.bundle.manifest, key)
329 .and_then(|c| c.meaning.clone().or_else(|| c.name.clone()))
330 }
331}
332
333#[async_trait]
334impl ToolHandler for ExplainHandler {
335 async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
336 Ok(render_at_boundary(self.compute(args), &self.stamp))
337 }
338
339 fn metadata(&self) -> Option<ToolInfo> {
340 Some(
341 ToolInfo::with_ui(
342 Self::NAME,
343 Some(
344 "Explain the computed workbook outputs: an ordered business-language \
345 derivation trace (formula + operands + meaning per step) plus a \
346 manifest-declared annotations object. Stamped + stateless (re-run \
347 from the same inputs)."
348 .into(),
349 ),
350 input_schema_for_manifest(&self.bundle.manifest, &self.bundle.cell_map),
351 WORKBOOK_TOOL_UI,
352 )
353 .with_output_schema(explain_output_schema()),
354 )
355 }
356}
357
358pub struct GetManifestHandler {
364 bundle: Arc<WorkbookBundle>,
365 stamp: ProvStamp,
366}
367
368impl GetManifestHandler {
369 pub const NAME: &str = "get_manifest";
371
372 #[must_use]
374 pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
375 let stamp = ProvStamp::from_bundle(&bundle);
376 Self { bundle, stamp }
377 }
378}
379
380fn input_projection(role: &pmcp_workbook_runtime::CellRole) -> Value {
389 use pmcp_workbook_runtime::{json_key_for_role, InputTier};
390 let (tier_kind, default) = match &role.tier {
391 Some(InputTier::Variable { default }) => ("variable", cell_value_display(default)),
392 Some(InputTier::BoundedVariable { default, .. }) => {
393 ("bounded_variable", cell_value_display(default))
394 },
395 None => ("variable", Value::Null),
396 };
397 json!({
398 "name": json_key_for_role(role),
399 "governance_name": role.name,
400 "unit": role.unit,
401 "meaning": role.meaning,
402 "tier": tier_kind,
403 "default": default,
404 })
405}
406
407fn curated_manifest(bundle: &WorkbookBundle, stamp: &ProvStamp) -> Value {
413 use pmcp_workbook_runtime::{json_key_for_role, Role};
414
415 let mut inputs = Vec::new();
416 let mut outputs = Vec::new();
417 for role in &bundle.manifest.cells {
418 match role.role {
419 Role::Input => inputs.push(input_projection(role)),
420 Role::Output => outputs.push(json!({
421 "name": json_key_for_role(role),
422 "governance_name": role.name,
423 "unit": role.unit,
424 "meaning": role.meaning,
425 })),
426 Role::Constant | Role::Formula => {},
427 }
428 }
429
430 let governed: Vec<Value> = bundle
431 .manifest
432 .governed_data
433 .iter()
434 .map(|g| {
435 json!({
436 "key": g.key,
437 "value": cell_value_display(&g.value),
438 "approved_by": g.approved_by,
439 "provenance": g.provenance,
440 })
441 })
442 .collect();
443
444 let changelog: Vec<Value> = bundle
445 .manifest
446 .changelog
447 .iter()
448 .map(|c| json!({ "version": c.version, "note": c.note }))
449 .collect();
450
451 json!({
452 "bundle_id": stamp.bundle_id,
453 "version": stamp.version,
454 "combined_hash": stamp.combined_hash,
455 "inputs": inputs,
456 "outputs": outputs,
457 "governed_data": governed,
458 "changelog": changelog,
459 "provenance": stamp.to_json(),
460 })
461}
462
463#[async_trait]
464impl ToolHandler for GetManifestHandler {
465 async fn handle(&self, _args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
466 Ok(curated_manifest(&self.bundle, &self.stamp))
467 }
468
469 fn metadata(&self) -> Option<ToolInfo> {
470 Some(
471 ToolInfo::with_ui(
472 Self::NAME,
473 Some(
474 "Describe the compiled workbook workflow: a curated agent-facing \
475 manifest projection (inputs with tier/default/unit, outputs with \
476 unit/meaning, governed-data summary, version/hashes, changelog) + \
477 provenance stamp."
478 .into(),
479 ),
480 empty_input_schema(),
481 WORKBOOK_TOOL_UI,
482 )
483 .with_output_schema(get_manifest_output_schema()),
484 )
485 }
486}
487
488pub struct DiffVersionHandler {
494 bundle: Arc<WorkbookBundle>,
495 stamp: ProvStamp,
496}
497
498impl DiffVersionHandler {
499 pub const NAME: &str = "diff_version";
501
502 #[must_use]
504 pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
505 let stamp = ProvStamp::from_bundle(&bundle);
506 Self { bundle, stamp }
507 }
508}
509
510fn serve_changelog(bundle: &WorkbookBundle, stamp: &ProvStamp) -> Value {
514 let cl = &bundle.changelog;
515 let deltas: Vec<Value> = cl.deltas.iter().map(delta_to_json).collect();
516 let payload = json!({
517 "from_version": cl.from_version,
518 "to_version": cl.to_version,
519 "deltas": deltas,
520 "summary": cl.summary,
521 });
522 with_provenance(payload, stamp)
523}
524
525fn delta_to_json(delta: &pmcp_workbook_runtime::OutputDelta) -> Value {
527 json!({
528 "region": delta.region,
529 "change_class": delta.change_class,
530 "old": meta_to_json(&delta.old),
531 "new": meta_to_json(&delta.new),
532 "severity": delta.severity,
533 })
534}
535
536fn meta_to_json(meta: &pmcp_workbook_runtime::OutputMeta) -> Value {
538 json!({
539 "meaning": meta.meaning,
540 "unit": meta.unit,
541 "provenance": meta.provenance,
542 })
543}
544
545#[async_trait]
546impl ToolHandler for DiffVersionHandler {
547 async fn handle(&self, _args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
548 Ok(serve_changelog(&self.bundle, &self.stamp))
549 }
550
551 fn metadata(&self) -> Option<ToolInfo> {
552 Some(
553 ToolInfo::with_ui(
554 Self::NAME,
555 Some(
556 "Describe what changed between two promoted workflow versions: the \
557 RECORDED, hash-verified prev→current changelog (per-output deltas \
558 with change class + drift/redefinition severity + a human-readable \
559 summary) + a provenance stamp. Served from the bundle's recorded \
560 evidence, not a runtime computation."
561 .into(),
562 ),
563 empty_input_schema(),
564 WORKBOOK_TOOL_UI,
565 )
566 .with_output_schema(diff_version_output_schema()),
567 )
568 }
569}
570
571pub struct RenderWorkbookHandler {
583 bundle: Arc<WorkbookBundle>,
584 stamp: ProvStamp,
585}
586
587impl RenderWorkbookHandler {
588 pub const NAME: &str = "render_workbook";
590
591 #[must_use]
593 pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
594 let stamp = ProvStamp::from_bundle(&bundle);
595 Self { bundle, stamp }
596 }
597
598 #[allow(clippy::result_large_err)]
602 fn compute(&self, args: Value) -> Result<Value, WorkbookToolError> {
603 let validated = validate_input(args, &self.bundle.manifest, &self.bundle.cell_map)?;
604 let uri = render_uri::encode(&validated.canonical_dto, &self.stamp)?;
605 let payload = json!({
606 "resource_uri": uri,
607 "mime_type": render_uri::WORKBOOK_XLSX_MIME,
608 });
609 Ok(with_provenance(payload, &self.stamp))
610 }
611}
612
613#[async_trait]
614impl ToolHandler for RenderWorkbookHandler {
615 async fn handle(&self, args: Value, _extra: RequestHandlerExtra) -> pmcp::Result<Value> {
616 Ok(render_at_boundary(self.compute(args), &self.stamp))
617 }
618
619 fn metadata(&self) -> Option<ToolInfo> {
620 Some(
621 ToolInfo::with_ui(
622 Self::NAME,
623 Some(
624 "Render the computed workbook to a downloadable .xlsx. Returns a \
625 provenance-bound workbook:// resource URI (NOT the bytes) — read \
626 that URI via resources/read to obtain the base64-encoded .xlsx, \
627 which is regenerated statelessly from the URI on each read. The URI \
628 encodes the inputs; treat it as sensitive."
629 .into(),
630 ),
631 input_schema_for_manifest(&self.bundle.manifest, &self.bundle.cell_map),
632 WORKBOOK_TOOL_UI,
633 )
634 .with_output_schema(render_workbook_output_schema()),
635 )
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use std::path::{Path, PathBuf};
643
644 use pmcp_workbook_runtime::{load_bundle, LocalDirSource};
645
646 fn golden_dir() -> PathBuf {
647 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tax-calc@1.1.0")
648 }
649
650 fn golden_bundle() -> Arc<WorkbookBundle> {
651 let source = LocalDirSource::new(golden_dir());
652 Arc::new(load_bundle(&source).expect("golden bundle boots"))
653 }
654
655 fn calc_handler() -> WorkbookToolHandler {
660 let bundle = golden_bundle();
661 let tool = bundle.cell_map.tools[0].clone();
662 WorkbookToolHandler::new(bundle, tool)
663 }
664
665 #[test]
672 fn reserved_tool_names_match_the_registered_meta_tool_names() {
673 let registered = [
674 ExplainHandler::NAME,
675 GetManifestHandler::NAME,
676 DiffVersionHandler::NAME,
677 RenderWorkbookHandler::NAME,
678 ];
679 assert_eq!(
680 pmcp_workbook_runtime::RESERVED_TOOL_NAMES,
681 registered,
682 "the shared RESERVED_TOOL_NAMES const must equal the four registered meta \
683 tool NAME constants (H3 — derive, never hand-copy)"
684 );
685 }
686
687 #[test]
688 fn calculate_returns_tool_outputs_with_provenance_no_headline() {
689 let handler = calc_handler();
690 let v = handler
691 .compute(json!({ "inputs": { "gross_income": 60000.0, "filing_status": "single" } }))
692 .expect("calculate succeeds");
693
694 let outputs = v["outputs"].as_object().expect("outputs is an object");
696 assert!(!outputs.is_empty(), "the tool projects its outputs");
697 for (_key, col) in outputs {
698 assert!(
699 col["value"].is_number() || col["value"].is_null(),
700 "each output carries a value"
701 );
702 }
703 let root = v.as_object().expect("payload is an object");
707 let mut top_keys: Vec<&str> = root.keys().map(String::as_str).collect();
708 top_keys.sort_unstable();
709 assert_eq!(
710 top_keys,
711 ["accepted_overrides", "outputs", "provenance"],
712 "no headline field elevated above the all-outputs projection (S-1)"
713 );
714 assert!(v["provenance"]["combined_hash"].is_string());
716 assert!(v.get("isError").is_none(), "a success is not an error");
717 }
718
719 #[test]
720 fn calculate_honors_non_default_input() {
721 let handler = calc_handler();
725
726 let v = handler
728 .compute(json!({ "inputs": { "gross_income": 100000.0 } }))
729 .expect("calculate honors a non-default gross_income");
730 assert_eq!(
731 v["outputs"]["taxable_income"]["value"],
732 json!(88000.0),
733 "taxable_income reflects the caller's gross_income (100000 - 12000), not the default"
734 );
735
736 let v = handler
739 .compute(json!({ "inputs": { "gross_income": 80000.0 } }))
740 .expect("calculate honors a second non-default gross_income");
741 assert_eq!(
742 v["outputs"]["taxable_income"]["value"],
743 json!(68000.0),
744 "a second caller input flows through (80000 - 12000)"
745 );
746 }
747
748 #[test]
749 fn calculate_invalid_input_returns_iserror_in_structured_content() {
750 let bundle = golden_bundle();
751 let tool = bundle.cell_map.tools[0].clone();
752 let handler = WorkbookToolHandler::new(bundle.clone(), tool);
753 let v = render_at_boundary(
755 handler.compute(json!({ "inputs": { "filing_status": "alien" } })),
756 &ProvStamp::from_bundle(&bundle),
757 );
758 assert_eq!(
759 v["isError"],
760 json!(true),
761 "isError rides in structuredContent"
762 );
763 assert_eq!(v["code"], json!("invalid_input"));
764 assert!(v["provenance"]["combined_hash"].is_string());
765 }
766
767 #[test]
768 fn non_finite_output_surfaces_as_error_not_null() {
769 let err = finite_output_value(&CellValue::Number(f64::NAN), "3_Outputs!B3", "tax_owed")
771 .expect_err("NaN is rejected (WR-06)");
772 assert_eq!(err.code, "invalid_input");
773 let err = finite_output_value(&CellValue::Number(f64::INFINITY), "c", "k")
774 .expect_err("Infinity is rejected (WR-06)");
775 assert_eq!(err.code, "invalid_input");
776 let ok = finite_output_value(&CellValue::Number(42.0), "c", "k").expect("finite ok");
778 assert_eq!(ok, json!(42.0));
779 }
780
781 #[test]
782 fn project_tool_outputs_fails_closed_on_missing_declared_output() {
783 let bundle = golden_bundle();
788 let tool = &bundle.cell_map.tools[0];
789 let run = RunResult::default();
792 let err = project_tool_outputs(tool, &run)
793 .expect_err("a missing declared output fails closed (WR-04)");
794 assert_eq!(err.code, "invalid_input");
795 assert!(
796 err.reason.contains("was not computed by the bundle IR"),
797 "the error names the cell_map/IR skew: {}",
798 err.reason
799 );
800 assert!(
802 tool.outputs
803 .iter()
804 .any(|e| err.reason.contains(&e.json_key) || err.reason.contains(&e.seed_coord)),
805 "the error identifies the uncomputed output: {}",
806 err.reason
807 );
808 }
809
810 #[test]
811 fn project_tool_outputs_succeeds_when_all_declared_outputs_present() {
812 let bundle = golden_bundle();
815 let tool = &bundle.cell_map.tools[0];
816 let mut run = RunResult::default();
817 for entry in &tool.outputs {
818 run.computed
819 .insert(entry.seed_coord.clone(), CellValue::Number(1.0));
820 }
821 let projected = project_tool_outputs(tool, &run).expect("all-present projects");
822 let obj = projected.as_object().expect("outputs is an object");
823 assert_eq!(
824 obj.len(),
825 tool.outputs.len(),
826 "every declared output is projected"
827 );
828 }
829
830 #[test]
831 fn tool_advertises_non_empty_output_schema() {
832 let handler = calc_handler();
833 let meta = handler.metadata().expect("metadata present");
834 let schema = meta
835 .output_schema
836 .expect("outputSchema advertised (WBSV-07)");
837 let outputs = &schema["properties"]["outputs"]["properties"];
838 assert!(
839 outputs.as_object().is_some_and(|o| !o.is_empty()),
840 "outputSchema enumerates the named outputs"
841 );
842 }
843
844 #[test]
847 fn sanitize_lowercases_and_maps_space_to_underscore() {
848 assert_eq!(
849 sanitize_tool_name("Calculate Tax").unwrap(),
850 "calculate_tax"
851 );
852 }
853
854 #[test]
855 fn sanitize_lowercases_existing_underscore_name() {
856 assert_eq!(
857 sanitize_tool_name("Calculate_Tax").unwrap(),
858 "calculate_tax"
859 );
860 }
861
862 #[test]
863 fn sanitize_collapses_illegal_runs_to_single_underscore() {
864 assert_eq!(sanitize_tool_name("a b").unwrap(), "a_b");
865 assert_eq!(sanitize_tool_name("a@@b").unwrap(), "a_b");
866 assert_eq!(sanitize_tool_name("a@ @b").unwrap(), "a_b");
867 }
868
869 #[test]
870 fn sanitize_trims_leading_and_trailing_edges() {
871 assert_eq!(sanitize_tool_name(" hello ").unwrap(), "hello");
872 assert_eq!(sanitize_tool_name("__hi__").unwrap(), "hi");
873 assert_eq!(sanitize_tool_name("-hi-").unwrap(), "hi");
874 }
875
876 #[test]
877 fn sanitize_truncates_to_64() {
878 let long = "a".repeat(200);
879 let out = sanitize_tool_name(&long).unwrap();
880 assert_eq!(out.len(), 64);
881 assert!(out.chars().all(|c| c == 'a'));
882 }
883
884 #[test]
885 fn sanitize_rejects_empty_and_all_illegal() {
886 assert!(sanitize_tool_name("").is_err());
887 assert!(sanitize_tool_name(" ").is_err());
888 assert!(sanitize_tool_name("@@@").is_err());
889 assert!(sanitize_tool_name("日本語").is_err());
890 }
891
892 #[test]
893 fn workbook_tool_handler_metadata_carries_both_schemas() {
894 let handler = calc_handler();
895 let meta = handler.metadata().expect("metadata present");
896 assert_eq!(
898 meta.name,
899 sanitize_tool_name(&handler.tool.name).unwrap(),
900 "metadata name is the sanitized tool name"
901 );
902 assert!(meta.input_schema.is_object(), "carries an input schema");
903 assert!(meta.output_schema.is_some(), "carries an output schema");
904 }
905
906 #[test]
909 fn explain_emits_ordered_trace_and_generic_manifest_annotations() {
910 let handler = ExplainHandler::new(golden_bundle());
911 let v = handler
912 .compute(json!({ "inputs": { "gross_income": 60000.0, "filing_status": "single" } }))
913 .expect("explain succeeds");
914
915 let steps = v["steps"].as_array().expect("steps is an array");
917 assert!(!steps.is_empty(), "explain emits derivation steps");
918 for step in steps {
919 assert_eq!(step["step"], json!("derivation"));
920 assert!(step["cell"].is_string());
921 }
922
923 let annotations = v["annotations"].as_object().expect("annotations object");
927 assert!(annotations.contains_key("bracket_boundary_1"));
928 assert!(annotations.contains_key("bracket_boundary_2"));
929 assert_eq!(
930 annotations["bracket_boundary_1"]["target"],
931 json!("2_Brackets!A2")
932 );
933 assert!(annotations["bracket_boundary_1"]["meaning"].is_string());
934
935 assert!(v["provenance"]["combined_hash"].is_string());
936 }
937
938 #[test]
939 fn explain_invalid_input_returns_iserror() {
940 let bundle = golden_bundle();
941 let handler = ExplainHandler::new(bundle.clone());
942 let v = render_at_boundary(
943 handler.compute(json!({ "inputs": { "filing_status": "alien" } })),
944 &ProvStamp::from_bundle(&bundle),
945 );
946 assert_eq!(v["isError"], json!(true));
947 assert_eq!(v["code"], json!("invalid_input"));
948 }
949
950 #[test]
953 fn get_manifest_returns_curated_projection_with_no_input() {
954 let bundle = golden_bundle();
955 let v = curated_manifest(&bundle, &ProvStamp::from_bundle(&bundle));
956 assert_eq!(v["bundle_id"], json!("tax-calc"));
957 assert_eq!(v["version"], json!("1.1.0"));
958 assert!(v["combined_hash"].is_string());
959 let inputs = v["inputs"].as_array().expect("inputs array");
961 assert_eq!(
962 inputs.len(),
963 4,
964 "four inputs projected (income, filing, deductions, withheld)"
965 );
966 assert!(inputs.iter().all(|i| i["tier"].is_string()));
967 let outputs = v["outputs"].as_array().expect("outputs array");
968 assert_eq!(
969 outputs.len(),
970 5,
971 "five outputs projected (4 tax + 1 refund) across the two tools"
972 );
973 assert!(v["governed_data"].is_array());
974 assert!(v["changelog"].is_array());
975 assert!(v["provenance"]["combined_hash"].is_string());
976 }
977
978 #[test]
983 fn get_manifest_advertises_the_stripped_served_keys() {
984 use super::super::schema::output_schema_for_manifest;
985 use std::collections::BTreeSet;
986 let bundle = golden_bundle();
987 let v = curated_manifest(&bundle, &ProvStamp::from_bundle(&bundle));
988
989 let manifest_inputs: BTreeSet<String> = v["inputs"]
991 .as_array()
992 .expect("inputs array")
993 .iter()
994 .map(|i| i["name"].as_str().expect("input name string").to_string())
995 .collect();
996 let manifest_outputs: BTreeSet<String> = v["outputs"]
997 .as_array()
998 .expect("outputs array")
999 .iter()
1000 .map(|o| o["name"].as_str().expect("output name string").to_string())
1001 .collect();
1002
1003 for name in manifest_inputs.iter().chain(manifest_outputs.iter()) {
1005 assert!(
1006 !name.starts_with("in_") && !name.starts_with("out_"),
1007 "advertised get_manifest name `{name}` is stripped (no governance prefix)"
1008 );
1009 }
1010
1011 let wide_in = input_schema_for_manifest(&bundle.manifest, &bundle.cell_map);
1015 let served_inputs: BTreeSet<String> = wide_in["properties"]["inputs"]["properties"]
1016 .as_object()
1017 .map(|m| m.keys().cloned().collect())
1018 .unwrap_or_default();
1019 let wide_out = output_schema_for_manifest(&bundle.manifest, &bundle.cell_map);
1020 let served_outputs: BTreeSet<String> = wide_out["properties"]["outputs"]["properties"]
1021 .as_object()
1022 .map(|m| m.keys().cloned().collect())
1023 .unwrap_or_default();
1024
1025 assert_eq!(
1026 manifest_inputs, served_inputs,
1027 "get_manifest input names == the workbook-wide served input keys (stripped)"
1028 );
1029 assert_eq!(
1030 manifest_outputs, served_outputs,
1031 "get_manifest output names == the workbook-wide served output keys (stripped)"
1032 );
1033
1034 for tool in &bundle.cell_map.tools {
1038 let in_schema = input_schema_for_tool(&bundle.manifest, &bundle.cell_map, tool);
1039 if let Some(props) = in_schema["properties"]["inputs"]["properties"].as_object() {
1040 for key in props.keys() {
1041 assert!(
1042 manifest_inputs.contains(key),
1043 "served per-tool input key `{key}` is discoverable in get_manifest"
1044 );
1045 }
1046 }
1047 }
1048 }
1049
1050 #[test]
1053 fn diff_version_serves_recorded_changelog() {
1054 let bundle = golden_bundle();
1055 let v = serve_changelog(&bundle, &ProvStamp::from_bundle(&bundle));
1056
1057 assert_eq!(v["from_version"], json!(bundle.changelog.from_version));
1059 assert_eq!(v["to_version"], json!(bundle.changelog.to_version));
1060 assert_eq!(v["summary"], json!(bundle.changelog.summary));
1061 let deltas = v["deltas"].as_array().expect("deltas array");
1062 assert_eq!(deltas.len(), bundle.changelog.deltas.len());
1063 if let Some(first) = deltas.first() {
1064 assert!(first["region"].is_string());
1065 assert!(first["change_class"].is_string());
1066 assert!(first["severity"].is_string());
1067 }
1068 assert!(v["provenance"]["combined_hash"].is_string());
1069 assert!(
1070 v.get("isError").is_none(),
1071 "a served changelog is not an error"
1072 );
1073 }
1074
1075 #[test]
1076 fn diff_version_advertises_output_schema() {
1077 let handler = DiffVersionHandler::new(golden_bundle());
1078 let meta = handler.metadata().expect("metadata present");
1079 let schema = meta.output_schema.expect("output schema advertised");
1080 assert_eq!(
1081 schema["properties"]["from_version"]["type"],
1082 json!("string")
1083 );
1084 assert_eq!(schema["properties"]["deltas"]["type"], json!("array"));
1085 }
1086
1087 #[test]
1090 fn render_workbook_returns_uri_pointer_not_bytes() {
1091 let bundle = golden_bundle();
1092 let handler = RenderWorkbookHandler::new(bundle.clone());
1093 let v = handler
1094 .compute(json!({ "inputs": { "gross_income": 60000.0, "filing_status": "single" } }))
1095 .expect("render_workbook succeeds");
1096
1097 let uri = v["resource_uri"]
1099 .as_str()
1100 .expect("resource_uri is a string");
1101 assert!(
1102 uri.starts_with(render_uri::RENDER_URI_PREFIX),
1103 "returns a workbook:// pointer"
1104 );
1105 assert!(
1106 v.get("bytes").is_none() && v.get("data").is_none(),
1107 "the bytes are NOT in the tool response"
1108 );
1109 let decoded = render_uri::decode(uri).expect("pointer decodes");
1111 assert_eq!(decoded.provenance, ProvStamp::from_bundle(&bundle));
1112 assert_eq!(decoded.provenance.combined_hash, bundle.stamp.combined);
1113 assert!(v["provenance"]["combined_hash"].is_string());
1115 assert!(v.get("isError").is_none(), "a success is not an error");
1116 }
1117
1118 #[test]
1119 fn render_workbook_invalid_input_returns_iserror() {
1120 let bundle = golden_bundle();
1121 let handler = RenderWorkbookHandler::new(bundle.clone());
1122 let v = render_at_boundary(
1123 handler.compute(json!({ "inputs": { "filing_status": "alien" } })),
1124 &ProvStamp::from_bundle(&bundle),
1125 );
1126 assert_eq!(v["isError"], json!(true), "isError rides in the payload");
1127 assert_eq!(v["code"], json!("invalid_input"));
1128 assert!(v["provenance"]["combined_hash"].is_string());
1129 }
1130
1131 #[test]
1132 fn render_workbook_advertises_non_empty_output_schema() {
1133 let handler = RenderWorkbookHandler::new(golden_bundle());
1134 let meta = handler.metadata().expect("metadata present");
1135 let schema = meta
1136 .output_schema
1137 .expect("outputSchema advertised (WBSV-07)");
1138 assert_eq!(
1139 schema["properties"]["resource_uri"]["type"],
1140 json!("string")
1141 );
1142 }
1143}