1mod tokio_impl;
14
15pub use tokio_impl::TokioCronService;
16
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, Ordering};
19
20use tracing::debug;
21
22use camel_component_api::{
23 BoxProcessor, CamelError, Component, ComponentContext, Consumer, ConsumerContext, CronCallback,
24 CronFire, CronSchedule, CronService, Endpoint, ProducerContext, RuntimeObservability,
25 UriConfig,
26};
27use camel_component_api::{Exchange, Message};
28use chrono_tz::Tz;
29
30#[derive(Debug, Clone, UriConfig)]
38#[uri_scheme = "cron"]
39#[uri_config(skip_impl, crate = "camel_component_api")]
40pub struct CronConfig {
41 pub name: String,
43
44 #[uri_param(name = "schedule")]
46 pub schedule: String,
47
48 #[uri_param(name = "timeZone", default = "UTC")]
50 time_zone_str: String,
51
52 #[uri_param(name = "includeMetadata", default = "true")]
54 include_metadata: bool,
55}
56
57impl CronConfig {
58 pub fn time_zone(&self) -> Result<Tz, CamelError> {
60 self.time_zone_str.parse::<Tz>().map_err(|e| {
61 CamelError::EndpointCreationFailed(format!(
62 "invalid timeZone '{}': {}",
63 self.time_zone_str, e
64 ))
65 })
66 }
67
68 pub fn include_metadata(&self) -> bool {
70 self.include_metadata
71 }
72
73 fn normalized_cron_expression(&self) -> String {
78 format!("0 {}", self.schedule)
79 }
80
81 pub fn validate(&self) -> Result<(), CamelError> {
83 if self.name.is_empty() {
84 return Err(CamelError::EndpointCreationFailed(
85 "cron name must not be empty".to_string(),
86 ));
87 }
88 if self.schedule.is_empty() {
89 return Err(CamelError::EndpointCreationFailed(
90 "cron schedule must not be empty".to_string(),
91 ));
92 }
93 let field_count = self.schedule.split_whitespace().count();
95 if field_count != 5 {
96 return Err(CamelError::EndpointCreationFailed(format!(
97 "cron schedule must be 5-field Unix format (min hour dom month dow), got {} field(s): '{}'",
98 field_count, self.schedule
99 )));
100 }
101 self.normalized_cron_expression()
104 .parse::<cron::Schedule>()
105 .map_err(|e| {
106 CamelError::EndpointCreationFailed(format!(
107 "invalid cron expression '{}': {}",
108 self.schedule, e
109 ))
110 })?;
111 self.time_zone()?;
113 Ok(())
114 }
115}
116
117impl UriConfig for CronConfig {
118 fn scheme() -> &'static str {
119 "cron"
120 }
121
122 fn from_uri(uri: &str) -> Result<Self, CamelError> {
123 let parts = camel_component_api::parse_uri(uri)?;
124 Self::from_components(parts)
125 }
126
127 fn from_components(parts: camel_component_api::UriComponents) -> Result<Self, CamelError> {
128 let mut config = Self::parse_uri_components(parts)?;
129 config.schedule = config.schedule.replace('+', " ");
135 CronConfig::validate(&config)?;
136 Ok(config)
137 }
138
139 fn validate(self) -> Result<Self, CamelError> {
140 CronConfig::validate(&self)?;
141 Ok(self)
142 }
143}
144
145pub struct CronEndpoint {
151 uri: String,
152 config: CronConfig,
153 service: Arc<dyn CronService>,
154}
155
156impl CronEndpoint {
157 pub fn new(uri: String, config: CronConfig, service: Arc<dyn CronService>) -> Self {
158 Self {
159 uri,
160 config,
161 service,
162 }
163 }
164}
165
166impl Endpoint for CronEndpoint {
167 fn uri(&self) -> &str {
168 &self.uri
169 }
170
171 fn create_consumer(
172 &self,
173 _rt: Arc<dyn RuntimeObservability>,
174 ) -> Result<Box<dyn Consumer>, CamelError> {
175 Ok(Box::new(CronConsumer::new(
176 self.config.clone(),
177 self.service.clone(),
178 )))
179 }
180
181 fn create_producer(
182 &self,
183 _rt: Arc<dyn RuntimeObservability>,
184 _ctx: &ProducerContext,
185 ) -> Result<BoxProcessor, CamelError> {
186 Err(CamelError::EndpointCreationFailed(
187 "cron: component is consumer-only".to_string(),
188 ))
189 }
190}
191
192pub struct CronConsumer {
198 config: CronConfig,
199 service: Arc<dyn CronService>,
200 started: AtomicBool,
201}
202
203impl CronConsumer {
204 pub fn new(config: CronConfig, service: Arc<dyn CronService>) -> Self {
206 Self {
207 config,
208 service,
209 started: AtomicBool::new(false),
210 }
211 }
212
213 #[cfg(test)]
215 pub(crate) fn mark_started_for_test(&self) {
216 self.started.store(true, Ordering::SeqCst);
217 }
218}
219
220#[async_trait::async_trait]
221impl Consumer for CronConsumer {
222 async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
223 self.started
224 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
225 .map_err(|_| {
226 CamelError::EndpointCreationFailed("cron consumer already started".to_string())
227 })?;
228
229 CronConfig::validate(&self.config)?;
230 let config = self.config.clone();
231
232 let schedule = CronSchedule {
233 expression: config.normalized_cron_expression(),
234 time_zone: config.time_zone()?,
235 trigger_id: config.name.clone(),
236 route_id: Some(context.route_id().to_string()),
237 };
238
239 let include_metadata = config.include_metadata();
240 let trigger_name = config.name.clone();
241 let trigger_schedule = config.schedule.clone();
242 let time_zone_str = config.time_zone_str.clone();
243
244 let ctx = context.clone();
246 let callback: CronCallback = Arc::new(move |fire: CronFire| {
247 let ctx = ctx.clone();
248 let name = trigger_name.clone();
249 let sched = trigger_schedule.clone();
250 let tz = time_zone_str.clone();
251 Box::pin(async move {
252 let mut exchange = Exchange::new(Message::default());
253 if include_metadata {
254 exchange.input.set_header("CamelCronName", name);
255 exchange.input.set_header("CamelCronSchedule", sched);
256 exchange.input.set_header("CamelCronTimezone", tz);
257 exchange
258 .input
259 .set_header("CamelCronScheduledTime", fire.scheduled_at.to_rfc3339());
260 exchange
261 .input
262 .set_header("CamelCronFiredTime", fire.fired_at.to_rfc3339());
263 exchange
264 .input
265 .set_header("CamelCronCounter", fire.counter.to_string());
266 }
267 ctx.send(exchange).await
268 })
269 });
270
271 let cancel_token = context.cancel_token();
272 let trigger_id = schedule.trigger_id.clone();
273
274 let result = self.service.run(&schedule, callback, cancel_token).await;
276
277 self.started.store(false, Ordering::SeqCst);
278
279 debug!(trigger = trigger_id, "cron consumer stopped");
280 result
281 }
282
283 async fn stop(&mut self) -> Result<(), CamelError> {
284 Ok(())
287 }
288}
289
290pub struct CronComponent {
299 service: Arc<dyn CronService>,
300}
301
302impl Default for CronComponent {
303 fn default() -> Self {
304 Self::new()
305 }
306}
307
308impl CronComponent {
309 pub fn new() -> Self {
311 Self {
312 service: Arc::new(TokioCronService),
313 }
314 }
315
316 pub fn with_service(service: Arc<dyn CronService>) -> Self {
318 Self { service }
319 }
320}
321
322impl Component for CronComponent {
323 fn scheme(&self) -> &str {
324 "cron"
325 }
326
327 fn create_endpoint(
328 &self,
329 uri: &str,
330 _ctx: &dyn ComponentContext,
331 ) -> Result<Box<dyn Endpoint>, CamelError> {
332 let config = CronConfig::from_uri(uri)?;
333 Ok(Box::new(CronEndpoint::new(
334 uri.to_string(),
335 config,
336 self.service.clone(),
337 )))
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn test_cron_config_basic() {
347 let config = CronConfig::from_uri("cron:nightly?schedule=0%202%20*%20*%20*").unwrap();
348 assert_eq!(config.name, "nightly");
349 assert_eq!(config.schedule, "0 2 * * *");
350 assert_eq!(config.time_zone_str, "UTC");
351 assert!(config.include_metadata);
352 }
353
354 #[test]
355 fn test_cron_config_with_timezone() {
356 let config =
357 CronConfig::from_uri("cron:morning?schedule=0%209%20*%20*%201&timeZone=America/Lima")
358 .unwrap();
359 assert_eq!(config.name, "morning");
360 assert_eq!(config.time_zone().unwrap(), chrono_tz::Tz::America__Lima);
361 }
362
363 #[test]
364 fn test_cron_config_percent_encoded_spaces() {
365 let config = CronConfig::from_uri("cron:test?schedule=0%202%20*%20*%20*").unwrap();
367 assert_eq!(config.schedule, "0 2 * * *");
368 config.validate().unwrap();
369 }
370
371 #[test]
372 fn test_cron_config_plus_as_space() {
373 let config = CronConfig::from_uri("cron:test?schedule=0+2+*+*+*").unwrap();
375 assert_eq!(config.schedule, "0 2 * * *");
376 config.validate().unwrap();
377 }
378
379 #[test]
380 fn test_rejects_empty_name() {
381 let err = CronConfig::from_uri("cron:?schedule=0%202%20*%20*%20*").unwrap_err();
382 assert!(
383 err.to_string().contains("must not be empty"),
384 "unexpected error: {err}"
385 );
386 }
387
388 #[test]
389 fn test_rejects_missing_schedule() {
390 let result = CronConfig::from_uri("cron:test");
391 assert!(result.is_err() || result.unwrap().validate().is_err());
393 }
394
395 #[test]
396 fn test_rejects_six_field_cron() {
397 let err = CronConfig::from_uri("cron:test?schedule=0%200%202%20*%20*%20*").unwrap_err();
398 assert!(err.to_string().contains("5-field"));
399 }
400
401 #[test]
402 fn test_rejects_invalid_cron() {
403 let err = CronConfig::from_uri("cron:test?schedule=99%2099%2099%2099%2099").unwrap_err();
404 assert!(
405 err.to_string().contains("invalid cron"),
406 "unexpected error: {err}"
407 );
408 }
409
410 #[test]
411 fn test_rejects_invalid_timezone() {
412 let err = CronConfig::from_uri("cron:test?schedule=0%202%20*%20*%20*&timeZone=NotAZone")
413 .unwrap_err();
414 assert!(
415 err.to_string().contains("invalid timeZone"),
416 "unexpected error: {err}"
417 );
418 }
419
420 #[test]
421 fn test_valid_expressions() {
422 for expr in &[
427 "0%202%20*%20*%20*",
428 "*%2F5%20*%20*%20*%20*",
429 "0%209%20*%20*%201",
430 ] {
431 let config = CronConfig::from_uri(&format!("cron:t?schedule={}", expr)).unwrap();
432 config
433 .validate()
434 .unwrap_or_else(|e| panic!("'{}' should be valid: {}", expr, e));
435 }
436 }
437
438 struct FastCronService;
441 #[async_trait::async_trait]
442 impl CronService for FastCronService {
443 async fn run(
444 &self,
445 _schedule: &CronSchedule,
446 callback: CronCallback,
447 cancel: tokio_util::sync::CancellationToken,
448 ) -> Result<(), CamelError> {
449 let mut counter = 0u64;
450 loop {
451 tokio::select! {
452 _ = cancel.cancelled() => return Ok(()),
453 _ = tokio::time::sleep(tokio::time::Duration::from_millis(10)) => {
454 counter += 1;
455 let now = chrono::Utc::now();
456 let fire = CronFire {
457 scheduled_at: now,
458 fired_at: now,
459 counter,
460 };
461 callback(fire).await?;
462 }
463 }
464 }
465 }
466 }
467
468 #[tokio::test]
471 async fn test_double_start_returns_error() {
472 let config = CronConfig::from_uri("cron:test?schedule=0 2 * * *").unwrap();
473 let mut consumer = CronConsumer::new(config, Arc::new(FastCronService));
474 consumer.mark_started_for_test();
475
476 let (tx, _rx) = tokio::sync::mpsc::channel(16);
477 let cancel = tokio_util::sync::CancellationToken::new();
478 let ctx = ConsumerContext::new(tx, cancel, "test-route".to_string());
479 let result = consumer.start(ctx).await;
480 assert!(result.is_err(), "double-start should error");
481 }
482
483 #[tokio::test]
484 async fn test_consumer_fires_exchange() {
485 let config = CronConfig::from_uri("cron:test?schedule=* * * * *").unwrap();
486 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
487 let cancel = tokio_util::sync::CancellationToken::new();
488 let ctx = ConsumerContext::new(tx, cancel.clone(), "test-route".to_string());
489 let mut consumer = CronConsumer::new(config, Arc::new(FastCronService));
490
491 let cancel2 = cancel.clone();
492 let handle = tokio::spawn(async move { consumer.start(ctx).await });
493
494 let exchange = tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv()).await;
496 assert!(exchange.is_ok(), "should have received an exchange");
497
498 cancel2.cancel();
499 let _ = handle.await;
500 }
501
502 #[tokio::test]
503 async fn test_consumer_metadata_headers() {
504 let config = CronConfig::from_uri("cron:test?schedule=* * * * *").unwrap();
505 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
506 let cancel = tokio_util::sync::CancellationToken::new();
507 let ctx = ConsumerContext::new(tx, cancel.clone(), "test-route".to_string());
508 let mut consumer = CronConsumer::new(config, Arc::new(FastCronService));
509
510 let cancel2 = cancel.clone();
511 tokio::spawn(async move { consumer.start(ctx).await });
512
513 let envelope = tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv())
514 .await
515 .unwrap()
516 .unwrap();
517
518 let headers = &envelope.exchange.input.headers;
519 assert!(headers.contains_key("CamelCronName"));
520 assert!(headers.contains_key("CamelCronSchedule"));
521 assert!(headers.contains_key("CamelCronTimezone"));
522 assert!(headers.contains_key("CamelCronScheduledTime"));
523 assert!(headers.contains_key("CamelCronFiredTime"));
524 assert!(headers.contains_key("CamelCronCounter"));
525
526 cancel2.cancel();
527 }
528
529 #[tokio::test]
530 async fn test_include_metadata_false_omits_headers() {
531 let config =
532 CronConfig::from_uri("cron:test?schedule=* * * * *&includeMetadata=false").unwrap();
533 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
534 let cancel = tokio_util::sync::CancellationToken::new();
535 let ctx = ConsumerContext::new(tx, cancel.clone(), "test-route".to_string());
536 let mut consumer = CronConsumer::new(config, Arc::new(FastCronService));
537
538 let cancel2 = cancel.clone();
539 tokio::spawn(async move { consumer.start(ctx).await });
540
541 let envelope = tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv())
542 .await
543 .unwrap()
544 .unwrap();
545
546 assert!(
547 envelope.exchange.input.headers.is_empty(),
548 "no headers when includeMetadata=false"
549 );
550
551 cancel2.cancel();
552 }
553
554 #[tokio::test]
555 async fn test_callback_error_propagates_to_start() {
556 let config = CronConfig::from_uri("cron:test?schedule=* * * * *").unwrap();
558 let (tx, rx) = tokio::sync::mpsc::channel(16);
559 drop(rx);
561 let cancel = tokio_util::sync::CancellationToken::new();
562 let ctx = ConsumerContext::new(tx, cancel, "test-route".to_string());
563 let mut consumer = CronConsumer::new(config, Arc::new(FastCronService));
564
565 let result =
566 tokio::time::timeout(tokio::time::Duration::from_secs(2), consumer.start(ctx)).await;
567
568 let inner = result.expect("should not timeout");
570 assert!(
571 inner.is_err(),
572 "closed channel should propagate error to start()"
573 );
574 }
575
576 #[test]
579 fn test_cron_component_scheme() {
580 let comp = CronComponent::new();
581 assert_eq!(comp.scheme(), "cron");
582 }
583
584 #[test]
585 fn test_cron_component_creates_endpoint() {
586 let comp = CronComponent::new();
587 let ctx = camel_component_api::NoOpComponentContext;
588 let endpoint = comp
589 .create_endpoint("cron:nightly?schedule=0 2 * * *", &ctx)
590 .unwrap();
591 assert_eq!(endpoint.uri(), "cron:nightly?schedule=0 2 * * *");
592 }
593
594 #[test]
595 fn test_cron_component_rejects_invalid_schedule() {
596 let comp = CronComponent::new();
597 let ctx = camel_component_api::NoOpComponentContext;
598 let result = comp.create_endpoint("cron:test?schedule=invalid", &ctx);
599 assert!(result.is_err());
600 }
601
602 #[test]
603 fn test_cron_component_with_custom_service() {
604 let custom_service: Arc<dyn CronService> = Arc::new(TokioCronService);
605 let comp = CronComponent::with_service(custom_service);
606 assert_eq!(comp.scheme(), "cron");
607 }
608}