Skip to main content

camel_template/
config.rs

1//! Tunable resource limits for the external template component (ADR-0047
2//! Stage 2). Mirrors the `MinijinjaLimitsConfig` shape in
3//! `crates/languages/camel-language-api/src/language_limits.rs` — all fields
4//! are `Option` with `kebab-case` rename + `deny_unknown_fields` so a typo in
5//! `Camel.toml` is caught at deserialize time.
6
7use serde::{Deserialize, Serialize};
8
9use crate::error::TemplateReloadError;
10
11/// Operator-facing limits block. `None` means "use the rust-camel default".
12///
13/// Surfaced in `Camel.toml` as:
14///
15/// ```toml
16/// [components.template.limits]
17/// max-total-source-bytes = 16777216
18/// max-include-count = 64
19/// max-include-depth = 16
20/// max-template-size = 1048576
21/// reload-timeout-ms = 5000
22/// ```
23#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
24#[serde(rename_all = "kebab-case", deny_unknown_fields)]
25pub struct ExternalTemplateLimitsConfig {
26    /// Maximum total source bytes across the dependency closure of a single
27    /// template set. Defaults to 16 MiB when unset.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub max_total_source_bytes: Option<usize>,
30
31    /// Maximum number of included/imported templates per closure.
32    /// Defaults to 64 when unset.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub max_include_count: Option<u32>,
35
36    /// Maximum include/extends nesting depth. Defaults to 16 when unset.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub max_include_depth: Option<u32>,
39
40    /// Maximum size of a single template file in bytes. Defaults to 1 MiB when
41    /// unset.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub max_template_size: Option<usize>,
44
45    /// Wall-clock budget for a full reload build, in milliseconds. Defaults to
46    /// 5000 ms when unset.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub reload_timeout_ms: Option<u64>,
49}
50
51/// Resolved, finite, non-zero limits. Built once at startup from
52/// `ExternalTemplateLimitsConfig::resolve()`; the hot path reads this struct.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct ResolvedExternalTemplateLimits {
55    pub max_total_source_bytes: usize,
56    pub max_include_count: u32,
57    pub max_include_depth: u32,
58    pub max_template_size: usize,
59    pub reload_timeout_ms: u64,
60}
61
62impl ExternalTemplateLimitsConfig {
63    /// Fold operator-supplied values to finite, non-zero defaults. Any
64    /// explicitly-set `Some(0)` is rejected (zero would silently disable the
65    /// bound); `None` substitutes the documented default.
66    pub fn resolve(&self) -> Result<ResolvedExternalTemplateLimits, TemplateReloadError> {
67        if let Some(0) = self.max_total_source_bytes {
68            return Err(TemplateReloadError::BoundExceeded(
69                "zero value not permitted",
70            ));
71        }
72        if let Some(0) = self.max_include_count {
73            return Err(TemplateReloadError::BoundExceeded(
74                "zero value not permitted",
75            ));
76        }
77        if let Some(0) = self.max_include_depth {
78            return Err(TemplateReloadError::BoundExceeded(
79                "zero value not permitted",
80            ));
81        }
82        if let Some(0) = self.max_template_size {
83            return Err(TemplateReloadError::BoundExceeded(
84                "zero value not permitted",
85            ));
86        }
87        if let Some(0) = self.reload_timeout_ms {
88            return Err(TemplateReloadError::BoundExceeded(
89                "zero value not permitted",
90            ));
91        }
92
93        Ok(ResolvedExternalTemplateLimits {
94            max_total_source_bytes: self.max_total_source_bytes.unwrap_or(16 * 1024 * 1024),
95            max_include_count: self.max_include_count.unwrap_or(64),
96            max_include_depth: self.max_include_depth.unwrap_or(16),
97            max_template_size: self.max_template_size.unwrap_or(1024 * 1024),
98            reload_timeout_ms: self.reload_timeout_ms.unwrap_or(5000),
99        })
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn limits_resolve_defaults() {
109        let cfg = ExternalTemplateLimitsConfig::default();
110        let resolved = cfg.resolve().expect("default config resolves");
111        assert_eq!(resolved.max_total_source_bytes, 16 * 1024 * 1024);
112        assert_eq!(resolved.max_include_count, 64);
113        assert_eq!(resolved.max_include_depth, 16);
114        assert_eq!(resolved.max_template_size, 1024 * 1024);
115        assert_eq!(resolved.reload_timeout_ms, 5000);
116    }
117
118    #[test]
119    fn limits_reject_zero() {
120        let cfg = ExternalTemplateLimitsConfig {
121            max_include_count: Some(0),
122            ..Default::default()
123        };
124        let err = cfg.resolve().expect_err("zero bound must be rejected");
125        assert!(matches!(err, TemplateReloadError::BoundExceeded(_)));
126    }
127
128    #[test]
129    fn deny_unknown_fields() {
130        let toml = "max-include-count = 3\nbogus = 1\n";
131        let result: Result<ExternalTemplateLimitsConfig, _> = toml::from_str(toml);
132        assert!(result.is_err(), "deny_unknown_fields must reject `bogus`");
133    }
134}