use crate::ir::{IrState, IrTransition};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum SpecProvenance {
Observed,
Inferred,
Assumed,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct FunctionalSpec {
pub spec_version: String,
pub target: SpecTarget,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entities: Vec<Entity>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub operations: Vec<Operation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ui_states: Vec<IrState>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub navigation: Vec<IrTransition>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<AuthModel>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assumptions: Vec<AssumptionEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SpecTarget {
pub source_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Entity {
pub name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<EntityField>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relationships: Vec<Relationship>,
pub confidence: SpecProvenance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credibility: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EntityField {
pub name: String,
#[serde(rename = "type")]
pub field_type: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub values: Vec<String>,
pub confidence: SpecProvenance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credibility: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Relationship {
pub to: String,
pub kind: String,
pub confidence: SpecProvenance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credibility: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Operation {
pub name: String,
pub verb: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entity: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub inputs: Vec<OperationInput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effect: Option<OperationEffect>,
pub confidence: SpecProvenance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credibility: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct OperationInput {
pub field: String,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub validation: Option<ValidationRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ValidationRule {
pub rule: String,
pub confidence: SpecProvenance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credibility: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct OperationEffect {
pub confidence: SpecProvenance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assumption: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credibility: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AuthModel {
pub model: String,
pub confidence: SpecProvenance,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<AuthRole>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credibility: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AuthRole {
pub name: String,
pub confidence: SpecProvenance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credibility: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AssumptionEntry {
#[serde(rename = "ref")]
pub r#ref: String,
pub default_applied: String,
#[serde(default = "default_overridable")]
pub overridable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
fn default_overridable() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provenance_snake_case_round_trip() {
for (wire, variant) in [
("\"observed\"", SpecProvenance::Observed),
("\"inferred\"", SpecProvenance::Inferred),
("\"assumed\"", SpecProvenance::Assumed),
] {
let parsed: SpecProvenance = serde_json::from_str(wire).unwrap();
assert_eq!(parsed, variant);
assert_eq!(serde_json::to_string(&parsed).unwrap(), wire);
}
}
#[test]
fn assumption_overridable_defaults_true_when_absent() {
let json =
r#"{"ref":"operations.createInvoice.effect","defaultApplied":"REST 201 persist"}"#;
let parsed: AssumptionEntry = serde_json::from_str(json).unwrap();
assert!(parsed.overridable);
assert_eq!(parsed.r#ref, "operations.createInvoice.effect");
}
#[test]
fn entity_field_renames_type_keyword() {
let f = EntityField {
name: "amount".into(),
field_type: "money".into(),
values: vec![],
confidence: SpecProvenance::Observed,
provenance: Some("form#invoice input[name=amount]".into()),
credibility: None,
};
let v = serde_json::to_value(&f).unwrap();
assert_eq!(v["type"], "money");
assert!(
v.get("credibility").is_none(),
"absent credibility must not serialize"
);
}
#[test]
fn spec_reuses_ir_state_and_transition_types() {
let _ui: Vec<IrState> = Vec::new();
let _nav: Vec<IrTransition> = Vec::new();
let spec = FunctionalSpec {
spec_version: "0".into(),
target: SpecTarget {
source_url: "https://example.test".into(),
observed_at: None,
},
entities: vec![],
operations: vec![],
ui_states: _ui,
navigation: _nav,
auth: None,
assumptions: vec![],
};
let round: FunctionalSpec =
serde_json::from_str(&serde_json::to_string(&spec).unwrap()).unwrap();
assert_eq!(round.spec_version, "0");
}
}