use serde::Deserialize;
use serde_json::Value;
use crate::spec::Expr;
pub const FORMAT_VERSION: u32 = 1;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Envelope {
pub format: u32,
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub spec: Option<Value>,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub config: Option<Value>,
#[serde(default)]
pub universe: Option<Universe>,
#[serde(default)]
pub engine_version: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Universe {
#[serde(default)]
pub from: Option<i64>,
#[serde(default)]
pub to: Option<i64>,
#[serde(default)]
pub symbols_hint: Option<String>,
#[serde(default)]
pub symbols: Option<Vec<String>>,
}
#[derive(Debug)]
pub struct Checked {
pub name: String,
pub spec: Value,
}
pub fn check(doc: &str) -> Result<Checked, Vec<String>> {
let value: Value = serde_json::from_str(doc).map_err(|e| vec![format!("invalid JSON: {e}")])?;
check_value(&value)
}
pub fn check_value(value: &Value) -> Result<Checked, Vec<String>> {
let env: Envelope = serde_json::from_value(value.clone())
.map_err(|e| vec![format!("not a valid strategy envelope: {e}")])?;
let mut errors: Vec<String> = Vec::new();
if env.format != FORMAT_VERSION {
errors.push(format!(
"unsupported envelope format {} (this build understands format {FORMAT_VERSION})",
env.format
));
}
if env.name.trim().is_empty() {
errors.push("`name` must not be empty".into());
}
if let Some(config) = &env.config {
if !config.is_object() {
errors.push("`config` must be a JSON object".into());
}
}
let resolved = match (&env.spec, &env.source) {
(Some(_), Some(_)) => {
errors.push("provide exactly one of `spec` or `source`, not both".into());
None
}
(None, None) => {
errors.push("provide one of `spec` (an Expr tree) or `source` (lemon text)".into());
None
}
(Some(spec), None) => match serde_json::from_value::<Expr>(spec.clone()) {
Ok(_) => Some(spec.clone()),
Err(e) => {
errors.push(format!("`spec` is not a valid strategy tree: {e}"));
None
}
},
(None, Some(src)) => match crate::parse(src) {
Ok(tree) => Some(tree),
Err(e) => {
errors.push(format!(
"`source` failed to parse: {}:{}: {}",
e.line, e.col, e.message
));
None
}
},
};
match (errors.is_empty(), resolved) {
(true, Some(spec)) => Ok(Checked {
name: env.name,
spec,
}),
_ => Err(errors),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checks_a_source_envelope_and_resolves_the_spec() {
let doc = r#"{
"format": 1,
"name": "Momentum",
"description": "buy strength",
"source": "close > sma(close, 2)",
"config": { "fee_ratio": 0.001 },
"universe": { "from": 20180101, "to": 20251231, "symbols_hint": "sp500" },
"engine_version": "yuzu-core 0.x"
}"#;
let checked = check(doc).expect("valid envelope");
assert_eq!(checked.name, "Momentum");
assert_eq!(checked.spec, crate::parse("close > sma(close, 2)").unwrap());
assert_eq!(checked.spec["op"], "Gt");
}
#[test]
fn checks_a_spec_envelope() {
let spec = crate::parse("rsi(close, 14)").unwrap();
let doc = serde_json::json!({
"format": 1,
"name": "RSI",
"spec": spec,
})
.to_string();
let checked = check(&doc).expect("valid spec envelope");
assert_eq!(checked.spec["op"], "Rsi");
}
#[test]
fn rejects_wrong_format_version() {
let doc = r#"{ "format": 99, "name": "X", "source": "close" }"#;
let errs = check(doc).unwrap_err();
assert!(
errs.iter()
.any(|e| e.contains("unsupported envelope format 99")),
"{errs:?}"
);
}
#[test]
fn rejects_empty_name() {
let doc = r#"{ "format": 1, "name": " ", "source": "close" }"#;
let errs = check(doc).unwrap_err();
assert!(
errs.iter().any(|e| e.contains("`name` must not be empty")),
"{errs:?}"
);
}
#[test]
fn rejects_both_spec_and_source() {
let doc = r#"{ "format": 1, "name": "X", "source": "close", "spec": {"op":"Data","name":"close"} }"#;
let errs = check(doc).unwrap_err();
assert!(
errs.iter()
.any(|e| e.contains("exactly one of `spec` or `source`")),
"{errs:?}"
);
}
#[test]
fn rejects_neither_spec_nor_source() {
let doc = r#"{ "format": 1, "name": "X" }"#;
let errs = check(doc).unwrap_err();
assert!(
errs.iter().any(|e| e.contains("provide one of `spec`")),
"{errs:?}"
);
}
#[test]
fn rejects_a_malformed_spec_tree() {
let doc = r#"{ "format": 1, "name": "X",
"spec": { "op": "Average", "of": { "op": "Data", "name": "close" } } }"#;
let errs = check(doc).unwrap_err();
assert!(
errs.iter().any(|e| e.contains("not a valid strategy tree")),
"{errs:?}"
);
}
#[test]
fn rejects_an_unknown_op_tag() {
let doc = r#"{ "format": 1, "name": "X",
"spec": { "op": "Nonesuch", "of": { "op": "Data", "name": "close" } } }"#;
let errs = check(doc).unwrap_err();
assert!(
errs.iter().any(|e| e.contains("not a valid strategy tree")),
"{errs:?}"
);
}
#[test]
fn rejects_a_source_syntax_error() {
let doc = r#"{ "format": 1, "name": "X", "source": "sma(close," }"#;
let errs = check(doc).unwrap_err();
assert!(
errs.iter().any(|e| e.contains("`source` failed to parse")),
"{errs:?}"
);
}
#[test]
fn rejects_unknown_envelope_keys() {
let doc = r#"{ "format": 1, "name": "X", "source": "close", "descroption": "typo" }"#;
let errs = check(doc).unwrap_err();
assert!(
errs.iter()
.any(|e| e.contains("not a valid strategy envelope")),
"{errs:?}"
);
}
#[test]
fn rejects_non_object_config() {
let doc = r#"{ "format": 1, "name": "X", "source": "close", "config": 3 }"#;
let errs = check(doc).unwrap_err();
assert!(
errs.iter()
.any(|e| e.contains("`config` must be a JSON object")),
"{errs:?}"
);
}
#[test]
fn checked_in_schema_tracks_the_format_version() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("workspace root")
.join("schema/strategy-envelope.schema.json");
let body = std::fs::read_to_string(&path).expect("read envelope schema");
let schema: Value = serde_json::from_str(&body).expect("schema is valid JSON");
assert_eq!(
schema["properties"]["format"]["const"].as_u64(),
Some(FORMAT_VERSION as u64),
"schema/strategy-envelope.schema.json format const != FORMAT_VERSION",
);
}
}