1use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
34#[serde(rename_all = "kebab-case", deny_unknown_fields)]
35pub struct RhaiLimitsConfig {
36 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub max_operations: Option<u64>,
40
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub max_string_size: Option<usize>,
44
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub max_array_size: Option<usize>,
48
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub max_map_size: Option<usize>,
52
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub max_expression_depth: Option<u32>,
56
57 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub max_function_expression_depth: Option<u32>,
61
62 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub max_call_levels: Option<u32>,
67
68 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub execution_timeout_ms: Option<u64>,
73}
74
75#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
91#[serde(rename_all = "kebab-case", deny_unknown_fields)]
92pub struct JsLimitsConfig {
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub execution_timeout_ms: Option<u64>,
96
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub max_loop_iterations: Option<u64>,
100
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub max_recursion_depth: Option<usize>,
104
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub max_stack_size: Option<usize>,
108}
109
110#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
128#[serde(rename_all = "kebab-case", deny_unknown_fields)]
129pub struct MinijinjaLimitsConfig {
130 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub max_template_source_size: Option<usize>,
134
135 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub max_context_size: Option<usize>,
139
140 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub max_output_size: Option<usize>,
144
145 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub fuel: Option<u64>,
149
150 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub max_recursion_depth: Option<u32>,
154
155 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub execution_timeout_ms: Option<u64>,
159}
160
161#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
167#[serde(rename_all = "kebab-case", deny_unknown_fields)]
168pub struct RhaiEngineConfig {
169 #[serde(default)]
171 pub limits: RhaiLimitsConfig,
172}
173
174#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
176#[serde(rename_all = "kebab-case", deny_unknown_fields)]
177pub struct JsEngineConfig {
178 #[serde(default)]
180 pub limits: JsLimitsConfig,
181}
182
183#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
185#[serde(rename_all = "kebab-case", deny_unknown_fields)]
186pub struct MinijinjaEngineConfig {
187 #[serde(default)]
189 pub limits: MinijinjaLimitsConfig,
190}
191
192#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
202#[serde(rename_all = "kebab-case", deny_unknown_fields)]
203pub struct LanguagesConfig {
204 #[serde(default)]
206 pub rhai: RhaiEngineConfig,
207
208 #[serde(default)]
210 pub js: JsEngineConfig,
211
212 #[serde(default)]
214 pub minijinja: MinijinjaEngineConfig,
215}
216
217#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
228 fn rhai_defaults_to_all_none() {
229 let cfg = RhaiLimitsConfig::default();
230 assert_eq!(cfg.max_operations, None);
231 assert_eq!(cfg.max_string_size, None);
232 assert_eq!(cfg.max_array_size, None);
233 assert_eq!(cfg.max_map_size, None);
234 assert_eq!(cfg.max_expression_depth, None);
235 assert_eq!(cfg.max_function_expression_depth, None);
236 assert_eq!(cfg.execution_timeout_ms, None);
237 }
238
239 #[test]
240 fn rhai_deserialises_full_block() {
241 let toml = toml::toml! {
242 max-operations = 500000i64
243 max-string-size = 10485760i64
244 max-array-size = 100000i64
245 max-map-size = 100000i64
246 max-expression-depth = 10
247 max-function-expression-depth = 5
248 execution-timeout-ms = 5000i64
249 };
250 let cfg: RhaiLimitsConfig = toml.try_into().expect("deserialize");
251 assert_eq!(cfg.max_operations, Some(500_000));
252 assert_eq!(cfg.max_string_size, Some(10_485_760));
253 assert_eq!(cfg.max_array_size, Some(100_000));
254 assert_eq!(cfg.max_map_size, Some(100_000));
255 assert_eq!(cfg.max_expression_depth, Some(10));
256 assert_eq!(cfg.max_function_expression_depth, Some(5));
257 assert_eq!(cfg.execution_timeout_ms, Some(5000));
258 }
259
260 #[test]
261 fn rhai_deserialises_partial_block() {
262 let toml = toml::toml! {
263 max-operations = 100000i64
264 execution-timeout-ms = 3000i64
265 };
266 let cfg: RhaiLimitsConfig = toml.try_into().expect("deserialize");
267 assert_eq!(cfg.max_operations, Some(100_000));
268 assert_eq!(cfg.execution_timeout_ms, Some(3000));
269 assert_eq!(cfg.max_string_size, None);
271 assert_eq!(cfg.max_expression_depth, None);
272 }
273
274 #[test]
275 fn rhai_rejects_unknown_field() {
276 let toml = toml::toml! {
277 max-operations = 100000i64
278 fuel = 1000i64
279 };
280 let result: Result<RhaiLimitsConfig, _> = toml.try_into();
281 assert!(result.is_err(), "deny_unknown_fields must reject `fuel`");
282 }
283
284 #[test]
285 fn rhai_serde_round_trip_preserves_set_fields() {
286 let original = RhaiLimitsConfig {
287 max_operations: Some(200_000),
288 max_string_size: Some(5_242_880),
289 execution_timeout_ms: Some(10_000),
290 ..Default::default()
291 };
292 let serialized = toml::to_string(&original).expect("serialize");
293 let back: RhaiLimitsConfig = toml::from_str(&serialized).expect("deserialize");
294 assert_eq!(original, back);
295 }
296
297 #[test]
298 fn rhai_skip_serializing_none_fields() {
299 let cfg = RhaiLimitsConfig {
300 max_operations: Some(100_000),
301 max_string_size: None,
302 execution_timeout_ms: Some(5000),
303 ..Default::default()
304 };
305 let s = toml::to_string(&cfg).expect("serialize");
306 assert!(s.contains("max-operations"));
307 assert!(s.contains("execution-timeout-ms"));
308 assert!(!s.contains("max-string-size"));
309 assert!(!s.contains("max-expression-depth"));
310 }
311
312 #[test]
315 fn js_defaults_to_all_none() {
316 let cfg = JsLimitsConfig::default();
317 assert_eq!(cfg.execution_timeout_ms, None);
318 assert_eq!(cfg.max_loop_iterations, None);
319 assert_eq!(cfg.max_recursion_depth, None);
320 assert_eq!(cfg.max_stack_size, None);
321 }
322
323 #[test]
324 fn js_deserialises_full_block() {
325 let toml = toml::toml! {
326 execution-timeout-ms = 5000i64
327 max-loop-iterations = 1000000i64
328 max-recursion-depth = 64i64
329 max-stack-size = 1048576i64
330 };
331 let cfg: JsLimitsConfig = toml.try_into().expect("deserialize");
332 assert_eq!(cfg.execution_timeout_ms, Some(5000));
333 assert_eq!(cfg.max_loop_iterations, Some(1_000_000));
334 assert_eq!(cfg.max_recursion_depth, Some(64));
335 assert_eq!(cfg.max_stack_size, Some(1_048_576));
336 }
337
338 #[test]
339 fn js_deserialises_partial_block() {
340 let toml = toml::toml! {
341 execution-timeout-ms = 3000i64
342 max-recursion-depth = 32i64
343 };
344 let cfg: JsLimitsConfig = toml.try_into().expect("deserialize");
345 assert_eq!(cfg.execution_timeout_ms, Some(3000));
346 assert_eq!(cfg.max_recursion_depth, Some(32));
347 assert_eq!(cfg.max_loop_iterations, None);
348 assert_eq!(cfg.max_stack_size, None);
349 }
350
351 #[test]
352 fn js_rejects_unknown_field() {
353 let toml = toml::toml! {
354 execution-timeout-ms = 5000i64
355 fuel = 1000i64
356 };
357 let result: Result<JsLimitsConfig, _> = toml.try_into();
358 assert!(result.is_err(), "deny_unknown_fields must reject `fuel`");
359 }
360
361 #[test]
362 fn js_serde_round_trip_preserves_set_fields() {
363 let original = JsLimitsConfig {
364 execution_timeout_ms: Some(10_000),
365 max_loop_iterations: Some(500_000),
366 ..Default::default()
367 };
368 let serialized = toml::to_string(&original).expect("serialize");
369 let back: JsLimitsConfig = toml::from_str(&serialized).expect("deserialize");
370 assert_eq!(original, back);
371 }
372
373 #[test]
374 fn js_skip_serializing_none_fields() {
375 let cfg = JsLimitsConfig {
376 execution_timeout_ms: Some(5000),
377 max_loop_iterations: Some(1_000_000),
378 ..Default::default()
379 };
380 let s = toml::to_string(&cfg).expect("serialize");
381 assert!(s.contains("execution-timeout-ms"));
382 assert!(s.contains("max-loop-iterations"));
383 assert!(!s.contains("max-recursion-depth"));
384 assert!(!s.contains("max-stack-size"));
385 }
386
387 #[test]
390 fn rhai_engine_config_defaults() {
391 let cfg = RhaiEngineConfig::default();
392 assert_eq!(cfg.limits, RhaiLimitsConfig::default());
393 }
394
395 #[test]
396 fn js_engine_config_defaults() {
397 let cfg = JsEngineConfig::default();
398 assert_eq!(cfg.limits, JsLimitsConfig::default());
399 }
400
401 #[test]
402 fn languages_config_defaults() {
403 let cfg = LanguagesConfig::default();
404 assert_eq!(cfg.rhai.limits, RhaiLimitsConfig::default());
405 assert_eq!(cfg.js.limits, JsLimitsConfig::default());
406 assert_eq!(cfg.minijinja.limits, MinijinjaLimitsConfig::default());
407 }
408
409 #[test]
410 fn languages_deserialises_both_engines() {
411 let toml_str = r#"
412 [rhai.limits]
413 max-operations = 500000
414 execution-timeout-ms = 5000
415
416 [js.limits]
417 execution-timeout-ms = 3000
418 max-loop-iterations = 1000000
419 "#;
420 let cfg: LanguagesConfig = toml::from_str(toml_str).expect("deserialize");
421 assert_eq!(cfg.rhai.limits.max_operations, Some(500_000));
422 assert_eq!(cfg.rhai.limits.execution_timeout_ms, Some(5000));
423 assert_eq!(cfg.js.limits.execution_timeout_ms, Some(3000));
424 assert_eq!(cfg.js.limits.max_loop_iterations, Some(1_000_000));
425 }
426
427 #[test]
428 fn languages_serde_round_trip() {
429 let original = LanguagesConfig {
430 rhai: RhaiEngineConfig {
431 limits: RhaiLimitsConfig {
432 max_operations: Some(100_000),
433 ..Default::default()
434 },
435 },
436 js: JsEngineConfig::default(),
437 minijinja: MinijinjaEngineConfig::default(),
438 };
439 let serialized = toml::to_string(&original).expect("serialize");
440 let back: LanguagesConfig = toml::from_str(&serialized).expect("deserialize");
441 assert_eq!(original, back);
442 }
443
444 #[test]
447 fn minijinja_defaults_to_all_none() {
448 let cfg = MinijinjaLimitsConfig::default();
449 assert_eq!(cfg.max_template_source_size, None);
450 assert_eq!(cfg.max_context_size, None);
451 assert_eq!(cfg.max_output_size, None);
452 assert_eq!(cfg.fuel, None);
453 assert_eq!(cfg.max_recursion_depth, None);
454 assert_eq!(cfg.execution_timeout_ms, None);
455 }
456
457 #[test]
458 fn minijinja_deserialises_full_block() {
459 let toml = toml::toml! {
460 max-template-source-size = 1048576i64
461 max-context-size = 4194304i64
462 max-output-size = 4194304i64
463 fuel = 100000i64
464 max-recursion-depth = 64
465 execution-timeout-ms = 5000i64
466 };
467 let cfg: MinijinjaLimitsConfig = toml.try_into().expect("deserialize");
468 assert_eq!(cfg.max_template_source_size, Some(1_048_576));
469 assert_eq!(cfg.max_context_size, Some(4_194_304));
470 assert_eq!(cfg.max_output_size, Some(4_194_304));
471 assert_eq!(cfg.fuel, Some(100_000));
472 assert_eq!(cfg.max_recursion_depth, Some(64));
473 assert_eq!(cfg.execution_timeout_ms, Some(5_000));
474 }
475
476 #[test]
477 fn minijinja_rejects_unknown_field() {
478 let toml = toml::toml! {
479 fuel = 1000i64
480 bogus = 1i64
481 };
482 let result: Result<MinijinjaLimitsConfig, _> = toml.try_into();
483 assert!(result.is_err(), "deny_unknown_fields must reject `bogus`");
484 }
485
486 #[test]
487 fn minijinja_skip_serializing_none_fields() {
488 let cfg = MinijinjaLimitsConfig {
489 fuel: Some(100_000),
490 max_recursion_depth: Some(64),
491 ..Default::default()
492 };
493 let s = toml::to_string(&cfg).expect("serialize");
494 assert!(s.contains("fuel") && s.contains("max-recursion-depth"));
495 assert!(!s.contains("max-template-source-size") && !s.contains("max-context-size"));
496 }
497}