1use serde::Deserialize;
35use serde_json::Value;
36
37use crate::spec::Expr;
38
39pub const FORMAT_VERSION: u32 = 1;
42
43#[derive(Debug, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct Envelope {
49 pub format: u32,
51 pub name: String,
53 #[serde(default)]
54 pub description: Option<String>,
55 #[serde(default)]
56 pub author: Option<String>,
57 #[serde(default)]
59 pub spec: Option<Value>,
60 #[serde(default)]
62 pub source: Option<String>,
63 #[serde(default)]
66 pub config: Option<Value>,
67 #[serde(default)]
68 pub universe: Option<Universe>,
69 #[serde(default)]
71 pub engine_version: Option<String>,
72}
73
74#[derive(Debug, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct Universe {
79 #[serde(default)]
81 pub from: Option<i64>,
82 #[serde(default)]
84 pub to: Option<i64>,
85 #[serde(default)]
87 pub symbols_hint: Option<String>,
88 #[serde(default)]
90 pub symbols: Option<Vec<String>>,
91}
92
93#[derive(Debug)]
96pub struct Checked {
97 pub name: String,
98 pub spec: Value,
100}
101
102pub fn check(doc: &str) -> Result<Checked, Vec<String>> {
111 let value: Value = serde_json::from_str(doc).map_err(|e| vec![format!("invalid JSON: {e}")])?;
112 check_value(&value)
113}
114
115pub fn check_value(value: &Value) -> Result<Checked, Vec<String>> {
117 let env: Envelope = serde_json::from_value(value.clone())
118 .map_err(|e| vec![format!("not a valid strategy envelope: {e}")])?;
119
120 let mut errors: Vec<String> = Vec::new();
121
122 if env.format != FORMAT_VERSION {
123 errors.push(format!(
124 "unsupported envelope format {} (this build understands format {FORMAT_VERSION})",
125 env.format
126 ));
127 }
128 if env.name.trim().is_empty() {
129 errors.push("`name` must not be empty".into());
130 }
131 if let Some(config) = &env.config {
132 if !config.is_object() {
133 errors.push("`config` must be a JSON object".into());
134 }
135 }
136
137 let resolved = match (&env.spec, &env.source) {
138 (Some(_), Some(_)) => {
139 errors.push("provide exactly one of `spec` or `source`, not both".into());
140 None
141 }
142 (None, None) => {
143 errors.push("provide one of `spec` (an Expr tree) or `source` (lemon text)".into());
144 None
145 }
146 (Some(spec), None) => match serde_json::from_value::<Expr>(spec.clone()) {
147 Ok(_) => Some(spec.clone()),
148 Err(e) => {
149 errors.push(format!("`spec` is not a valid strategy tree: {e}"));
150 None
151 }
152 },
153 (None, Some(src)) => match crate::parse(src) {
154 Ok(tree) => Some(tree),
155 Err(e) => {
156 errors.push(format!(
157 "`source` failed to parse: {}:{}: {}",
158 e.line, e.col, e.message
159 ));
160 None
161 }
162 },
163 };
164
165 match (errors.is_empty(), resolved) {
166 (true, Some(spec)) => Ok(Checked {
167 name: env.name,
168 spec,
169 }),
170 _ => Err(errors),
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn checks_a_source_envelope_and_resolves_the_spec() {
180 let doc = r#"{
181 "format": 1,
182 "name": "Momentum",
183 "description": "buy strength",
184 "source": "close > sma(close, 2)",
185 "config": { "fee_ratio": 0.001 },
186 "universe": { "from": 20180101, "to": 20251231, "symbols_hint": "sp500" },
187 "engine_version": "yuzu-core 0.x"
188 }"#;
189 let checked = check(doc).expect("valid envelope");
190 assert_eq!(checked.name, "Momentum");
191 assert_eq!(checked.spec, crate::parse("close > sma(close, 2)").unwrap());
193 assert_eq!(checked.spec["op"], "Gt");
194 }
195
196 #[test]
197 fn checks_a_spec_envelope() {
198 let spec = crate::parse("rsi(close, 14)").unwrap();
199 let doc = serde_json::json!({
200 "format": 1,
201 "name": "RSI",
202 "spec": spec,
203 })
204 .to_string();
205 let checked = check(&doc).expect("valid spec envelope");
206 assert_eq!(checked.spec["op"], "Rsi");
207 }
208
209 #[test]
210 fn rejects_wrong_format_version() {
211 let doc = r#"{ "format": 99, "name": "X", "source": "close" }"#;
212 let errs = check(doc).unwrap_err();
213 assert!(
214 errs.iter()
215 .any(|e| e.contains("unsupported envelope format 99")),
216 "{errs:?}"
217 );
218 }
219
220 #[test]
221 fn rejects_empty_name() {
222 let doc = r#"{ "format": 1, "name": " ", "source": "close" }"#;
223 let errs = check(doc).unwrap_err();
224 assert!(
225 errs.iter().any(|e| e.contains("`name` must not be empty")),
226 "{errs:?}"
227 );
228 }
229
230 #[test]
231 fn rejects_both_spec_and_source() {
232 let doc = r#"{ "format": 1, "name": "X", "source": "close", "spec": {"op":"Data","name":"close"} }"#;
233 let errs = check(doc).unwrap_err();
234 assert!(
235 errs.iter()
236 .any(|e| e.contains("exactly one of `spec` or `source`")),
237 "{errs:?}"
238 );
239 }
240
241 #[test]
242 fn rejects_neither_spec_nor_source() {
243 let doc = r#"{ "format": 1, "name": "X" }"#;
244 let errs = check(doc).unwrap_err();
245 assert!(
246 errs.iter().any(|e| e.contains("provide one of `spec`")),
247 "{errs:?}"
248 );
249 }
250
251 #[test]
252 fn rejects_a_malformed_spec_tree() {
253 let doc = r#"{ "format": 1, "name": "X",
255 "spec": { "op": "Average", "of": { "op": "Data", "name": "close" } } }"#;
256 let errs = check(doc).unwrap_err();
257 assert!(
258 errs.iter().any(|e| e.contains("not a valid strategy tree")),
259 "{errs:?}"
260 );
261 }
262
263 #[test]
264 fn rejects_an_unknown_op_tag() {
265 let doc = r#"{ "format": 1, "name": "X",
266 "spec": { "op": "Nonesuch", "of": { "op": "Data", "name": "close" } } }"#;
267 let errs = check(doc).unwrap_err();
268 assert!(
269 errs.iter().any(|e| e.contains("not a valid strategy tree")),
270 "{errs:?}"
271 );
272 }
273
274 #[test]
275 fn rejects_a_source_syntax_error() {
276 let doc = r#"{ "format": 1, "name": "X", "source": "sma(close," }"#;
277 let errs = check(doc).unwrap_err();
278 assert!(
279 errs.iter().any(|e| e.contains("`source` failed to parse")),
280 "{errs:?}"
281 );
282 }
283
284 #[test]
285 fn rejects_unknown_envelope_keys() {
286 let doc = r#"{ "format": 1, "name": "X", "source": "close", "descroption": "typo" }"#;
287 let errs = check(doc).unwrap_err();
288 assert!(
289 errs.iter()
290 .any(|e| e.contains("not a valid strategy envelope")),
291 "{errs:?}"
292 );
293 }
294
295 #[test]
296 fn rejects_non_object_config() {
297 let doc = r#"{ "format": 1, "name": "X", "source": "close", "config": 3 }"#;
298 let errs = check(doc).unwrap_err();
299 assert!(
300 errs.iter()
301 .any(|e| e.contains("`config` must be a JSON object")),
302 "{errs:?}"
303 );
304 }
305
306 #[test]
307 fn checked_in_schema_tracks_the_format_version() {
308 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
312 .parent()
313 .and_then(|p| p.parent())
314 .expect("workspace root")
315 .join("schema/strategy-envelope.schema.json");
316 let body = std::fs::read_to_string(&path).expect("read envelope schema");
317 let schema: Value = serde_json::from_str(&body).expect("schema is valid JSON");
318 assert_eq!(
319 schema["properties"]["format"]["const"].as_u64(),
320 Some(FORMAT_VERSION as u64),
321 "schema/strategy-envelope.schema.json format const != FORMAT_VERSION",
322 );
323 }
324}