Skip to main content

pmcp_server_toolkit/workbook/
handler.rs

1//! The curated workbook tool handlers (WBSV-01/02/03/04): `calculate`,
2//! `explain`, `get_manifest`, `diff_version`.
3//!
4//! All are native [`pmcp::ToolHandler`] impls registered via `tool_arc` and
5//! [`pmcp::types::ToolInfo::with_ui`] (so the returned `Value` lands in
6//! `structuredContent`). Each attaches the provenance stamp and advertises a
7//! non-empty `outputSchema` (WBSV-07). Domain failures return the `isError:true`
8//! envelope via [`to_iserror_result`] — NEVER a protocol-level error (T-92-10).
9//!
10//! The per-Table [`WorkbookToolHandler`]s (WBV2-04) + `explain` re-run the
11//! SERVE-time [`pmcp_workbook_runtime::run_executor`] over the pre-built
12//! `bundle.dag` (no compiler, no second evaluator), seeding the `CellEnv` via the
13//! embedded `cell_map`. Each per-Table handler projects ONLY its own Table's
14//! outputs via [`project_tool_outputs`] — one named MCP tool per output Table.
15
16// Compiler/clippy-enforced panic-freedom on the value path (mirrors the runtime).
17#![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// ---- Shared handler helpers (kept decomposed so each handler fn stays under
43//      cognitive complexity 25) -------------------------------------------------
44
45/// Re-run the embedded IR over the validated seeds and return the [`RunResult`].
46/// The per-cell DAG is the one built ONCE at bundle load (`bundle.dag`). A DAG
47/// cycle (impossible for a conforming bundle) surfaces as an `invalid_input`
48/// error rather than a panic.
49#[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/// Project ONLY one tool's outputs into the typed `{ <json_key>: { value, unit } }`
64/// map (WBV2-04). Each output Table is its own MCP tool, so its handler projects
65/// exactly that Table's output cells — never the union across tools.
66///
67/// WR-04: fail closed on a declared-but-uncomputed output (a cell_map/IR skew).
68/// WR-06: every numeric output is finiteness-checked.
69#[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/// Project one computed [`CellValue`] into its JSON `value`, finiteness-checking
92/// numbers (WR-06). A non-finite number is an error, NOT a JSON `null`.
93#[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
113/// Append the provenance stamp to a success payload object.
114pub(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/// Render a fallible compute pipeline once at the boundary: a domain failure
122/// becomes the `isError:true` envelope (in `structuredContent`), never a
123/// protocol-level error.
124#[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// ---- per-tool handler (WBV2-04) ----------------------------------------------
133
134/// Sanitize a raw output-Table name into an MCP tool name matching
135/// `^[a-zA-Z0-9_-]{1,64}$` (T-100-10), wrapping the SINGLE shared runtime
136/// sanitizer ([`pmcp_workbook_runtime::sanitize_tool_name`]) so the served
137/// registration and the offline compiler's collision lint cannot drift on the
138/// locked five-rule semantics (lowercase, illegal-run → single `_`, trim edges,
139/// truncate 64, reject empty/all-illegal). A reject becomes the fail-closed
140/// `invalid_tool_name` domain error.
141///
142/// # Errors
143/// Returns `Err(WorkbookToolError::unmappable_tool_name)` when the input has no
144/// character mappable to the charset (empty or all-illegal).
145#[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
150/// One served MCP tool per output Table (WBV2-04): validate → seed via cell_map →
151/// re-run the embedded IR → project ONLY this tool's outputs (finite) → stamp.
152///
153/// Each handler advertises a per-tool I/O schema: an inputSchema carrying ONLY
154/// this tool's DAG-derived `input_keys`, and a non-empty outputSchema over this
155/// tool's own outputs (TypedToolWithOutput). The generic single `calculate` is
156/// retired (§4 — an LLM selects a NAMED tool per output Table).
157pub struct WorkbookToolHandler {
158    bundle: Arc<WorkbookBundle>,
159    tool: Tool,
160    stamp: ProvStamp,
161}
162
163impl WorkbookToolHandler {
164    /// Build over the shared verified bundle + this tool's projection.
165    #[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    /// The sanitized MCP tool name (the registered name + the metadata name —
176    /// ONE source so they cannot drift).
177    ///
178    /// # Errors
179    /// Returns `Err` if this tool's raw name is unmappable to the MCP charset.
180    #[allow(clippy::result_large_err)]
181    pub fn registered_name(&self) -> Result<String, WorkbookToolError> {
182        sanitize_tool_name(&self.tool.name)
183    }
184
185    /// The per-tool description (the output Table's caption), falling back to a
186    /// generic one when the Table carried no caption.
187    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    /// The linear `?`-chained per-tool pipeline: validate → re-run → project ONLY
200    /// this tool's outputs → stamp.
201    #[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        // The sanitized name is the metadata name. If it is somehow unmappable
222        // (registration would have already rejected it), fall back to the raw
223        // name so metadata() stays infallible — registration is the fail-closed gate.
224        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
239// ---- explain -----------------------------------------------------------------
240
241/// A display projection of a [`CellValue`] for the explain trace.
242fn 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
252/// The `explain` handler (WBSV-02): a stateless re-run that renders the
253/// derivation trace as ordered business-language steps, plus a GENERIC
254/// manifest-declared `annotations` object (S-2 — any domain-specific keystone is
255/// generalized into manifest-declared annotations; the engine reads only
256/// `manifest.annotations` names, nothing domain-specific).
257pub struct ExplainHandler {
258    bundle: Arc<WorkbookBundle>,
259    stamp: ProvStamp,
260}
261
262impl ExplainHandler {
263    /// The registered tool name — the single source for registration + metadata.
264    pub const NAME: &str = "explain";
265
266    /// Build over the shared verified bundle.
267    #[must_use]
268    pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
269        let stamp = ProvStamp::from_bundle(&bundle);
270        Self { bundle, stamp }
271    }
272
273    /// The linear `?`-chained `explain` pipeline: validate → re-run → ordered
274    /// derivation steps + manifest annotations → stamp.
275    #[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    /// Render the [`RunResult`] traces into ORDERED business-language steps
288    /// (sorted by cell key for determinism), each carrying the formula + operand
289    /// values + the manifest meaning.
290    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    /// The GENERIC manifest-declared annotations object (S-2): keyed by each
312    /// [`pmcp_workbook_runtime::AnnotationDecl`] `name`, carrying its `target` +
313    /// `meaning`. The engine reads ONLY manifest-declared names — nothing
314    /// domain-specific.
315    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    /// The manifest meaning for a cell key (for the business-language prose).
327    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
358// ---- get_manifest ------------------------------------------------------------
359
360/// The `get_manifest` handler (WBSV-03): a CURATED agent-facing projection —
361/// inputs (tier+default+unit), outputs (unit/meaning), governed-data summary,
362/// versions/hashes, changelog — NOT the raw internal manifest.
363pub struct GetManifestHandler {
364    bundle: Arc<WorkbookBundle>,
365    stamp: ProvStamp,
366}
367
368impl GetManifestHandler {
369    /// The registered tool name — the single source for registration + metadata.
370    pub const NAME: &str = "get_manifest";
371
372    /// Build over the shared verified bundle.
373    #[must_use]
374    pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
375        let stamp = ProvStamp::from_bundle(&bundle);
376        Self { bundle, stamp }
377    }
378}
379
380/// Project one manifest input cell into its curated agent-facing record (M5).
381///
382/// The advertised `name` is the STRIPPED served key
383/// ([`json_key_for_role`](pmcp_workbook_runtime::json_key_for_role)) — the SAME key
384/// the served tool schema (`input_schema_for_tool`) advertises and `validate_input`
385/// accepts — so an agent that reads `get_manifest` then calls the tool with the
386/// discovered name is NOT rejected. The raw prefixed `role.name` (`in_income`) is kept
387/// only as internal `governance_name` for the named-range/governance audit trail.
388fn 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
407/// Build the curated agent-facing manifest projection (WBSV-03) + stamp.
408///
409/// M5: BOTH the input and output projections advertise the STRIPPED served key (the
410/// `json_key`) as `name`, so the discovery surface == the call surface — never the
411/// raw `in_`/`out_` prefixed name (which is kept only as `governance_name`).
412fn 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
488// ---- diff_version ------------------------------------------------------------
489
490/// The `diff_version` handler (WBSV-04): serve the RECORDED prev→current
491/// [`pmcp_workbook_runtime::VersionChangelog`] the offline promote step folded
492/// into the bundle (hash-verified at boot — NOT a runtime computation), stamped.
493pub struct DiffVersionHandler {
494    bundle: Arc<WorkbookBundle>,
495    stamp: ProvStamp,
496}
497
498impl DiffVersionHandler {
499    /// The registered tool name — the single source for registration + metadata.
500    pub const NAME: &str = "diff_version";
501
502    /// Build over the shared verified bundle.
503    #[must_use]
504    pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
505        let stamp = ProvStamp::from_bundle(&bundle);
506        Self { bundle, stamp }
507    }
508}
509
510/// Serialize the recorded [`pmcp_workbook_runtime::VersionChangelog`] into the
511/// served structured payload. Infallible — the changelog was hash-verified and
512/// parsed at boot, so serving it cannot fail.
513fn 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
525/// Project one [`pmcp_workbook_runtime::OutputDelta`] into its served JSON shape.
526fn 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
536/// Project one [`pmcp_workbook_runtime::OutputMeta`] into its served JSON.
537fn 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
571// ---- render_workbook ---------------------------------------------------------
572
573/// The `render_workbook` handler (WBSV-05): validate the inputs, then return a
574/// provenance-bound `workbook://` URI POINTER — NOT the `.xlsx` bytes. The bytes
575/// are recomputed per `resources/read` by [`super::render_resource`] from the
576/// decoded URI (stateless regen-on-read, Lambda-safe, V3).
577///
578/// The URI encodes the canonical inputs + the bundle [`ProvStamp`]
579/// (`combined_hash`, Codex HIGH #3) via [`render_uri::encode`]. A domain failure
580/// (invalid input, an un-encodable payload) routes through [`to_iserror_result`]
581/// into `structuredContent` — never a protocol-level error (T-92-10).
582pub struct RenderWorkbookHandler {
583    bundle: Arc<WorkbookBundle>,
584    stamp: ProvStamp,
585}
586
587impl RenderWorkbookHandler {
588    /// The registered tool name — the single source for registration + metadata.
589    pub const NAME: &str = "render_workbook";
590
591    /// Build over the shared verified bundle.
592    #[must_use]
593    pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
594        let stamp = ProvStamp::from_bundle(&bundle);
595        Self { bundle, stamp }
596    }
597
598    /// The linear `?`-chained `render_workbook` pipeline: validate → encode the
599    /// canonical DTO + provenance into a `workbook://` URI → return the POINTER
600    /// (plus the stamp), NOT the bytes.
601    #[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    /// A per-tool handler over the golden's FIRST output Table (the multi-tool
656    /// model lift — Plan 04). The served compute path is shared across tools, so a
657    /// handler over the first tool exercises the same validate→run→project pipeline
658    /// the single `calculate` handler used to.
659    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    /// H3 BINDING: the reserved-tool-name set the offline compiler rejects against
666    /// (`RESERVED_TOOL_NAMES`, in the runtime leaf) is EXACTLY the four meta tools
667    /// this toolkit registers — derived from their `NAME` constants. If a handler's
668    /// `NAME` ever changes (or a fifth meta tool is added) WITHOUT updating the shared
669    /// const, this binding test fails, so the compiler gate cannot silently drift from
670    /// what is registered.
671    #[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        // Each of this tool's named outputs is present as a { value, unit } pair.
695        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        // S-1: the success payload has EXACTLY outputs/accepted_overrides/
704        // provenance — no privileged headline scalar elevated above the
705        // uniform all-outputs projection.
706        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        // Provenance stamp on every result.
715        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        // CR-01: a caller-supplied input MUST drive the computation, not be
722        // silently discarded in favour of the bundle's baked-in default
723        // (gross_income=60000).
724        let handler = calc_handler();
725
726        // gross_income 100000, default deduction 12000 => taxable_income 88000.
727        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        // A DIFFERENT non-default input also flows (guards against a single-value
737        // coincidence): gross_income 80000 - 12000 => 68000.
738        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        // An out-of-enum filing_status is a domain failure.
754        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        // WR-06: a non-finite f64 must surface as an error, never JSON null.
770        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        // A finite number projects fine.
777        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        // WR-04: a declared output (verified in cell_map at boot) absent from the run
784        // result is a cell_map/IR skew, NOT a success. project_tool_outputs must fail
785        // closed with invalid_input so the served payload can never silently diverge
786        // from the advertised outputSchema (WBSV-07) — never an `else { continue }`.
787        let bundle = golden_bundle();
788        let tool = &bundle.cell_map.tools[0];
789        // A crafted RunResult whose `computed` map is EMPTY — every declared output's
790        // seed_coord is therefore absent.
791        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        // The named, missing output is identified in the message.
801        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        // Companion to the fail-closed test: when every declared output IS computed,
813        // project_tool_outputs returns the full { value, unit } map (no false positive).
814        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    // ---- sanitize_tool_name (WBV2-04, T-100-10 locked semantics) ----------
845
846    #[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        // Name is the sanitized tool name.
897        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    // ---- explain (WBSV-02, S-2) ------------------------------------------
907
908    #[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        // An ordered per-cell derivation trace.
916        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        // S-2: a GENERIC annotations object keyed by the manifest AnnotationDecl
924        // names (the tax golden declares bracket_boundary_1/2) — nothing
925        // domain-specific is hardcoded.
926        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    // ---- get_manifest (WBSV-03) ------------------------------------------
951
952    #[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        // Curated inputs/outputs/governed_data/changelog projections.
960        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    /// M5: `get_manifest` advertises the STRIPPED served key (the `json_key`) as the
979    /// input/output `name` — EXACTLY the keys the served tool schemas advertise — never
980    /// the raw `in_`/`out_` prefixed name. An agent that reads `get_manifest` then calls
981    /// the tool with the discovered name is therefore NOT rejected.
982    #[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        // The advertised input/output names from get_manifest.
990        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        // NO advertised name carries an in_/out_ governance prefix (stripped).
1004        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        // The WORKBOOK-WIDE served schema keys (get_manifest is a workbook-wide
1012        // projection — every manifest input/output, NOT a single tool's DAG-scoped
1013        // subset). M5 asserts get_manifest's advertised names EQUAL these served keys.
1014        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        // And every PER-TOOL served key is discoverable in get_manifest (a tool's
1035        // DAG-scoped subset is always covered by the workbook-wide advertised names),
1036        // so a discovered name is always callable.
1037        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    // ---- diff_version (WBSV-04) ------------------------------------------
1051
1052    #[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        // The served changelog matches the recorded one (not recomputed).
1058        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    // ---- render_workbook (WBSV-05) ---------------------------------------
1088
1089    #[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        // The response carries a workbook:// pointer, NOT the bytes.
1098        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        // The pointer decodes back to the bound provenance (Codex HIGH #3).
1110        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        // The success payload carries the provenance stamp.
1114        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}