1use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
33#[serde(rename_all = "kebab-case", deny_unknown_fields)]
34pub struct RhaiLimitsConfig {
35 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub max_operations: Option<u64>,
39
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub max_string_size: Option<usize>,
43
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub max_array_size: Option<usize>,
47
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub max_map_size: Option<usize>,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub max_expression_depth: Option<u32>,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub max_function_expression_depth: Option<u32>,
60
61 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub execution_timeout_ms: Option<u64>,
66}
67
68#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
84#[serde(rename_all = "kebab-case", deny_unknown_fields)]
85pub struct JsLimitsConfig {
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub execution_timeout_ms: Option<u64>,
89
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub max_loop_iterations: Option<u64>,
93
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub max_recursion_depth: Option<usize>,
97
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub max_stack_size: Option<usize>,
101}
102
103#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
121#[serde(rename_all = "kebab-case", deny_unknown_fields)]
122pub struct MinijinjaLimitsConfig {
123 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub max_template_source_size: Option<usize>,
127
128 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub max_context_size: Option<usize>,
132
133 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub max_output_size: Option<usize>,
137
138 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub fuel: Option<u64>,
142
143 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub max_recursion_depth: Option<u32>,
147
148 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub execution_timeout_ms: Option<u64>,
152}
153
154#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
160#[serde(rename_all = "kebab-case", deny_unknown_fields)]
161pub struct RhaiEngineConfig {
162 #[serde(default)]
164 pub limits: RhaiLimitsConfig,
165}
166
167#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
169#[serde(rename_all = "kebab-case", deny_unknown_fields)]
170pub struct JsEngineConfig {
171 #[serde(default)]
173 pub limits: JsLimitsConfig,
174}
175
176#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
178#[serde(rename_all = "kebab-case", deny_unknown_fields)]
179pub struct MinijinjaEngineConfig {
180 #[serde(default)]
182 pub limits: MinijinjaLimitsConfig,
183}
184
185#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
195#[serde(rename_all = "kebab-case", deny_unknown_fields)]
196pub struct LanguagesConfig {
197 #[serde(default)]
199 pub rhai: RhaiEngineConfig,
200
201 #[serde(default)]
203 pub js: JsEngineConfig,
204
205 #[serde(default)]
207 pub minijinja: MinijinjaEngineConfig,
208}
209
210#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
221 fn rhai_defaults_to_all_none() {
222 let cfg = RhaiLimitsConfig::default();
223 assert_eq!(cfg.max_operations, None);
224 assert_eq!(cfg.max_string_size, None);
225 assert_eq!(cfg.max_array_size, None);
226 assert_eq!(cfg.max_map_size, None);
227 assert_eq!(cfg.max_expression_depth, None);
228 assert_eq!(cfg.max_function_expression_depth, None);
229 assert_eq!(cfg.execution_timeout_ms, None);
230 }
231
232 #[test]
233 fn rhai_deserialises_full_block() {
234 let toml = toml::toml! {
235 max-operations = 500000i64
236 max-string-size = 10485760i64
237 max-array-size = 100000i64
238 max-map-size = 100000i64
239 max-expression-depth = 10
240 max-function-expression-depth = 5
241 execution-timeout-ms = 5000i64
242 };
243 let cfg: RhaiLimitsConfig = toml.try_into().expect("deserialize");
244 assert_eq!(cfg.max_operations, Some(500_000));
245 assert_eq!(cfg.max_string_size, Some(10_485_760));
246 assert_eq!(cfg.max_array_size, Some(100_000));
247 assert_eq!(cfg.max_map_size, Some(100_000));
248 assert_eq!(cfg.max_expression_depth, Some(10));
249 assert_eq!(cfg.max_function_expression_depth, Some(5));
250 assert_eq!(cfg.execution_timeout_ms, Some(5000));
251 }
252
253 #[test]
254 fn rhai_deserialises_partial_block() {
255 let toml = toml::toml! {
256 max-operations = 100000i64
257 execution-timeout-ms = 3000i64
258 };
259 let cfg: RhaiLimitsConfig = toml.try_into().expect("deserialize");
260 assert_eq!(cfg.max_operations, Some(100_000));
261 assert_eq!(cfg.execution_timeout_ms, Some(3000));
262 assert_eq!(cfg.max_string_size, None);
264 assert_eq!(cfg.max_expression_depth, None);
265 }
266
267 #[test]
268 fn rhai_rejects_unknown_field() {
269 let toml = toml::toml! {
270 max-operations = 100000i64
271 fuel = 1000i64
272 };
273 let result: Result<RhaiLimitsConfig, _> = toml.try_into();
274 assert!(result.is_err(), "deny_unknown_fields must reject `fuel`");
275 }
276
277 #[test]
278 fn rhai_serde_round_trip_preserves_set_fields() {
279 let original = RhaiLimitsConfig {
280 max_operations: Some(200_000),
281 max_string_size: Some(5_242_880),
282 execution_timeout_ms: Some(10_000),
283 ..Default::default()
284 };
285 let serialized = toml::to_string(&original).expect("serialize");
286 let back: RhaiLimitsConfig = toml::from_str(&serialized).expect("deserialize");
287 assert_eq!(original, back);
288 }
289
290 #[test]
291 fn rhai_skip_serializing_none_fields() {
292 let cfg = RhaiLimitsConfig {
293 max_operations: Some(100_000),
294 max_string_size: None,
295 execution_timeout_ms: Some(5000),
296 ..Default::default()
297 };
298 let s = toml::to_string(&cfg).expect("serialize");
299 assert!(s.contains("max-operations"));
300 assert!(s.contains("execution-timeout-ms"));
301 assert!(!s.contains("max-string-size"));
302 assert!(!s.contains("max-expression-depth"));
303 }
304
305 #[test]
308 fn js_defaults_to_all_none() {
309 let cfg = JsLimitsConfig::default();
310 assert_eq!(cfg.execution_timeout_ms, None);
311 assert_eq!(cfg.max_loop_iterations, None);
312 assert_eq!(cfg.max_recursion_depth, None);
313 assert_eq!(cfg.max_stack_size, None);
314 }
315
316 #[test]
317 fn js_deserialises_full_block() {
318 let toml = toml::toml! {
319 execution-timeout-ms = 5000i64
320 max-loop-iterations = 1000000i64
321 max-recursion-depth = 64i64
322 max-stack-size = 1048576i64
323 };
324 let cfg: JsLimitsConfig = toml.try_into().expect("deserialize");
325 assert_eq!(cfg.execution_timeout_ms, Some(5000));
326 assert_eq!(cfg.max_loop_iterations, Some(1_000_000));
327 assert_eq!(cfg.max_recursion_depth, Some(64));
328 assert_eq!(cfg.max_stack_size, Some(1_048_576));
329 }
330
331 #[test]
332 fn js_deserialises_partial_block() {
333 let toml = toml::toml! {
334 execution-timeout-ms = 3000i64
335 max-recursion-depth = 32i64
336 };
337 let cfg: JsLimitsConfig = toml.try_into().expect("deserialize");
338 assert_eq!(cfg.execution_timeout_ms, Some(3000));
339 assert_eq!(cfg.max_recursion_depth, Some(32));
340 assert_eq!(cfg.max_loop_iterations, None);
341 assert_eq!(cfg.max_stack_size, None);
342 }
343
344 #[test]
345 fn js_rejects_unknown_field() {
346 let toml = toml::toml! {
347 execution-timeout-ms = 5000i64
348 fuel = 1000i64
349 };
350 let result: Result<JsLimitsConfig, _> = toml.try_into();
351 assert!(result.is_err(), "deny_unknown_fields must reject `fuel`");
352 }
353
354 #[test]
355 fn js_serde_round_trip_preserves_set_fields() {
356 let original = JsLimitsConfig {
357 execution_timeout_ms: Some(10_000),
358 max_loop_iterations: Some(500_000),
359 ..Default::default()
360 };
361 let serialized = toml::to_string(&original).expect("serialize");
362 let back: JsLimitsConfig = toml::from_str(&serialized).expect("deserialize");
363 assert_eq!(original, back);
364 }
365
366 #[test]
367 fn js_skip_serializing_none_fields() {
368 let cfg = JsLimitsConfig {
369 execution_timeout_ms: Some(5000),
370 max_loop_iterations: Some(1_000_000),
371 ..Default::default()
372 };
373 let s = toml::to_string(&cfg).expect("serialize");
374 assert!(s.contains("execution-timeout-ms"));
375 assert!(s.contains("max-loop-iterations"));
376 assert!(!s.contains("max-recursion-depth"));
377 assert!(!s.contains("max-stack-size"));
378 }
379
380 #[test]
383 fn rhai_engine_config_defaults() {
384 let cfg = RhaiEngineConfig::default();
385 assert_eq!(cfg.limits, RhaiLimitsConfig::default());
386 }
387
388 #[test]
389 fn js_engine_config_defaults() {
390 let cfg = JsEngineConfig::default();
391 assert_eq!(cfg.limits, JsLimitsConfig::default());
392 }
393
394 #[test]
395 fn languages_config_defaults() {
396 let cfg = LanguagesConfig::default();
397 assert_eq!(cfg.rhai.limits, RhaiLimitsConfig::default());
398 assert_eq!(cfg.js.limits, JsLimitsConfig::default());
399 assert_eq!(cfg.minijinja.limits, MinijinjaLimitsConfig::default());
400 }
401
402 #[test]
403 fn languages_deserialises_both_engines() {
404 let toml_str = r#"
405 [rhai.limits]
406 max-operations = 500000
407 execution-timeout-ms = 5000
408
409 [js.limits]
410 execution-timeout-ms = 3000
411 max-loop-iterations = 1000000
412 "#;
413 let cfg: LanguagesConfig = toml::from_str(toml_str).expect("deserialize");
414 assert_eq!(cfg.rhai.limits.max_operations, Some(500_000));
415 assert_eq!(cfg.rhai.limits.execution_timeout_ms, Some(5000));
416 assert_eq!(cfg.js.limits.execution_timeout_ms, Some(3000));
417 assert_eq!(cfg.js.limits.max_loop_iterations, Some(1_000_000));
418 }
419
420 #[test]
421 fn languages_serde_round_trip() {
422 let original = LanguagesConfig {
423 rhai: RhaiEngineConfig {
424 limits: RhaiLimitsConfig {
425 max_operations: Some(100_000),
426 ..Default::default()
427 },
428 },
429 js: JsEngineConfig::default(),
430 minijinja: MinijinjaEngineConfig::default(),
431 };
432 let serialized = toml::to_string(&original).expect("serialize");
433 let back: LanguagesConfig = toml::from_str(&serialized).expect("deserialize");
434 assert_eq!(original, back);
435 }
436
437 #[test]
440 fn minijinja_defaults_to_all_none() {
441 let cfg = MinijinjaLimitsConfig::default();
442 assert_eq!(cfg.max_template_source_size, None);
443 assert_eq!(cfg.max_context_size, None);
444 assert_eq!(cfg.max_output_size, None);
445 assert_eq!(cfg.fuel, None);
446 assert_eq!(cfg.max_recursion_depth, None);
447 assert_eq!(cfg.execution_timeout_ms, None);
448 }
449
450 #[test]
451 fn minijinja_deserialises_full_block() {
452 let toml = toml::toml! {
453 max-template-source-size = 1048576i64
454 max-context-size = 4194304i64
455 max-output-size = 4194304i64
456 fuel = 100000i64
457 max-recursion-depth = 64
458 execution-timeout-ms = 5000i64
459 };
460 let cfg: MinijinjaLimitsConfig = toml.try_into().expect("deserialize");
461 assert_eq!(cfg.max_template_source_size, Some(1_048_576));
462 assert_eq!(cfg.max_context_size, Some(4_194_304));
463 assert_eq!(cfg.max_output_size, Some(4_194_304));
464 assert_eq!(cfg.fuel, Some(100_000));
465 assert_eq!(cfg.max_recursion_depth, Some(64));
466 assert_eq!(cfg.execution_timeout_ms, Some(5_000));
467 }
468
469 #[test]
470 fn minijinja_rejects_unknown_field() {
471 let toml = toml::toml! {
472 fuel = 1000i64
473 bogus = 1i64
474 };
475 let result: Result<MinijinjaLimitsConfig, _> = toml.try_into();
476 assert!(result.is_err(), "deny_unknown_fields must reject `bogus`");
477 }
478
479 #[test]
480 fn minijinja_skip_serializing_none_fields() {
481 let cfg = MinijinjaLimitsConfig {
482 fuel: Some(100_000),
483 max_recursion_depth: Some(64),
484 ..Default::default()
485 };
486 let s = toml::to_string(&cfg).expect("serialize");
487 assert!(s.contains("fuel") && s.contains("max-recursion-depth"));
488 assert!(!s.contains("max-template-source-size") && !s.contains("max-context-size"));
489 }
490}