use serde::{Deserialize, Serialize};
use crate::sheet_ir::value::CellValue;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Input,
Constant,
Output,
Formula,
}
impl Role {
pub fn from_name_prefix(name: &str) -> Option<Role> {
if name.starts_with("in_") {
Some(Role::Input)
} else if name.starts_with("const_") {
Some(Role::Constant)
} else if name.starts_with("out_") {
Some(Role::Output)
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Dtype {
Number,
Text,
Bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct CellRole {
pub cell: String,
pub role: Role,
pub name: Option<String>,
pub unit: Option<String>,
pub meaning: Option<String>,
pub dtype: Dtype,
pub colour_evidence: Option<String>,
pub source: String,
pub notes: Option<String>,
#[serde(default)]
pub tier: Option<InputTier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_values: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum InputTier {
Variable {
default: CellValue,
},
BoundedVariable {
default: CellValue,
min: CellValue,
max: CellValue,
},
}
pub fn json_key_for_role(role: &CellRole) -> String {
if let Some(name) = role.name.as_deref() {
return strip_governance_prefix(name).to_string();
}
role.meaning.clone().unwrap_or_else(|| role.cell.clone())
}
fn strip_governance_prefix(name: &str) -> &str {
for prefix in ["in_", "out_"] {
if let Some(rest) = name.strip_prefix(prefix) {
if !rest.is_empty() {
return rest;
}
}
}
name
}
pub const RESERVED_TOOL_NAMES: [&str; 4] =
["explain", "get_manifest", "diff_version", "render_workbook"];
pub fn sanitize_tool_name(raw: &str) -> Result<String, String> {
let mut out = String::with_capacity(raw.len());
let mut pending_underscore = false;
for ch in raw.chars() {
let lc = ch.to_ascii_lowercase();
if lc.is_ascii_alphanumeric() || lc == '_' || lc == '-' {
if pending_underscore && !out.is_empty() {
out.push('_');
}
pending_underscore = false;
out.push(lc);
} else {
pending_underscore = true;
}
}
let trimmed: String = out
.trim_matches(|c| c == '_' || c == '-')
.chars()
.take(64)
.collect();
let trimmed = trimmed.trim_matches(|c| c == '_' || c == '-').to_string();
if trimmed.is_empty() {
return Err(raw.to_string());
}
Ok(trimmed)
}
pub fn is_strict_constant(role: &CellRole) -> bool {
matches!(role.role, Role::Constant) && role.tier.is_none()
}
pub fn is_computed(role: &CellRole) -> bool {
matches!(role.role, Role::Output | Role::Formula)
}
pub fn role_for_cell<'a>(manifest: &'a Manifest, cell_key: &str) -> Option<&'a CellRole> {
manifest.cells.iter().find(|c| c.cell == cell_key)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ChangelogEntry {
pub version: String,
pub workbook_hash: String,
pub note: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct CapabilityDecl {
pub cell: String,
pub kind: String,
pub declared_contract: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct LoopDecl {
pub loop_name: String,
pub loop_range: String,
pub header_row: String,
pub output_cols: Vec<String>,
pub start_row: u32,
pub end_row: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GovernedDatum {
pub key: String,
pub value: CellValue,
pub effective_date: Option<String>,
pub approved_by: Option<String>,
pub provenance: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AnnotationDecl {
pub name: String,
pub target: String,
pub meaning: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Manifest {
pub schema_version: u32,
pub workflow: String,
pub workbook_hash: Option<String>,
pub ratified: bool,
pub ratified_by: Option<String>,
pub ratified_at: Option<String>,
pub cells: Vec<CellRole>,
pub loop_block: Option<LoopDecl>,
#[serde(default)]
pub governed_data: Vec<GovernedDatum>,
#[serde(default)]
pub changelog: Vec<ChangelogEntry>,
#[serde(default)]
pub capability_calls: Vec<CapabilityDecl>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub annotations: Vec<AnnotationDecl>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn role_has_exactly_the_four_variants() {
let all = [Role::Input, Role::Constant, Role::Output, Role::Formula];
for r in all {
match r {
Role::Input | Role::Constant | Role::Output | Role::Formula => {},
}
}
assert_eq!(all.len(), 4, "Role must have exactly four variants");
}
#[test]
fn from_name_prefix_maps_the_three_role_prefixes() {
assert_eq!(Role::from_name_prefix("in_total_area"), Some(Role::Input));
assert_eq!(Role::from_name_prefix("const_margin"), Some(Role::Constant));
assert_eq!(Role::from_name_prefix("out_first_fix"), Some(Role::Output));
assert_eq!(Role::from_name_prefix("Rooms"), None);
assert_eq!(Role::from_name_prefix("unprefixed"), None);
}
#[test]
fn manifest_round_trips_through_serde_json() {
let manifest = Manifest {
schema_version: 1,
workflow: "ufh-quote".to_string(),
workbook_hash: Some("abc123".to_string()),
ratified: false,
ratified_by: None,
ratified_at: None,
cells: vec![
CellRole {
cell: "1_Inputs!E6".to_string(),
role: Role::Input,
name: Some("in_total_area".to_string()),
unit: Some("m2".to_string()),
meaning: Some("Total floor area".to_string()),
dtype: Dtype::Number,
colour_evidence: Some("FF0000FF".to_string()),
source: "colour+guide".to_string(),
notes: None,
tier: None,
allowed_values: None,
},
CellRole {
cell: "2_Constants!B2".to_string(),
role: Role::Constant,
name: None,
unit: None,
meaning: None,
dtype: Dtype::Number,
colour_evidence: Some("FFFFFF00".to_string()),
source: "yellow-assumption".to_string(),
notes: Some("BA assumption".to_string()),
tier: None,
allowed_values: None,
},
],
loop_block: None,
governed_data: vec![
GovernedDatum {
key: "const_coil_divisor".to_string(),
value: CellValue::Number(100.0),
effective_date: Some("2026-06-06".to_string()),
approved_by: Some("BA".to_string()),
provenance: Some("design §11.1".to_string()),
},
GovernedDatum {
key: "const_pipe_family".to_string(),
value: CellValue::Text("16mm".to_string()),
effective_date: None,
approved_by: None,
provenance: None,
},
],
changelog: vec![],
capability_calls: vec![],
annotations: vec![],
};
let json = serde_json::to_string(&manifest).expect("serialize Manifest");
let back: Manifest = serde_json::from_str(&json).expect("deserialize Manifest");
assert_eq!(manifest, back, "Manifest must serde round-trip to equality");
}
#[test]
fn governed_data_table_round_trips_a_non_numeric_typed_value() {
let manifest = Manifest {
schema_version: 1,
workflow: "ufh-quote".to_string(),
workbook_hash: None,
ratified: true,
ratified_by: Some("BA".to_string()),
ratified_at: Some("2026-06-06".to_string()),
cells: vec![],
loop_block: None,
governed_data: vec![GovernedDatum {
key: "const_install_enabled".to_string(),
value: CellValue::Bool(true),
effective_date: Some("2026-06-06".to_string()),
approved_by: Some("BA".to_string()),
provenance: Some("BA-doc §4".to_string()),
}],
changelog: vec![],
capability_calls: vec![],
annotations: vec![],
};
let json = serde_json::to_string(&manifest).expect("serialize Manifest");
let back: Manifest = serde_json::from_str(&json).expect("deserialize Manifest");
assert_eq!(manifest, back);
assert_eq!(back.governed_data[0].value, CellValue::Bool(true));
}
#[test]
fn governed_data_defaults_to_empty_when_absent_from_json() {
let json = r#"{
"schema_version": 1,
"workflow": "ufh-quote",
"workbook_hash": null,
"ratified": false,
"ratified_by": null,
"ratified_at": null,
"cells": [],
"loop_block": null
}"#;
let m: Manifest = serde_json::from_str(json).expect("deserialize without governed_data");
assert!(m.governed_data.is_empty());
}
#[test]
fn yellow_assumption_is_a_constant_with_source() {
let cell = CellRole {
cell: "2_Constants!B2".to_string(),
role: Role::Constant,
name: None,
unit: None,
meaning: None,
dtype: Dtype::Number,
colour_evidence: Some("FFFFFF00".to_string()),
source: "yellow-assumption".to_string(),
notes: None,
tier: None,
allowed_values: None,
};
assert_eq!(cell.role, Role::Constant);
assert_eq!(cell.source, "yellow-assumption");
}
#[test]
fn schema_for_manifest_produces_a_schema_without_panic() {
let schema = schemars::schema_for!(Manifest);
let json = serde_json::to_value(&schema).expect("schema serializes");
assert_eq!(json["title"], "Manifest");
}
fn role_with_tier(role: Role, tier: Option<InputTier>) -> CellRole {
CellRole {
cell: "1_Inputs!E6".to_string(),
role,
name: None,
unit: None,
meaning: None,
dtype: Dtype::Number,
colour_evidence: None,
source: "test".to_string(),
notes: None,
tier,
allowed_values: None,
}
}
#[test]
fn tier_defaults_to_none_when_absent() {
let json = r#"{
"cell": "1_Inputs!E6",
"role": "input",
"name": null,
"unit": null,
"meaning": null,
"dtype": "number",
"colour_evidence": null,
"source": "test",
"notes": null
}"#;
let r: CellRole = serde_json::from_str(json).expect("deserialize without tier");
assert_eq!(r.tier, None, "absent tier must default to None");
}
#[test]
fn variable_tier_round_trips() {
let r = role_with_tier(
Role::Input,
Some(InputTier::Variable {
default: CellValue::Number(0.37),
}),
);
let json = serde_json::to_string(&r).expect("serialize CellRole with Variable tier");
let back: CellRole = serde_json::from_str(&json).expect("deserialize");
assert_eq!(r, back, "Variable-tier CellRole must serde round-trip");
}
#[test]
fn bounded_variable_carries_unenforced_range() {
let r = role_with_tier(
Role::Input,
Some(InputTier::BoundedVariable {
default: CellValue::Number(0.2),
min: CellValue::Number(0.1),
max: CellValue::Number(0.3),
}),
);
let json = serde_json::to_string(&r).expect("serialize BoundedVariable tier");
let back: CellRole = serde_json::from_str(&json).expect("deserialize");
assert_eq!(
r, back,
"BoundedVariable carries min/max through round-trip"
);
match back.tier {
Some(InputTier::BoundedVariable { min, max, .. }) => {
assert_eq!(min, CellValue::Number(0.1));
assert_eq!(max, CellValue::Number(0.3));
},
other => panic!("expected BoundedVariable, got {other:?}"),
}
}
#[test]
fn allowed_values_defaults_to_none_when_absent() {
let json = r#"{
"cell": "1_Inputs!C6",
"role": "input",
"name": null,
"unit": null,
"meaning": null,
"dtype": "text",
"colour_evidence": null,
"source": "test",
"notes": null
}"#;
let r: CellRole = serde_json::from_str(json).expect("deserialize without allowed_values");
assert_eq!(
r.allowed_values, None,
"absent allowed_values must default to None"
);
}
#[test]
fn allowed_values_round_trips_when_some() {
let mut r = role_with_tier(Role::Input, None);
r.allowed_values = Some(vec!["heat_pump".to_string(), "boiler".to_string()]);
let json = serde_json::to_string(&r).expect("serialize CellRole with allowed_values");
let back: CellRole = serde_json::from_str(&json).expect("deserialize");
assert_eq!(
r, back,
"Some(allowed_values) CellRole must serde round-trip to equality"
);
assert_eq!(
back.allowed_values,
Some(vec!["heat_pump".to_string(), "boiler".to_string()]),
"workbook order is preserved through the round-trip"
);
}
#[test]
fn allowed_values_is_skipped_from_json_when_none() {
let r = role_with_tier(Role::Input, None);
let v = serde_json::to_value(&r).expect("serialize CellRole");
assert!(
v.get("allowed_values").is_none(),
"None allowed_values must be skipped from serialization, got {v}"
);
}
#[test]
fn changelog_and_capability_calls_default_empty() {
let json = r#"{
"schema_version": 1,
"workflow": "ufh-quote",
"workbook_hash": null,
"ratified": false,
"ratified_by": null,
"ratified_at": null,
"cells": [],
"loop_block": null
}"#;
let m: Manifest =
serde_json::from_str(json).expect("deserialize without changelog/capability_calls");
assert!(m.changelog.is_empty(), "absent changelog defaults empty");
assert!(
m.capability_calls.is_empty(),
"absent capability_calls defaults empty"
);
}
#[test]
fn annotations_default_to_empty_when_absent_from_json() {
let json = r#"{
"schema_version": 1,
"workflow": "tax-calc",
"workbook_hash": null,
"ratified": false,
"ratified_by": null,
"ratified_at": null,
"cells": [],
"loop_block": null
}"#;
let m: Manifest = serde_json::from_str(json).expect("deserialize without annotations");
assert!(
m.annotations.is_empty(),
"absent annotations must default to an empty Vec"
);
}
#[test]
fn annotations_round_trip_to_equality_when_present() {
let mut m: Manifest = serde_json::from_str(
r#"{
"schema_version": 1,
"workflow": "tax-calc",
"workbook_hash": null,
"ratified": false,
"ratified_by": null,
"ratified_at": null,
"cells": [],
"loop_block": null
}"#,
)
.expect("base manifest");
m.annotations = vec![
AnnotationDecl {
name: "headline".to_string(),
target: "out_total".to_string(),
meaning: "The total payable amount".to_string(),
},
AnnotationDecl {
name: "rate".to_string(),
target: "1_Inputs!E6".to_string(),
meaning: "The applied tax rate".to_string(),
},
];
let json = serde_json::to_string(&m).expect("serialize Manifest with annotations");
let back: Manifest = serde_json::from_str(&json).expect("deserialize");
assert_eq!(m, back, "annotations must serde round-trip to equality");
}
#[test]
fn empty_annotations_are_skipped_from_serialization() {
let m: Manifest = serde_json::from_str(
r#"{
"schema_version": 1,
"workflow": "tax-calc",
"workbook_hash": null,
"ratified": false,
"ratified_by": null,
"ratified_at": null,
"cells": [],
"loop_block": null
}"#,
)
.expect("base manifest");
let v = serde_json::to_value(&m).expect("serialize Manifest");
assert!(
v.get("annotations").is_none(),
"empty annotations must be skipped from serialization, got {v}"
);
}
#[test]
fn role_ontology_still_has_exactly_four() {
let all = [Role::Input, Role::Constant, Role::Output, Role::Formula];
for r in all {
match r {
Role::Input | Role::Constant | Role::Output | Role::Formula => {},
}
}
assert_eq!(all.len(), 4, "Role must still have exactly four variants");
}
#[test]
fn untiered_input_role_documented_not_strict() {
let untiered_input = role_with_tier(Role::Input, None);
let untiered_const = role_with_tier(Role::Constant, None);
assert!(
!is_strict_constant(&untiered_input),
"an untiered Role::Input must NOT be treated as a strict constant"
);
assert!(
is_strict_constant(&untiered_const),
"an untiered Role::Constant IS a strict constant (fails closed)"
);
let tiered_const = role_with_tier(
Role::Constant,
Some(InputTier::Variable {
default: CellValue::Number(1.0),
}),
);
assert!(
!is_strict_constant(&tiered_const),
"a Constant with an explicit tier is no longer strict"
);
}
fn named_role(role: Role, name: &str) -> CellRole {
let mut r = role_with_tier(role, None);
r.name = Some(name.to_string());
r
}
#[test]
fn json_key_strips_leading_in_prefix_from_name() {
let r = named_role(Role::Input, "in_gross_income");
assert_eq!(
json_key_for_role(&r),
"gross_income",
"the served input key must drop the in_ governance prefix"
);
}
#[test]
fn json_key_strips_leading_out_prefix_from_name() {
let r = named_role(Role::Output, "out_tax_owed");
assert_eq!(json_key_for_role(&r), "tax_owed");
}
#[test]
fn json_key_does_not_mutate_role_name() {
let r = named_role(Role::Input, "in_gross_income");
let _ = json_key_for_role(&r);
assert_eq!(
r.name.as_deref(),
Some("in_gross_income"),
"role.name must stay prefixed for governance/named-range matching"
);
}
#[test]
fn json_key_strips_only_a_single_prefix() {
let r = named_role(Role::Input, "in_in_x");
assert_eq!(json_key_for_role(&r), "in_x");
}
#[test]
fn json_key_leaves_unprefixed_name_untouched() {
let r = named_role(Role::Input, "loan_amount");
assert_eq!(json_key_for_role(&r), "loan_amount");
let r2 = named_role(Role::Input, "margin_in_pct");
assert_eq!(json_key_for_role(&r2), "margin_in_pct");
}
#[test]
fn json_key_does_not_strip_prefix_only_name() {
let r = named_role(Role::Input, "in_");
assert_eq!(json_key_for_role(&r), "in_");
let r2 = named_role(Role::Output, "out_");
assert_eq!(json_key_for_role(&r2), "out_");
}
#[test]
fn json_key_strip_does_not_apply_to_meaning_or_cell_fallback() {
let mut r = role_with_tier(Role::Input, None);
r.name = None;
r.meaning = Some("in_some_label".to_string());
assert_eq!(json_key_for_role(&r), "in_some_label");
let mut r2 = role_with_tier(Role::Output, None);
r2.name = None;
r2.meaning = None;
assert_eq!(json_key_for_role(&r2), "1_Inputs!E6");
}
#[test]
fn prop_strip_removes_at_most_one_prefix_and_is_loss_free() {
let corpus = [
"in_gross_income",
"out_tax_owed",
"in_in_x",
"loan_amount",
"margin_in_pct",
"in_",
"out_",
"x",
"in_a",
"outflow", "inflow", ];
for raw in corpus {
let once = strip_governance_prefix(raw);
assert!(
!once.is_empty(),
"non-empty name {raw:?} must not strip to empty"
);
let removed_one = raw
.strip_prefix("in_")
.or_else(|| raw.strip_prefix("out_"))
.map_or(false, |rest| !rest.is_empty() && once == rest);
assert!(
once == raw || removed_one,
"strip removes at most one prefix for {raw:?} (got {once:?})"
);
if !raw.starts_with("in_") && !raw.starts_with("out_") {
assert_eq!(once, raw, "non-prefixed {raw:?} must be returned verbatim");
}
}
}
#[test]
fn prop_strip_is_idempotent_on_served_keys() {
for served in [
"gross_income",
"tax_owed",
"loan_amount",
"x",
"in_", ] {
assert_eq!(
strip_governance_prefix(served),
served,
"an already-served key {served:?} must be a strip no-op"
);
}
}
}