use crate::error::{ForgeError, ForgeResult};
use serde_yaml_ng::Value;
pub fn validate_against_schema(yaml: &Value) -> ForgeResult<()> {
let version = yaml
.get("_forge_version")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ForgeError::Validation(
"Missing required field: _forge_version. Must be \"1.0.0\" or \"5.0.0\""
.to_string(),
)
})?;
let schema_str = match version {
"1.0.0" => include_str!("../../schema/forge-v1.0.0.schema.json"),
"5.0.0" => include_str!("../../schema/forge-v5.0.0.schema.json"),
_ => {
return Err(ForgeError::Validation(format!(
"Unsupported _forge_version: '{version}'. Supported versions: 1.0.0 (scalar-only), 5.0.0 (arrays/tables)"
)));
},
};
let schema_value: serde_json::Value = serde_json::from_str(schema_str)
.map_err(|e| ForgeError::Validation(format!("Failed to parse schema: {e}")))?;
let json_value: serde_json::Value = serde_json::to_value(yaml)
.map_err(|e| ForgeError::Validation(format!("Failed to convert YAML to JSON: {e}")))?;
let validator = jsonschema::validator_for(&schema_value)
.map_err(|e| ForgeError::Validation(format!("Failed to compile schema: {e}")))?;
if let Err(_error) = validator.validate(&json_value) {
let error_messages: Vec<String> = validator
.iter_errors(&json_value)
.map(|e| format!(" - {e}"))
.collect();
return Err(ForgeError::Validation(format!(
"Schema validation failed:\n{}",
error_messages.join("\n")
)));
}
if version == "1.0.0" {
validate_v1_0_0_no_tables(yaml)?;
}
Ok(())
}
pub fn validate_v1_0_0_no_tables(yaml: &Value) -> ForgeResult<()> {
if let Value::Mapping(map) = yaml {
for (key, value) in map {
let key_str = key.as_str().unwrap_or("");
if key_str == "_forge_version" || key_str == "_name" || key_str == "scenarios" {
continue;
}
if key_str == "monte_carlo" {
return Err(ForgeError::Validation(
"monte_carlo requires v5.0.0+. \
Upgrade to _forge_version: \"5.0.0\" to use Monte Carlo simulation."
.to_string(),
));
}
if let Value::Mapping(inner_map) = value {
if inner_map.contains_key("value") || inner_map.contains_key("formula") {
continue;
}
for (col_key, col_value) in inner_map {
let col_key_str = col_key.as_str().unwrap_or("");
if matches!(col_value, Value::Sequence(_)) {
return Err(ForgeError::Validation(format!(
"v1.0.0 models do not support tables/arrays. Found table '{key_str}' with array column '{col_key_str}'.\n\
\n\
v1.0.0 only supports scalar values.\n\
To use tables/arrays, upgrade to v5.0.0:\n\
\n\
_forge_version: \"5.0.0\"\n\
\n\
Or convert your table to scalars using dot notation:\n\
{key_str}.{col_key_str}: {{ value: ..., formula: null }}\n\
{key_str}.{col_key_str}: {{ value: ..., formula: null }}"
)));
}
if let Value::Mapping(col_map) = col_value {
if let Some(Value::Sequence(_)) = col_map.get("value") {
return Err(ForgeError::Validation(format!(
"v1.0.0 models do not support tables/arrays. Found table '{key_str}' with array column '{col_key_str}' (rich format).\n\
\n\
v1.0.0 only supports scalar values.\n\
To use tables/arrays, upgrade to v5.0.0:\n\
\n\
_forge_version: \"5.0.0\""
)));
}
}
if let Value::String(s) = col_value {
if s.starts_with('=') {
return Err(ForgeError::Validation(format!(
"v1.0.0 models do not support tables/arrays. Found table '{key_str}' with formula column '{col_key_str}'.\n\
\n\
v1.0.0 only supports scalar values.\n\
To use tables/arrays, upgrade to v5.0.0:\n\
\n\
_forge_version: \"5.0.0\""
)));
}
}
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_schema_validates_tornado_section() {
let mut file = NamedTempFile::new().unwrap();
writeln!(
file,
r#"_forge_version: "5.0.0"
price:
value: 100
tornado:
output: price
inputs:
- name: price
low: 80
high: 120
"#
)
.unwrap();
let content = std::fs::read_to_string(file.path()).unwrap();
let yaml: Value = serde_yaml_ng::from_str(&content).unwrap();
let result = validate_against_schema(&yaml);
assert!(
result.is_ok(),
"Schema should accept valid tornado section: {:?}",
result.err()
);
}
#[test]
fn test_schema_validates_decision_tree_section() {
let mut file = NamedTempFile::new().unwrap();
writeln!(
file,
r#"_forge_version: "5.0.0"
decision_tree:
name: "Simple Decision"
root:
type: decision
branches:
option_a:
value: 100
option_b:
value: 200
"#
)
.unwrap();
let content = std::fs::read_to_string(file.path()).unwrap();
let yaml: Value = serde_yaml_ng::from_str(&content).unwrap();
let result = validate_against_schema(&yaml);
assert!(
result.is_ok(),
"Schema should accept valid decision_tree section: {:?}",
result.err()
);
}
#[test]
fn test_v1_rejects_tables() {
let yaml_str = r#"
_forge_version: "1.0.0"
data:
values: [1, 2, 3]
"#;
let yaml: Value = serde_yaml_ng::from_str(yaml_str).unwrap();
let result = validate_v1_0_0_no_tables(&yaml);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("v1.0.0"));
}
#[test]
fn test_v1_allows_scalars() {
let yaml_str = r#"
_forge_version: "1.0.0"
price:
value: 100
formula: null
"#;
let yaml: Value = serde_yaml_ng::from_str(yaml_str).unwrap();
let result = validate_v1_0_0_no_tables(&yaml);
assert!(result.is_ok());
}
#[test]
fn test_v1_rejects_monte_carlo() {
let yaml_str = r#"
_forge_version: "1.0.0"
monte_carlo:
iterations: 1000
"#;
let yaml: Value = serde_yaml_ng::from_str(yaml_str).unwrap();
let result = validate_v1_0_0_no_tables(&yaml);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("monte_carlo"));
}
#[test]
fn test_missing_forge_version() {
let yaml_str = r"
price:
value: 100
";
let yaml: Value = serde_yaml_ng::from_str(yaml_str).unwrap();
let result = validate_against_schema(&yaml);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("_forge_version"));
}
#[test]
fn test_unsupported_version() {
let yaml_str = r#"
_forge_version: "99.0.0"
price:
value: 100
"#;
let yaml: Value = serde_yaml_ng::from_str(yaml_str).unwrap();
let result = validate_against_schema(&yaml);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Unsupported"));
}
}