Skip to main content

camel_component_timer/
lib.rs

1//! Timer component for rust-camel — fires `Exchange` events on configurable period, delay, and repeat-count schedules.
2//!
3//! Main types: `TimerComponent`, `TimerConsumer`, `TimerConfig`, `TimerEndpoint`.
4//! URI format: `timer:name?period=1000&delay=0&repeatCount=0`.
5//!
6//! # Features
7//!
8//! - **fixedRate**: When enabled, uses skip-missed-tick semantics instead of burst.
9//! - **includeMetadata**: Controls whether timer metadata headers are included in exchanges.
10//! - Double-start protection via `AtomicBool` guard.
11
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::time::Duration;
14
15use async_trait::async_trait;
16use chrono::Utc;
17use tokio::time;
18use tracing::debug;
19
20use camel_component_api::{BoxProcessor, CamelError, Exchange, Message};
21use camel_component_api::{Component, Consumer, ConsumerContext, Endpoint, ProducerContext};
22use camel_component_api::{ComponentMetadata, UriConfig};
23
24// ---------------------------------------------------------------------------
25// TimerConfig
26// ---------------------------------------------------------------------------
27
28/// Configuration parsed from a timer URI.
29///
30/// Format: `timer:name?period=1000&delay=0&repeatCount=0&fixedRate=false&includeMetadata=true`
31#[derive(Debug, Clone, UriConfig)]
32#[uri_scheme = "timer"]
33#[uri_config(
34    skip_impl,
35    metadata(
36        scheme = "timer",
37        description = "Generate timer-based events",
38        consumer
39    ),
40    crate = "camel_component_api"
41)]
42pub struct TimerConfig {
43    /// Timer name (the path portion of the URI).
44    pub name: String,
45
46    /// Interval between ticks (milliseconds). Default: 1000.
47    #[allow(dead_code)] // Used by macro-generated Duration conversion
48    #[uri_param(name = "period", default = "1000")]
49    period_ms: u64,
50
51    /// Converted Duration for period.
52    pub period: Duration,
53
54    /// Initial delay before the first tick (milliseconds). Default: 0.
55    #[allow(dead_code)] // Used by macro-generated Duration conversion
56    #[uri_param(name = "delay", default = "0")]
57    delay_ms: u64,
58
59    /// Converted Duration for delay.
60    pub delay: Duration,
61
62    /// Maximum number of ticks. `None` means infinite.
63    #[uri_param(name = "repeatCount")]
64    pub repeat_count: Option<u32>,
65
66    /// When true, use fixed-rate semantics (skip missed ticks).
67    /// When false (default), use burst semantics (fire all missed ticks immediately).
68    #[uri_param(name = "fixedRate", default = "false")]
69    pub fixed_rate: bool,
70
71    /// When true (default), include metadata headers (CamelTimerFiredTime,
72    /// CamelMessageTimestamp, CamelTimerName) in each exchange.
73    /// When false, send a minimal exchange without metadata headers.
74    #[uri_param(name = "includeMetadata", default = "true")]
75    pub include_metadata: bool,
76}
77
78// Inherent validate — callable as TimerConfig::validate(&self)
79impl TimerConfig {
80    /// Validate the configuration without consuming self.
81    pub fn validate(&self) -> Result<(), CamelError> {
82        if self.name.trim().is_empty() {
83            return Err(CamelError::InvalidUri(
84                "timer name must not be empty".to_string(),
85            ));
86        }
87        if self.period.is_zero() {
88            return Err(CamelError::InvalidUri(
89                "timer period must be greater than 0".to_string(),
90            ));
91        }
92        Ok(())
93    }
94}
95
96impl UriConfig for TimerConfig {
97    fn scheme() -> &'static str {
98        "timer"
99    }
100
101    fn from_uri(uri: &str) -> Result<Self, CamelError> {
102        let parts = camel_component_api::parse_uri(uri)?;
103        Self::from_components(parts)
104    }
105
106    fn from_components(parts: camel_component_api::UriComponents) -> Result<Self, CamelError> {
107        let config = Self::parse_uri_components(parts)?;
108        TimerConfig::validate(&config)?;
109        Ok(config)
110    }
111
112    fn validate(self) -> Result<Self, CamelError> {
113        // Delegate to the inherent validate(&self)
114        TimerConfig::validate(&self)?;
115        Ok(self)
116    }
117}
118
119// ---------------------------------------------------------------------------
120// TimerComponent
121// ---------------------------------------------------------------------------
122
123/// The Timer component produces exchanges on a periodic interval.
124pub struct TimerComponent;
125
126impl TimerComponent {
127    pub fn new() -> Self {
128        Self
129    }
130}
131
132impl Default for TimerComponent {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138impl Component for TimerComponent {
139    fn scheme(&self) -> &str {
140        "timer"
141    }
142
143    fn metadata(&self) -> ComponentMetadata {
144        TimerConfig::metadata()
145    }
146
147    fn create_endpoint(
148        &self,
149        uri: &str,
150        _ctx: &dyn camel_component_api::ComponentContext,
151    ) -> Result<Box<dyn Endpoint>, CamelError> {
152        let config = TimerConfig::from_uri(uri)?;
153        Ok(Box::new(TimerEndpoint {
154            uri: uri.to_string(),
155            config,
156        }))
157    }
158}
159
160// ---------------------------------------------------------------------------
161// TimerEndpoint
162// ---------------------------------------------------------------------------
163
164pub struct TimerEndpoint {
165    uri: String,
166    config: TimerConfig,
167}
168
169impl Endpoint for TimerEndpoint {
170    fn uri(&self) -> &str {
171        &self.uri
172    }
173
174    fn create_consumer(
175        &self,
176        rt: std::sync::Arc<dyn camel_component_api::RuntimeObservability>,
177    ) -> Result<Box<dyn Consumer>, CamelError> {
178        Ok(Box::new(TimerConsumer {
179            config: self.config.clone(),
180            started: AtomicBool::new(false),
181            runtime: rt,
182        }))
183    }
184
185    fn create_producer(
186        &self,
187        _rt: std::sync::Arc<dyn camel_component_api::RuntimeObservability>,
188        _ctx: &ProducerContext,
189    ) -> Result<BoxProcessor, CamelError> {
190        Err(CamelError::EndpointCreationFailed(
191            "timer endpoint does not support producers".to_string(),
192        ))
193    }
194}
195
196// ---------------------------------------------------------------------------
197// TimerConsumer
198// ---------------------------------------------------------------------------
199
200pub struct TimerConsumer {
201    config: TimerConfig,
202    /// Guard against double-start (TIMER-003).
203    started: AtomicBool,
204    /// Used for `rt.metrics().increment_errors(...)` calls per ADR-0012
205    /// (`b-prime:timer:fire-send` on tick fire-send failure).
206    runtime: std::sync::Arc<dyn camel_component_api::RuntimeObservability>,
207}
208
209#[async_trait]
210impl Consumer for TimerConsumer {
211    async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
212        // TIMER-003: Guard against double-start
213        self.started
214            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
215            .map_err(|_| {
216                CamelError::EndpointCreationFailed("timer consumer already started".to_string())
217            })?;
218
219        TimerConfig::validate(&self.config)?;
220        let config = self.config.clone();
221        let cancel_token = context.cancel_token();
222
223        // Initial delay (cancellable so shutdown isn't blocked by long delays)
224        if !config.delay.is_zero() {
225            tokio::select! {
226                _ = time::sleep(config.delay) => {}
227                _ = cancel_token.cancelled() => {
228                    debug!(timer = config.name, "Timer cancelled during initial delay");
229                    self.started.store(false, Ordering::SeqCst);
230                    return Ok(());
231                }
232            }
233        }
234
235        // If repeat_count is explicitly 0, fire zero times — stop immediately.
236        if config.repeat_count == Some(0) {
237            debug!(timer = config.name, "repeat_count=0, timer will not fire");
238            self.started.store(false, Ordering::SeqCst);
239            return Ok(());
240        }
241
242        let mut interval = time::interval(config.period);
243
244        // TIMER-002: fixedRate controls missed-tick behavior
245        if config.fixed_rate {
246            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
247        } else {
248            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Burst);
249        }
250
251        let mut count: u32 = 0;
252
253        loop {
254            tokio::select! {
255                _ = cancel_token.cancelled() => {
256                    debug!(timer = config.name, "Timer received cancellation, stopping");
257                    break;
258                }
259                _ = interval.tick() => {
260                    count += 1;
261
262                    debug!(timer = config.name, count, "Timer tick");
263
264                    let mut exchange = Exchange::new(Message::new(format!(
265                        "timer://{} tick #{}",
266                        config.name, count
267                    )));
268
269                    // TIMER-005 & TIMER-006: include metadata headers when enabled
270                    if config.include_metadata {
271                        exchange.input.set_header(
272                            "CamelTimerName",
273                            serde_json::Value::String(config.name.clone()),
274                        );
275                        exchange
276                            .input
277                            .set_header("CamelTimerCounter", serde_json::Value::Number(count.into()));
278
279                        // TIMER-005: CamelTimerFiredTime (ISO-8601) and CamelMessageTimestamp (epoch millis)
280                        let now = Utc::now();
281                        exchange.input.set_header(
282                            "CamelTimerFiredTime",
283                            serde_json::Value::String(now.to_rfc3339()),
284                        );
285                        exchange.input.set_header(
286                            "CamelMessageTimestamp",
287                            serde_json::Value::Number(
288                                now.timestamp_millis().into(),
289                            ),
290                        );
291                    }
292
293                    if context.send(exchange).await.is_err() {
294                        // (category b′ per ADR-0012: locally terminal fire
295                        // send — the route channel is closed and the loop
296                        // exits, so this is the only signal.)
297                        self.runtime
298                            .metrics()
299                            .increment_errors(context.route_id(), "b-prime:timer:fire-send");
300                        // Channel closed, route was stopped
301                        break;
302                    }
303
304                    if let Some(max) = config.repeat_count
305                        && count >= max
306                    {
307                        break;
308                    }
309                }
310            }
311        }
312
313        // Reset started flag so consumer can be restarted after stop
314        self.started.store(false, Ordering::SeqCst);
315        Ok(())
316    }
317
318    async fn stop(&mut self) -> Result<(), CamelError> {
319        self.started.store(false, Ordering::SeqCst);
320        debug!(timer = self.config.name, "timer consumer stopped");
321        Ok(())
322    }
323}
324
325impl TimerConsumer {
326    /// Test helper: pre-set the started flag to simulate an already-running consumer.
327    #[cfg(test)]
328    pub(crate) fn mark_started_for_test(&self) {
329        self.started.store(true, Ordering::SeqCst);
330    }
331}
332
333// ---------------------------------------------------------------------------
334// Tests
335// ---------------------------------------------------------------------------
336
337#[cfg(test)]
338mod tests {
339    use std::sync::{Arc, Mutex};
340
341    use camel_component_api::test_support::PanicRuntimeObservability;
342    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
343        std::sync::Arc::new(PanicRuntimeObservability)
344    }
345
346    use super::*;
347    use camel_component_api::NoOpComponentContext;
348
349    #[test]
350    fn test_zero_period_rejected() {
351        let result = TimerConfig::from_uri("timer:tick?period=0");
352        assert!(result.is_err(), "period=0 should be rejected");
353        let err_msg = result.unwrap_err().to_string();
354        assert!(err_msg.contains("period"), "error should mention 'period'");
355    }
356
357    #[test]
358    fn test_timer_empty_name_rejected() {
359        let result = TimerConfig::from_uri("timer:");
360        assert!(result.is_err());
361        let err = result.unwrap_err().to_string();
362        assert!(err.contains("must not be empty"), "unexpected error: {err}");
363    }
364
365    #[test]
366    fn test_timer_config_defaults() {
367        let config = TimerConfig::from_uri("timer:tick").unwrap();
368        assert_eq!(config.name, "tick");
369        assert_eq!(config.period, Duration::from_millis(1000));
370        assert_eq!(config.delay, Duration::from_millis(0));
371        assert_eq!(config.repeat_count, None);
372    }
373
374    #[test]
375    fn test_timer_config_with_params() {
376        let config =
377            TimerConfig::from_uri("timer:myTimer?period=500&delay=100&repeatCount=5").unwrap();
378        assert_eq!(config.name, "myTimer");
379        assert_eq!(config.period, Duration::from_millis(500));
380        assert_eq!(config.delay, Duration::from_millis(100));
381        assert_eq!(config.repeat_count, Some(5));
382    }
383
384    #[test]
385    fn test_timer_config_wrong_scheme() {
386        let result = TimerConfig::from_uri("log:info");
387        assert!(result.is_err());
388    }
389
390    #[test]
391    fn test_timer_component_scheme() {
392        let component = TimerComponent::new();
393        assert_eq!(component.scheme(), "timer");
394    }
395
396    #[test]
397    fn test_timer_component_creates_endpoint() {
398        let component = TimerComponent::new();
399        let endpoint = component.create_endpoint("timer:tick?period=1000", &NoOpComponentContext);
400        assert!(endpoint.is_ok());
401    }
402
403    #[test]
404    fn test_timer_endpoint_no_producer() {
405        let ctx = ProducerContext::new();
406        let component = TimerComponent::new();
407        let endpoint = component
408            .create_endpoint("timer:tick", &NoOpComponentContext)
409            .unwrap();
410        let producer = endpoint.create_producer(rt(), &ctx);
411        assert!(producer.is_err());
412    }
413
414    #[test]
415    fn test_rejects_empty_timer_name() {
416        let mut cfg = TimerConfig::from_uri("timer:tick").unwrap();
417        cfg.name = "".into();
418        assert!(cfg.validate().is_err());
419    }
420
421    #[test]
422    fn test_rejects_zero_period() {
423        let mut cfg = TimerConfig::from_uri("timer:tick").unwrap();
424        cfg.period = Duration::ZERO;
425        assert!(cfg.validate().is_err());
426    }
427
428    #[test]
429    fn test_valid_config_passes() {
430        let mut cfg = TimerConfig::from_uri("timer:tick").unwrap();
431        cfg.name = "myTimer".into();
432        cfg.period = Duration::from_millis(1000);
433        assert!(cfg.validate().is_ok());
434    }
435
436    #[tokio::test]
437    async fn test_repeat_count_zero_fires_never() {
438        let component = TimerComponent::new();
439        let endpoint = component
440            .create_endpoint(
441                "timer:zero-test?period=50&repeatCount=0",
442                &NoOpComponentContext,
443            )
444            .unwrap();
445        let mut consumer = endpoint.create_consumer(rt()).unwrap();
446
447        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
448        let ctx = ConsumerContext::new(
449            tx,
450            tokio_util::sync::CancellationToken::new(),
451            "timer-test-route".to_string(),
452        );
453
454        // Start the consumer (spawns internally, returns immediately)
455        consumer.start(ctx).await.unwrap();
456
457        // Wait longer than the period — no messages should arrive
458        tokio::time::sleep(Duration::from_millis(200)).await;
459
460        // Drain any pending messages
461        let mut count = 0;
462        while rx.try_recv().is_ok() {
463            count += 1;
464        }
465        assert_eq!(
466            count, 0,
467            "repeat_count=0 should produce zero fires, got {count}"
468        );
469
470        // Clean up
471        consumer.stop().await.unwrap();
472    }
473
474    #[tokio::test]
475    async fn test_repeat_count_omitted_fires_indefinitely() {
476        use tokio_util::sync::CancellationToken;
477
478        let token = CancellationToken::new();
479        let (tx, mut rx) = tokio::sync::mpsc::channel(32);
480        let ctx = ConsumerContext::new(tx, token.clone(), "timer-infinite-route".to_string());
481
482        // No repeatCount in the URI — must fire indefinitely until cancelled.
483        let mut consumer = TimerConsumer {
484            config: TimerConfig::from_uri("timer:infinite-test?period=20").unwrap(),
485            started: AtomicBool::new(false),
486            runtime: rt(),
487        };
488
489        let handle = tokio::spawn(async move {
490            consumer.start(ctx).await.unwrap();
491        });
492
493        // Window long enough that any finite repeat_count <= 5 would have
494        // stopped well before the cap we assert (6 fires at 20ms = ~120ms).
495        tokio::time::sleep(Duration::from_millis(200)).await;
496        token.cancel();
497        let _ = tokio::time::timeout(Duration::from_secs(1), handle).await;
498
499        let mut count = 0;
500        while rx.try_recv().is_ok() {
501            count += 1;
502        }
503        assert!(
504            count >= 6,
505            "Omitted repeatCount should fire indefinitely; expected >= 6 fires in window, got {count}"
506        );
507    }
508
509    #[tokio::test]
510    async fn test_timer_consumer_fires() {
511        let component = TimerComponent::new();
512        let endpoint = component
513            .create_endpoint("timer:test?period=50&repeatCount=3", &NoOpComponentContext)
514            .unwrap();
515        let mut consumer = endpoint.create_consumer(rt()).unwrap();
516
517        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
518        let ctx = ConsumerContext::new(
519            tx,
520            tokio_util::sync::CancellationToken::new(),
521            "timer-test-route".to_string(),
522        );
523
524        // Run consumer in background
525        tokio::spawn(async move {
526            consumer.start(ctx).await.unwrap();
527        });
528
529        // Collect exchanges
530        let mut received = Vec::new();
531        while let Some(envelope) = rx.recv().await {
532            received.push(envelope.exchange);
533            if received.len() == 3 {
534                break;
535            }
536        }
537
538        assert_eq!(received.len(), 3);
539
540        // Verify headers on the first exchange
541        let first = &received[0];
542        assert_eq!(
543            first.input.header("CamelTimerName"),
544            Some(&serde_json::Value::String("test".into()))
545        );
546        assert_eq!(
547            first.input.header("CamelTimerCounter"),
548            Some(&serde_json::Value::Number(1.into()))
549        );
550    }
551
552    #[tokio::test]
553    async fn test_timer_consumer_respects_cancellation() {
554        use tokio_util::sync::CancellationToken;
555
556        let token = CancellationToken::new();
557        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
558        let ctx = ConsumerContext::new(tx, token.clone(), "timer-test-route".to_string());
559
560        let mut consumer = TimerConsumer {
561            config: TimerConfig::from_uri("timer:cancel-test?period=50").unwrap(),
562            started: AtomicBool::new(false),
563            runtime: rt(),
564        };
565
566        let handle = tokio::spawn(async move {
567            consumer.start(ctx).await.unwrap();
568        });
569
570        // Let it fire a few times
571        tokio::time::sleep(Duration::from_millis(180)).await;
572        token.cancel();
573
574        let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
575        assert!(
576            result.is_ok(),
577            "Consumer should have stopped after cancellation"
578        );
579
580        let mut count = 0;
581        while rx.try_recv().is_ok() {
582            count += 1;
583        }
584        assert!(
585            count >= 2,
586            "Expected at least 2 exchanges before cancellation, got {count}"
587        );
588    }
589
590    #[tokio::test]
591    async fn test_timer_consumer_stop_shuts_down() {
592        let component = TimerComponent::new();
593        let endpoint = component
594            .create_endpoint("timer:stop-test?period=50", &NoOpComponentContext)
595            .unwrap();
596        let mut consumer = endpoint.create_consumer(rt()).unwrap();
597
598        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
599        let token = tokio_util::sync::CancellationToken::new();
600        let ctx = ConsumerContext::new(tx, token.clone(), "timer-test-route".to_string());
601
602        // Run consumer in background (start() blocks until cancelled)
603        tokio::spawn(async move {
604            consumer.start(ctx).await.unwrap();
605        });
606
607        // Let it fire a few times
608        tokio::time::sleep(Duration::from_millis(180)).await;
609
610        // Drain any pending exchanges
611        let mut count = 0;
612        while rx.try_recv().is_ok() {
613            count += 1;
614        }
615        assert!(count >= 2, "Expected at least 2 exchanges, got {count}");
616
617        // Cancel the token to stop the consumer
618        token.cancel();
619    }
620
621    // TIMER-002: fixedRate config round-trip
622    #[test]
623    fn test_fixed_rate_default_is_false() {
624        let config = TimerConfig::from_uri("timer:tick").unwrap();
625        assert!(!config.fixed_rate, "fixedRate should default to false");
626    }
627
628    #[test]
629    fn test_fixed_rate_parsed_from_uri() {
630        let config = TimerConfig::from_uri("timer:tick?fixedRate=true").unwrap();
631        assert!(
632            config.fixed_rate,
633            "fixedRate should be true when set in URI"
634        );
635    }
636
637    // TIMER-003: double-start guard
638    #[tokio::test]
639    async fn test_double_start_returns_error() {
640        let component = TimerComponent::new();
641        let endpoint = component
642            .create_endpoint(
643                "timer:double?period=50&repeatCount=2",
644                &NoOpComponentContext,
645            )
646            .unwrap(); // allow-unwrap: test setup
647
648        let mut consumer = TimerConsumer {
649            config: TimerConfig {
650                name: "double-test".to_string(),
651                period: Duration::from_millis(100),
652                period_ms: 100,
653                delay: Duration::ZERO,
654                delay_ms: 0,
655                repeat_count: None,
656                fixed_rate: false,
657                include_metadata: true,
658            },
659            started: AtomicBool::new(false),
660            runtime: rt(),
661        };
662
663        // Simulate the consumer already being started by setting the flag.
664        consumer.mark_started_for_test();
665
666        let (tx, _rx) = tokio::sync::mpsc::channel(16);
667        let cancel_token = tokio_util::sync::CancellationToken::new();
668        let ctx = ConsumerContext::new(tx, cancel_token.clone(), "timer-test-route".to_string());
669
670        // Second start on an already-started consumer must return an error.
671        let result = consumer.start(ctx).await;
672        assert!(result.is_err(), "expected double-start to return Err");
673        let err_str = format!("{:?}", result.unwrap_err());
674        assert!(
675            err_str.contains("already started"),
676            "unexpected error: {err_str}"
677        );
678
679        drop(endpoint); // suppress unused-variable warning
680    }
681
682    // TIMER-005: CamelTimerFiredTime and CamelMessageTimestamp headers
683    #[tokio::test]
684    async fn test_timer_fired_time_and_message_timestamp_headers() {
685        let component = TimerComponent::new();
686        let endpoint = component
687            .create_endpoint(
688                "timer:headers?period=50&repeatCount=1",
689                &NoOpComponentContext,
690            )
691            .unwrap();
692        let mut consumer = endpoint.create_consumer(rt()).unwrap();
693
694        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
695        let ctx = ConsumerContext::new(
696            tx,
697            tokio_util::sync::CancellationToken::new(),
698            "timer-test-route".to_string(),
699        );
700
701        tokio::spawn(async move {
702            consumer.start(ctx).await.unwrap();
703        });
704
705        let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
706            .await
707            .expect("should receive exchange")
708            .expect("envelope should exist");
709
710        let exchange = envelope.exchange;
711
712        // CamelTimerFiredTime should be an ISO-8601 string
713        let fired_time = exchange
714            .input
715            .header("CamelTimerFiredTime")
716            .expect("CamelTimerFiredTime header should be present");
717        assert!(
718            fired_time.is_string(),
719            "CamelTimerFiredTime should be a string"
720        );
721        let fired_str = fired_time.as_str().unwrap();
722        // Should parse as ISO-8601 / RFC 3339
723        assert!(
724            chrono::DateTime::parse_from_rfc3339(fired_str).is_ok(),
725            "CamelTimerFiredTime should be valid RFC 3339: {fired_str}"
726        );
727
728        // CamelMessageTimestamp should be a number (epoch millis)
729        let msg_ts = exchange
730            .input
731            .header("CamelMessageTimestamp")
732            .expect("CamelMessageTimestamp header should be present");
733        assert!(
734            msg_ts.is_number(),
735            "CamelMessageTimestamp should be a number"
736        );
737        let ts_millis = msg_ts.as_i64().expect("should be i64");
738        assert!(ts_millis > 0, "timestamp should be positive");
739    }
740
741    #[test]
742    fn test_timer_fired_time_header_format() {
743        // Verify the format independently
744        let now = chrono::Utc::now();
745        let rfc = now.to_rfc3339();
746        assert!(chrono::DateTime::parse_from_rfc3339(&rfc).is_ok());
747        let millis = now.timestamp_millis();
748        assert!(millis > 0);
749    }
750
751    // TIMER-006: includeMetadata option
752    #[test]
753    fn test_include_metadata_default_is_true() {
754        let config = TimerConfig::from_uri("timer:tick").unwrap();
755        assert!(
756            config.include_metadata,
757            "includeMetadata should default to true"
758        );
759    }
760
761    #[test]
762    fn test_include_metadata_false_from_uri() {
763        let config = TimerConfig::from_uri("timer:tick?includeMetadata=false").unwrap();
764        assert!(
765            !config.include_metadata,
766            "includeMetadata should be false when set in URI"
767        );
768    }
769
770    #[tokio::test]
771    async fn test_include_metadata_false_omits_headers() {
772        let component = TimerComponent::new();
773        let endpoint = component
774            .create_endpoint(
775                "timer:minimal?period=50&repeatCount=1&includeMetadata=false",
776                &NoOpComponentContext,
777            )
778            .unwrap();
779        let mut consumer = endpoint.create_consumer(rt()).unwrap();
780
781        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
782        let ctx = ConsumerContext::new(
783            tx,
784            tokio_util::sync::CancellationToken::new(),
785            "timer-test-route".to_string(),
786        );
787
788        tokio::spawn(async move {
789            consumer.start(ctx).await.unwrap();
790        });
791
792        let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
793            .await
794            .expect("should receive exchange")
795            .expect("envelope should exist");
796
797        let exchange = envelope.exchange;
798
799        // No metadata headers should be present
800        assert!(
801            exchange.input.header("CamelTimerName").is_none(),
802            "CamelTimerName should not be present when includeMetadata=false"
803        );
804        assert!(
805            exchange.input.header("CamelTimerCounter").is_none(),
806            "CamelTimerCounter should not be present when includeMetadata=false"
807        );
808        assert!(
809            exchange.input.header("CamelTimerFiredTime").is_none(),
810            "CamelTimerFiredTime should not be present when includeMetadata=false"
811        );
812        assert!(
813            exchange.input.header("CamelMessageTimestamp").is_none(),
814            "CamelMessageTimestamp should not be present when includeMetadata=false"
815        );
816    }
817
818    #[tokio::test]
819    async fn test_include_metadata_true_includes_all_headers() {
820        let component = TimerComponent::new();
821        let endpoint = component
822            .create_endpoint(
823                "timer:full?period=50&repeatCount=1&includeMetadata=true",
824                &NoOpComponentContext,
825            )
826            .unwrap();
827        let mut consumer = endpoint.create_consumer(rt()).unwrap();
828
829        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
830        let ctx = ConsumerContext::new(
831            tx,
832            tokio_util::sync::CancellationToken::new(),
833            "timer-test-route".to_string(),
834        );
835
836        tokio::spawn(async move {
837            consumer.start(ctx).await.unwrap();
838        });
839
840        let envelope = tokio::time::timeout(Duration::from_secs(2), rx.recv())
841            .await
842            .expect("should receive exchange")
843            .expect("envelope should exist");
844
845        let exchange = envelope.exchange;
846
847        assert!(exchange.input.header("CamelTimerName").is_some());
848        assert!(exchange.input.header("CamelTimerCounter").is_some());
849        assert!(exchange.input.header("CamelTimerFiredTime").is_some());
850        assert!(exchange.input.header("CamelMessageTimestamp").is_some());
851    }
852
853    // TIMER-011: TimerEndpoint and TimerConsumer are pub
854    #[test]
855    fn test_timer_endpoint_is_pub() {
856        let component = TimerComponent::new();
857        let endpoint = component
858            .create_endpoint("timer:pub-test", &NoOpComponentContext)
859            .unwrap();
860        assert_eq!(endpoint.uri(), "timer:pub-test");
861    }
862
863    // -------------------------------------------------------------------
864    // Recording metrics collector for testing increment_errors calls
865    // (pattern: camel-direct tests::RecordingMetrics)
866    // -------------------------------------------------------------------
867
868    struct RecordingMetrics {
869        errors: Arc<Mutex<Vec<(String, String)>>>,
870    }
871
872    impl camel_api::MetricsCollector for RecordingMetrics {
873        fn record_exchange_duration(&self, _: &str, _: Duration) {}
874        fn increment_errors(&self, route_id: &str, error_type: &str) {
875            self.errors
876                .lock()
877                .unwrap()
878                .push((route_id.to_string(), error_type.to_string()));
879        }
880        fn increment_exchanges(&self, _: &str) {}
881        fn set_queue_depth(&self, _: &str, _: usize) {}
882        fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
883    }
884
885    struct RecordingRuntime {
886        metrics_collector: Arc<RecordingMetrics>,
887    }
888
889    impl RecordingRuntime {
890        fn new(errors: Arc<Mutex<Vec<(String, String)>>>) -> Self {
891            Self {
892                metrics_collector: Arc::new(RecordingMetrics { errors }),
893            }
894        }
895    }
896
897    impl camel_component_api::RuntimeObservability for RecordingRuntime {
898        fn metrics(&self) -> Arc<dyn camel_api::MetricsCollector> {
899            Arc::clone(&self.metrics_collector) as Arc<dyn camel_api::MetricsCollector>
900        }
901        fn health(&self) -> Arc<dyn camel_component_api::HealthCheckRegistry> {
902            panic!("RecordingRuntime::health not used in this test")
903        }
904    }
905
906    #[tokio::test]
907    async fn timer_fire_send_failure_counts_b_prime() {
908        let errors = Arc::new(Mutex::new(Vec::new()));
909        let component = TimerComponent::new();
910        let endpoint = component
911            .create_endpoint("timer:fire-send?period=20", &NoOpComponentContext)
912            .unwrap();
913
914        // Recording runtime through the same seam the other tests fill with
915        // the Panic helper (`rt()`).
916        let mut consumer = endpoint
917            .create_consumer(Arc::new(RecordingRuntime::new(Arc::clone(&errors))))
918            .unwrap();
919        // Receiver dropped: every fire send fails with `ChannelClosed`.
920        let (tx, _) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
921        let ctx = ConsumerContext::new(
922            tx,
923            tokio_util::sync::CancellationToken::new(),
924            "timer-test-route".to_string(),
925        );
926
927        // First tick fails the send; the consumer records it and exits its loop.
928        consumer.start(ctx).await.unwrap();
929
930        let recorded = errors.lock().unwrap().clone();
931        assert_eq!(
932            recorded,
933            vec![(
934                "timer-test-route".to_string(),
935                "b-prime:timer:fire-send".to_string()
936            )]
937        );
938
939        consumer.stop().await.unwrap();
940    }
941}