1#![cfg_attr(
14 not(test),
15 deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
16)]
17
18use std::collections::HashSet;
19
20use serde_json::{json, Map, Value};
21
22use pmcp_workbook_runtime::{CellEntry, CellMap, CellRole, Dtype, Manifest, Tool};
23
24pub(crate) fn dtype_json_type(dtype: Dtype) -> &'static str {
27 match dtype {
28 Dtype::Number => "number",
29 Dtype::Text => "string",
30 Dtype::Bool => "boolean",
31 }
32}
33
34fn role_for_seed<'a>(manifest: &'a Manifest, seed_coord: &str) -> Option<&'a CellRole> {
37 pmcp_workbook_runtime::role_for_cell(manifest, seed_coord)
38}
39
40fn output_column_schema(unit: Option<&str>, role: Option<&CellRole>) -> Value {
46 let dtype = role.map_or(Dtype::Number, |r| r.dtype);
47 let mut value_prop = Map::new();
48 value_prop.insert("type".to_string(), json!(dtype_json_type(dtype)));
49 if let Some(u) = unit {
50 value_prop.insert("unit".to_string(), json!(u));
51 }
52
53 let mut props = Map::new();
54 props.insert("value".to_string(), Value::Object(value_prop));
55 props.insert("unit".to_string(), json!({ "type": ["string", "null"] }));
56
57 let mut col = Map::new();
58 col.insert("type".to_string(), json!("object"));
59 col.insert("additionalProperties".to_string(), json!(false));
60 col.insert("properties".to_string(), Value::Object(props));
61 col.insert("required".to_string(), json!(["value"]));
62 if let Some(meaning) = role.and_then(|r| r.meaning.as_deref()) {
63 col.insert("description".to_string(), json!(meaning));
64 }
65 Value::Object(col)
66}
67
68#[must_use]
78pub fn output_schema_for_manifest(manifest: &Manifest, cell_map: &CellMap) -> Value {
79 let all_outputs: Vec<CellEntry> = cell_map
83 .tools
84 .iter()
85 .flat_map(|t| t.outputs.iter().cloned())
86 .collect();
87 let output_props = output_props_for_entries(manifest, &all_outputs);
88
89 let mut success = Map::new();
90 success.insert(
91 "outputs".to_string(),
92 json!({
93 "type": "object",
94 "additionalProperties": false,
95 "properties": Value::Object(output_props),
96 }),
97 );
98 success.insert(
99 "accepted_overrides".to_string(),
100 json!({ "type": "array", "items": { "type": "string" } }),
101 );
102 result_envelope_schema(success)
103}
104
105fn output_props_for_entries(manifest: &Manifest, outputs: &[CellEntry]) -> Map<String, Value> {
109 let mut output_props = Map::new();
110 for entry in outputs {
111 let role = role_for_seed(manifest, &entry.seed_coord);
112 output_props.insert(
113 entry.json_key.clone(),
114 output_column_schema(entry.unit.as_deref(), role),
115 );
116 }
117 output_props
118}
119
120#[must_use]
126pub fn output_schema_for_tool(manifest: &Manifest, tool: &Tool) -> Value {
127 let output_props = output_props_for_entries(manifest, &tool.outputs);
128
129 let mut success = Map::new();
130 success.insert(
131 "outputs".to_string(),
132 json!({
133 "type": "object",
134 "additionalProperties": false,
135 "properties": Value::Object(output_props),
136 }),
137 );
138 success.insert(
139 "accepted_overrides".to_string(),
140 json!({ "type": "array", "items": { "type": "string" } }),
141 );
142 result_envelope_schema(success)
143}
144
145#[must_use]
158pub fn result_envelope_schema(success_props: Map<String, Value>) -> Value {
159 let mut props = success_props;
160 props.insert("isError".to_string(), json!({ "type": "boolean" }));
162 props.insert("code".to_string(), json!({ "type": "string" }));
163 props.insert("reason".to_string(), json!({ "type": "string" }));
164 props.insert("field".to_string(), json!({ "type": "string" }));
165 props.insert(
166 "allowed".to_string(),
167 json!({ "type": "array", "items": {} }),
168 );
169 props.insert("range".to_string(), json!({ "type": "array" }));
170 props.insert(
171 "required".to_string(),
172 json!({ "type": "array", "items": { "type": "string" } }),
173 );
174 props.insert("provenance".to_string(), provenance_schema());
176
177 json!({
178 "type": "object",
179 "additionalProperties": true,
180 "properties": Value::Object(props),
181 "required": ["provenance"],
182 })
183}
184
185#[must_use]
190pub fn explain_output_schema() -> Value {
191 let mut success = Map::new();
192 success.insert(
193 "steps".to_string(),
194 json!({
195 "type": "array",
196 "description": "Ordered business-language derivation steps.",
197 "items": {
198 "type": "object",
199 "additionalProperties": true,
200 "properties": {
201 "step": { "type": "string" },
202 "cell": { "type": "string" },
203 },
204 },
205 }),
206 );
207 success.insert(
208 "annotations".to_string(),
209 json!({
210 "type": "object",
211 "description": "Manifest-declared annotations (keyed by AnnotationDecl name).",
212 "additionalProperties": { "type": "object" },
213 }),
214 );
215 result_envelope_schema(success)
216}
217
218#[must_use]
222pub fn get_manifest_output_schema() -> Value {
223 let mut success = Map::new();
224 success.insert("bundle_id".to_string(), json!({ "type": "string" }));
225 success.insert("version".to_string(), json!({ "type": "string" }));
226 success.insert("combined_hash".to_string(), json!({ "type": "string" }));
227 for field in ["inputs", "outputs", "governed_data", "changelog"] {
228 success.insert(
229 field.to_string(),
230 json!({ "type": "array", "items": { "type": "object" } }),
231 );
232 }
233 result_envelope_schema(success)
234}
235
236#[must_use]
241pub fn diff_version_output_schema() -> Value {
242 let mut success = Map::new();
243 success.insert("from_version".to_string(), json!({ "type": "string" }));
244 success.insert("to_version".to_string(), json!({ "type": "string" }));
245 success.insert(
246 "deltas".to_string(),
247 json!({
248 "type": "array",
249 "description": "Per-output change records (region, change class, old/new \
250 meaning+unit+provenance, drift/redefinition severity).",
251 "items": {
252 "type": "object",
253 "additionalProperties": true,
254 "properties": {
255 "region": { "type": "string" },
256 "change_class": { "type": "string" },
257 "severity": { "type": "string" },
258 },
259 },
260 }),
261 );
262 success.insert(
263 "summary".to_string(),
264 json!({ "type": "string", "description": "Human-readable transition summary." }),
265 );
266 result_envelope_schema(success)
267}
268
269#[must_use]
273pub fn render_workbook_output_schema() -> Value {
274 let mut success = Map::new();
275 success.insert(
276 "resource_uri".to_string(),
277 json!({
278 "type": "string",
279 "description": "A provenance-bound workbook:// resource URI. Read it via \
280 resources/read to obtain the base64-encoded .xlsx, which is \
281 regenerated statelessly from the URI on each read. The URI \
282 encodes the inputs — treat it as sensitive.",
283 }),
284 );
285 success.insert(
286 "mime_type".to_string(),
287 json!({
288 "type": "string",
289 "description": "The MIME type of the resource the URI resolves to (the OOXML \
290 spreadsheet type).",
291 }),
292 );
293 result_envelope_schema(success)
294}
295
296#[must_use]
299pub fn provenance_schema() -> Value {
300 json!({
301 "type": "object",
302 "additionalProperties": false,
303 "properties": {
304 "bundle_id": { "type": "string" },
305 "version": { "type": "string" },
306 "combined_hash": { "type": "string" },
307 },
308 "required": ["bundle_id", "version", "combined_hash"],
309 })
310}
311
312#[must_use]
318pub fn input_schema_for_manifest(manifest: &Manifest, cell_map: &CellMap) -> Value {
319 let mut input_props = Map::new();
320 for entry in &cell_map.inputs {
321 input_props.insert(
322 entry.json_key.clone(),
323 input_prop_for_entry(manifest, entry),
324 );
325 }
326 assemble_input_schema(manifest, input_props)
327}
328
329#[must_use]
338pub fn input_schema_for_tool(manifest: &Manifest, cell_map: &CellMap, tool: &Tool) -> Value {
339 let reached: HashSet<&str> = tool.input_keys.iter().map(String::as_str).collect();
342 let project_all = tool.input_keys.is_empty();
351 let mut input_props = Map::new();
352 for entry in &cell_map.inputs {
353 if project_all || reached.contains(entry.json_key.as_str()) {
355 input_props.insert(
356 entry.json_key.clone(),
357 input_prop_for_entry(manifest, entry),
358 );
359 }
360 }
361 assemble_input_schema(manifest, input_props)
362}
363
364fn input_prop_for_entry(manifest: &Manifest, entry: &CellEntry) -> Value {
369 let role = role_for_seed(manifest, &entry.seed_coord);
370 let dtype = role.map_or(Dtype::Number, |r| r.dtype);
371 let mut prop = Map::new();
372 prop.insert("type".to_string(), json!(dtype_json_type(dtype)));
373 if let Some(unit) = entry.unit.as_deref() {
374 prop.insert("unit".to_string(), json!(unit));
375 }
376 if let Some(meaning) = role.and_then(|r| r.meaning.as_deref()) {
377 prop.insert("description".to_string(), json!(meaning));
378 }
379 if let Some(allowed) = role.and_then(|r| r.allowed_values.as_ref()) {
383 prop.insert("enum".to_string(), json!(allowed));
384 }
385 Value::Object(prop)
386}
387
388fn assemble_input_schema(manifest: &Manifest, input_props: Map<String, Value>) -> Value {
393 let mut override_props = Map::new();
401 for key in crate::workbook::input::variable_tier_keys(manifest) {
402 override_props.insert(
403 key,
404 json!({ "type": ["number", "string", "boolean", "null"] }),
405 );
406 }
407
408 json!({
409 "type": "object",
410 "additionalProperties": false,
411 "properties": {
412 "inputs": {
413 "type": "object",
414 "additionalProperties": false,
415 "properties": Value::Object(input_props),
416 },
417 "overrides": {
418 "type": "object",
419 "additionalProperties": { "type": ["number", "string", "boolean", "null"] },
420 "properties": Value::Object(override_props),
421 "description": "Variable-tier parameter overrides, keyed by parameter \
422 name or cell key. Strict (BA-governed) constants are rejected.",
423 },
424 },
425 })
426}
427
428#[must_use]
430pub fn empty_input_schema() -> Value {
431 json!({ "type": "object", "additionalProperties": false })
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use pmcp_workbook_runtime::CellValue;
438 use pmcp_workbook_runtime::{CellEntry, CellMap, InputTier, Role, Tool};
439
440 fn input_role(
441 cell: &str,
442 dtype: Dtype,
443 meaning: &str,
444 allowed: Option<Vec<String>>,
445 ) -> CellRole {
446 CellRole {
447 cell: cell.to_string(),
448 role: Role::Input,
449 name: None,
450 unit: Some("USD".to_string()),
451 meaning: Some(meaning.to_string()),
452 dtype,
453 colour_evidence: None,
454 source: "test".to_string(),
455 notes: None,
456 tier: Some(InputTier::Variable {
457 default: CellValue::Number(0.0),
458 }),
459 allowed_values: allowed,
460 }
461 }
462
463 fn output_role(cell: &str, meaning: &str) -> CellRole {
464 CellRole {
465 cell: cell.to_string(),
466 role: Role::Output,
467 name: None,
468 unit: Some("USD".to_string()),
469 meaning: Some(meaning.to_string()),
470 dtype: Dtype::Number,
471 colour_evidence: None,
472 source: "test".to_string(),
473 notes: None,
474 tier: None,
475 allowed_values: None,
476 }
477 }
478
479 fn manifest_with(cells: Vec<CellRole>) -> Manifest {
480 Manifest {
481 schema_version: 1,
482 workflow: "tax-calc".to_string(),
483 workbook_hash: None,
484 ratified: true,
485 ratified_by: None,
486 ratified_at: None,
487 cells,
488 loop_block: None,
489 governed_data: vec![],
490 changelog: vec![],
491 capability_calls: vec![],
492 annotations: vec![],
493 }
494 }
495
496 fn three_input_manifest_and_map() -> (Manifest, CellMap) {
497 let manifest = manifest_with(vec![
498 input_role("1_Inputs!B2", Dtype::Number, "Gross income", None),
499 input_role(
500 "1_Inputs!B3",
501 Dtype::Text,
502 "Filing status",
503 Some(vec!["single".to_string(), "married_joint".to_string()]),
504 ),
505 input_role("1_Inputs!B4", Dtype::Number, "Deductions", None),
506 output_role("3_Outputs!B2", "Taxable income"),
507 output_role("3_Outputs!B3", "Tax owed"),
508 ]);
509 let cell_map = CellMap {
510 inputs: vec![
511 CellEntry {
512 json_key: "gross_income".to_string(),
513 seed_coord: "1_Inputs!B2".to_string(),
514 unit: Some("USD".to_string()),
515 },
516 CellEntry {
517 json_key: "filing_status".to_string(),
518 seed_coord: "1_Inputs!B3".to_string(),
519 unit: None,
520 },
521 CellEntry {
522 json_key: "deductions".to_string(),
523 seed_coord: "1_Inputs!B4".to_string(),
524 unit: Some("USD".to_string()),
525 },
526 ],
527 tools: vec![Tool {
528 name: "calculate".to_string(),
529 description: None,
530 input_keys: Vec::new(),
531 outputs: vec![
532 CellEntry {
533 json_key: "taxable_income".to_string(),
534 seed_coord: "3_Outputs!B2".to_string(),
535 unit: Some("USD".to_string()),
536 },
537 CellEntry {
538 json_key: "tax_owed".to_string(),
539 seed_coord: "3_Outputs!B3".to_string(),
540 unit: Some("USD".to_string()),
541 },
542 ],
543 oracle: std::collections::BTreeMap::new(),
544 }],
545 };
546 (manifest, cell_map)
547 }
548
549 #[test]
550 fn input_schema_is_strict_and_projects_all_inputs() {
551 let (m, cm) = three_input_manifest_and_map();
552 let schema = input_schema_for_manifest(&m, &cm);
553 assert_eq!(schema["additionalProperties"], false);
555 let props = &schema["properties"]["inputs"]["properties"];
556 assert_eq!(props["gross_income"]["type"], json!("number"));
558 assert_eq!(props["gross_income"]["unit"], json!("USD"));
559 assert_eq!(props["gross_income"]["description"], json!("Gross income"));
560 assert_eq!(props["filing_status"]["type"], json!("string"));
561 assert_eq!(props["deductions"]["type"], json!("number"));
562 assert_eq!(
564 schema["properties"]["inputs"]["additionalProperties"],
565 false
566 );
567 }
568
569 #[test]
570 fn input_schema_emits_enum_for_allowed_values_and_keeps_it_optional() {
571 let (m, cm) = three_input_manifest_and_map();
572 let schema = input_schema_for_manifest(&m, &cm);
573 let props = &schema["properties"]["inputs"]["properties"];
574 assert_eq!(
575 props["filing_status"]["enum"],
576 json!(["single", "married_joint"]),
577 "allowed_values surfaces as a JSON-Schema enum (verbatim order)"
578 );
579 assert!(props["gross_income"].get("enum").is_none());
581 assert!(schema["properties"]["inputs"].get("required").is_none());
583 }
584
585 #[test]
586 fn output_schema_is_non_empty_and_carries_every_named_output() {
587 let (m, cm) = three_input_manifest_and_map();
588 let schema = output_schema_for_manifest(&m, &cm);
589 let outputs = &schema["properties"]["outputs"]["properties"];
590 assert!(outputs["taxable_income"].is_object());
592 assert!(outputs["tax_owed"].is_object());
593 assert_eq!(
594 outputs["taxable_income"]["properties"]["value"]["unit"],
595 "USD"
596 );
597 assert_eq!(
598 outputs["taxable_income"]["properties"]["value"]["type"],
599 "number"
600 );
601 let headline_key = ["supply", "_", "total"].concat();
606 assert!(
607 schema["properties"].get(&headline_key).is_none(),
608 "no privileged headline field at the root (S-1)"
609 );
610 assert!(schema["properties"]["provenance"].is_object());
612 assert!(
613 !outputs
614 .as_object()
615 .expect("outputs is an object")
616 .is_empty(),
617 "outputSchema must enumerate at least one output"
618 );
619 }
620
621 #[test]
622 fn result_envelope_accepts_both_success_and_iserror_shapes() {
623 let (m, cm) = three_input_manifest_and_map();
624 let schema = output_schema_for_manifest(&m, &cm);
625 assert_eq!(schema["properties"]["isError"]["type"], "boolean");
627 assert_eq!(schema["properties"]["code"]["type"], "string");
628 assert_eq!(schema["properties"]["reason"]["type"], "string");
629 assert_eq!(schema["additionalProperties"], true);
631 assert_eq!(schema["required"], json!(["provenance"]));
633 }
634
635 #[test]
636 fn overrides_advertise_variable_tier_keys() {
637 let (m, cm) = three_input_manifest_and_map();
640 let schema = input_schema_for_manifest(&m, &cm);
641 let override_props = &schema["properties"]["overrides"]["properties"];
642 let props = override_props
643 .as_object()
644 .expect("overrides.properties is an object");
645 for key in ["1_Inputs!B2", "1_Inputs!B3", "1_Inputs!B4"] {
649 assert!(
650 props.contains_key(key),
651 "overrides advertises the variable-tier key `{key}` (got {props:?})"
652 );
653 assert_eq!(
654 override_props[key]["type"],
655 json!(["number", "string", "boolean", "null"]),
656 "each advertised override carries the permissive value-type union"
657 );
658 }
659 assert!(
661 !props.contains_key("3_Outputs!B2") && !props.contains_key("taxable_income"),
662 "a computed output is never an advertised override key"
663 );
664 }
665
666 #[test]
667 fn overrides_advertise_named_param_keys() {
668 let mut named = input_role("1_Inputs!B2", Dtype::Number, "Gross income", None);
671 named.name = Some("in_gross_income".to_string());
672 let m = manifest_with(vec![named, output_role("3_Outputs!B2", "Taxable income")]);
673 let cm = CellMap {
674 inputs: vec![CellEntry {
675 json_key: "gross_income".to_string(),
676 seed_coord: "1_Inputs!B2".to_string(),
677 unit: Some("USD".to_string()),
678 }],
679 tools: vec![Tool {
680 name: "calculate".to_string(),
681 description: None,
682 input_keys: Vec::new(),
683 outputs: vec![CellEntry {
684 json_key: "taxable_income".to_string(),
685 seed_coord: "3_Outputs!B2".to_string(),
686 unit: Some("USD".to_string()),
687 }],
688 oracle: std::collections::BTreeMap::new(),
689 }],
690 };
691 let schema = input_schema_for_manifest(&m, &cm);
692 let override_props = &schema["properties"]["overrides"]["properties"];
693 assert!(
694 override_props["in_gross_income"].is_object(),
695 "the named variable-tier param is advertised under its name"
696 );
697 }
698
699 #[test]
700 fn overrides_keep_open_additional_properties_for_discoverability_only() {
701 let (m, cm) = three_input_manifest_and_map();
705 let schema = input_schema_for_manifest(&m, &cm);
706 let overrides = &schema["properties"]["overrides"];
707 assert_eq!(
708 overrides["additionalProperties"],
709 json!({ "type": ["number", "string", "boolean", "null"] }),
710 "the open value-typed additionalProperties map is preserved"
711 );
712 assert!(
713 overrides["description"].as_str().is_some(),
714 "the prose override description is retained"
715 );
716 }
717
718 #[test]
719 fn provenance_schema_uses_combined_hash_never_workbook_hash() {
720 let schema = provenance_schema();
721 let props = &schema["properties"];
722 assert!(props["combined_hash"].is_object());
723 assert!(
724 props.get("workbook_hash").is_none(),
725 "the provenance schema must never carry workbook_hash (Codex HIGH #3)"
726 );
727 assert_eq!(
728 schema["required"],
729 json!(["bundle_id", "version", "combined_hash"])
730 );
731 }
732
733 #[test]
734 fn empty_input_keys_projects_full_pool() {
735 let (m, cm) = three_input_manifest_and_map();
741 let tool = &cm.tools[0];
742 assert!(
743 tool.input_keys.is_empty(),
744 "the fixture tool has no derived keys"
745 );
746 let schema = input_schema_for_tool(&m, &cm, tool);
747 let props = schema["properties"]["inputs"]["properties"]
748 .as_object()
749 .expect("inputs.properties object");
750 assert!(
751 !props.is_empty(),
752 "an empty-input_keys tool advertises the full pool, not an empty schema"
753 );
754 for key in ["gross_income", "filing_status", "deductions"] {
756 assert!(
757 props.contains_key(key),
758 "the full shared-input pool is advertised: missing {key}"
759 );
760 }
761 assert_eq!(
763 schema["properties"]["inputs"]["additionalProperties"],
764 json!(false),
765 "the strict per-tool envelope is preserved"
766 );
767 }
768
769 #[test]
770 fn populated_input_keys_projects_only_reached() {
771 let (m, mut cm) = three_input_manifest_and_map();
774 cm.tools[0].input_keys = vec!["gross_income".to_string()];
775 let schema = input_schema_for_tool(&m, &cm, &cm.tools[0]);
776 let props = schema["properties"]["inputs"]["properties"]
777 .as_object()
778 .expect("inputs.properties object");
779 assert_eq!(props.len(), 1, "only the reached key is projected");
780 assert!(props.contains_key("gross_income"));
781 assert!(!props.contains_key("deductions"));
782 }
783}