1use std::time::Duration;
2
3use rskit_errors::{AppError, AppResult, ErrorCode};
4use serde::Deserialize;
5
6#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
13pub struct BrokerConfig {
14 #[serde(default = "default_adapter")]
16 pub adapter: String,
17 #[serde(default = "default_name")]
19 pub name: String,
20 #[serde(default = "default_enabled")]
22 pub enabled: bool,
23 #[serde(default = "default_retries")]
25 pub retries: u32,
26 #[serde(default = "default_retry_backoff")]
28 pub retry_backoff: u64,
29 #[serde(default)]
31 pub request_timeout: Option<u64>,
32 #[serde(default)]
34 pub delivery_guarantee: DeliveryGuarantee,
35 #[serde(default)]
37 pub commit_strategy: CommitStrategy,
38 #[serde(default)]
40 pub dlq: DlqPolicy,
41 #[serde(default = "default_max_in_flight")]
43 pub max_in_flight: usize,
44 #[serde(default)]
46 pub consumer_group: Option<String>,
47 #[serde(default)]
49 pub topics: Vec<String>,
50 #[serde(default)]
52 pub subscriptions: Vec<String>,
53}
54
55impl BrokerConfig {
56 #[must_use]
58 pub fn new(adapter: impl Into<String>) -> Self {
59 Self {
60 adapter: adapter.into(),
61 ..Self::default()
62 }
63 }
64
65 #[must_use]
67 pub fn request_timeout_duration(&self) -> Option<Duration> {
68 self.request_timeout.map(Duration::from_millis)
69 }
70
71 #[must_use]
73 pub const fn retry_backoff_duration(&self) -> Duration {
74 Duration::from_millis(self.retry_backoff)
75 }
76
77 pub fn validate(&self) -> AppResult<()> {
79 validate_name("messaging adapter", &self.adapter)?;
80 validate_name("messaging config name", &self.name)?;
81
82 if self.max_in_flight == 0 {
83 return invalid("messaging max_in_flight must be greater than zero");
84 }
85
86 if let Some(timeout) = self.request_timeout
87 && timeout == 0
88 {
89 return invalid("messaging request_timeout must be greater than zero when set");
90 }
91
92 if self.retries > 0 && self.retry_backoff == 0 {
93 return invalid(
94 "messaging retry_backoff must be greater than zero when retries are enabled",
95 );
96 }
97
98 validate_optional_name("messaging consumer_group", self.consumer_group.as_deref())?;
99
100 for topic in &self.topics {
101 validate_topic_like("messaging topic", topic)?;
102 }
103 for subscription in &self.subscriptions {
104 validate_topic_like("messaging subscription", subscription)?;
105 }
106
107 self.dlq.validate()
108 }
109}
110
111impl Default for BrokerConfig {
112 fn default() -> Self {
113 Self {
114 adapter: default_adapter(),
115 name: default_name(),
116 enabled: default_enabled(),
117 retries: default_retries(),
118 retry_backoff: default_retry_backoff(),
119 request_timeout: None,
120 delivery_guarantee: DeliveryGuarantee::default(),
121 commit_strategy: CommitStrategy::default(),
122 dlq: DlqPolicy::default(),
123 max_in_flight: default_max_in_flight(),
124 consumer_group: None,
125 topics: Vec::new(),
126 subscriptions: Vec::new(),
127 }
128 }
129}
130
131#[doc(hidden)]
134#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
135pub struct BrokerConfigOverrides {
136 pub adapter: Option<String>,
138 pub name: Option<String>,
140 pub enabled: Option<bool>,
142 pub retries: Option<u32>,
144 pub retry_backoff: Option<u64>,
146 pub request_timeout: Option<Option<u64>>,
148 pub delivery_guarantee: Option<DeliveryGuarantee>,
150 pub commit_strategy: Option<CommitStrategy>,
152 pub dlq: Option<DlqPolicy>,
154 pub max_in_flight: Option<usize>,
156 pub consumer_group: Option<Option<String>>,
158 pub topics: Option<Vec<String>>,
160 pub subscriptions: Option<Vec<String>>,
162}
163
164impl BrokerConfigOverrides {
165 pub fn apply_to(self, base: &mut BrokerConfig) {
167 if let Some(value) = self.adapter {
168 base.adapter = value;
169 }
170 if let Some(value) = self.name {
171 base.name = value;
172 }
173 if let Some(value) = self.enabled {
174 base.enabled = value;
175 }
176 if let Some(value) = self.retries {
177 base.retries = value;
178 }
179 if let Some(value) = self.retry_backoff {
180 base.retry_backoff = value;
181 }
182 if let Some(value) = self.request_timeout {
183 base.request_timeout = value;
184 }
185 if let Some(value) = self.delivery_guarantee {
186 base.delivery_guarantee = value;
187 }
188 if let Some(value) = self.commit_strategy {
189 base.commit_strategy = value;
190 }
191 if let Some(value) = self.dlq {
192 base.dlq = value;
193 }
194 if let Some(value) = self.max_in_flight {
195 base.max_in_flight = value;
196 }
197 if let Some(value) = self.consumer_group {
198 base.consumer_group = value;
199 }
200 if let Some(value) = self.topics {
201 base.topics = value;
202 }
203 if let Some(value) = self.subscriptions {
204 base.subscriptions = value;
205 }
206 }
207}
208
209pub trait BrokerConfigExt {
216 fn base(&self) -> &BrokerConfig;
218 fn validate(&self) -> AppResult<()>;
220}
221
222const fn default_retries() -> u32 {
223 3
224}
225
226const fn default_retry_backoff() -> u64 {
227 100
228}
229
230fn default_adapter() -> String {
231 "memory".to_string()
232}
233
234const fn default_max_in_flight() -> usize {
235 1
236}
237
238fn default_name() -> String {
239 "default".to_string()
240}
241
242#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
244#[non_exhaustive]
245#[serde(rename_all = "snake_case")]
246pub enum DeliveryGuarantee {
247 AtMostOnce,
249 #[default]
251 AtLeastOnce,
252 ExactlyOnce,
254}
255
256#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
258#[non_exhaustive]
259#[serde(rename_all = "snake_case")]
260pub enum CommitStrategy {
261 Auto,
263 #[default]
265 PostHandlerSuccess,
266 Manual,
268}
269
270#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
272pub struct DlqPolicy {
273 #[serde(default = "default_enabled")]
275 pub enabled: bool,
276 #[serde(default = "default_dlq_suffix")]
278 pub suffix: String,
279}
280
281impl Default for DlqPolicy {
282 fn default() -> Self {
283 Self {
284 enabled: true,
285 suffix: default_dlq_suffix(),
286 }
287 }
288}
289
290impl DlqPolicy {
291 pub fn validate(&self) -> AppResult<()> {
293 if !self.enabled {
294 return Ok(());
295 }
296
297 if self.suffix.is_empty() {
298 return invalid("messaging dlq suffix is required when DLQ is enabled");
299 }
300 if self.suffix.len() > 64 {
301 return invalid("messaging dlq suffix must be at most 64 bytes");
302 }
303 if self.suffix.chars().any(char::is_whitespace) {
304 return invalid("messaging dlq suffix must not contain whitespace");
305 }
306 if self.suffix.contains('/') || self.suffix.contains('\\') {
307 return invalid("messaging dlq suffix must not contain path separators");
308 }
309
310 Ok(())
311 }
312}
313
314const fn default_enabled() -> bool {
315 true
316}
317
318fn default_dlq_suffix() -> String {
319 ".dlq".to_string()
320}
321
322fn validate_optional_name(field: &str, value: Option<&str>) -> AppResult<()> {
323 if let Some(value) = value {
324 validate_topic_like(field, value)?;
325 }
326 Ok(())
327}
328
329fn validate_topic_like(field: &str, value: &str) -> AppResult<()> {
330 if value.trim().is_empty() {
331 return invalid(format!("{field} is required"));
332 }
333 if value
334 .chars()
335 .any(|ch| ch.is_control() || ch.is_whitespace())
336 {
337 return invalid(format!(
338 "{field} must not contain whitespace or control characters"
339 ));
340 }
341 Ok(())
342}
343
344fn validate_name(field: &str, value: &str) -> AppResult<()> {
345 if value.trim().is_empty() {
346 return invalid(format!("{field} is required"));
347 }
348 if value.len() > 128 {
349 return invalid(format!("{field} must be at most 128 bytes"));
350 }
351 if !value
352 .chars()
353 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
354 {
355 return invalid(format!(
356 "{field} must contain only letters, digits, ., _, or -"
357 ));
358 }
359 Ok(())
360}
361
362fn invalid(message: impl Into<String>) -> AppResult<()> {
363 Err(AppError::new(ErrorCode::InvalidInput, message.into()))
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 #[test]
371 fn broker_config_defaults_are_broker_neutral() {
372 let config = BrokerConfig::default();
373
374 assert_eq!(config.adapter, "memory");
375 assert_eq!(config.name, "default");
376 assert!(config.enabled);
377 assert_eq!(config.retries, 3);
378 assert_eq!(config.retry_backoff_duration(), Duration::from_millis(100));
379 assert!(config.request_timeout_duration().is_none());
380 assert_eq!(config.delivery_guarantee, DeliveryGuarantee::AtLeastOnce);
381 assert_eq!(config.commit_strategy, CommitStrategy::PostHandlerSuccess);
382 assert_eq!(config.max_in_flight, 1);
383 assert!(config.consumer_group.is_none());
384 assert!(config.topics.is_empty());
385 assert!(config.subscriptions.is_empty());
386 assert!(config.validate().is_ok());
387 }
388
389 #[test]
390 fn broker_config_validation_rejects_invalid_shared_values() {
391 let config = BrokerConfig {
392 max_in_flight: 0,
393 ..Default::default()
394 };
395 assert!(config.validate().is_err());
396
397 let config = BrokerConfig {
398 request_timeout: Some(0),
399 ..Default::default()
400 };
401 assert!(config.validate().is_err());
402
403 let config = BrokerConfig {
404 retry_backoff: 0,
405 ..Default::default()
406 };
407 assert!(config.validate().is_err());
408
409 let mut config = BrokerConfig::default();
410 config.dlq.suffix = "bad suffix".to_string();
411 assert!(config.validate().is_err());
412 }
413}