1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3
4use crate::CamelError;
5use crate::declarative::LanguageExpressionDef;
6use crate::splitter::StreamSplitConfig;
7
8pub const CANONICAL_CONTRACT_NAME: &str = "canonical-v1";
9pub const CANONICAL_CONTRACT_VERSION: u32 = 2;
10pub const CANONICAL_CONTRACT_SUPPORTED_STEPS: &[&str] = &[
11 "to",
12 "log",
13 "wire_tap",
14 "script",
15 "filter",
16 "choice",
17 "split",
18 "aggregate",
19 "stop",
20 "delay",
21 "cache",
22 "cache_invalidate",
23 "cache_peek_stale",
24];
25pub const CANONICAL_CONTRACT_DECLARATIVE_ONLY_STEPS: &[&str] =
26 &["script", "filter", "choice", "split"];
27pub const CANONICAL_CONTRACT_EXCLUDED_DECLARATIVE_STEPS: &[&str] = &[
28 "set_header",
29 "set_property",
30 "set_body",
31 "multicast",
32 "convert_body_to",
33 "bean",
34 "marshal",
35 "unmarshal",
36];
37pub const CANONICAL_CONTRACT_RUST_ONLY_STEPS: &[&str] = &[
38 "processor",
39 "process",
40 "process_fn",
41 "map_body",
42 "set_body_fn",
43 "set_header_fn",
44];
45
46pub fn canonical_contract_supports_step(step: &str) -> bool {
47 CANONICAL_CONTRACT_SUPPORTED_STEPS.contains(&step)
48}
49
50pub fn canonical_contract_rejection_reason(step: &str) -> Option<&'static str> {
51 if CANONICAL_CONTRACT_EXCLUDED_DECLARATIVE_STEPS.contains(&step) {
52 return Some(
53 "declared out-of-scope for canonical v2; use declarative route compilation path outside CQRS canonical commands",
54 );
55 }
56
57 if CANONICAL_CONTRACT_RUST_ONLY_STEPS.contains(&step) {
58 return Some("rust-only programmable step; not representable in canonical v2 contract");
59 }
60
61 if canonical_contract_supports_step(step)
62 && CANONICAL_CONTRACT_DECLARATIVE_ONLY_STEPS.contains(&step)
63 {
64 return Some(
65 "supported only as declarative/serializable expression form; closure/processor variants are outside canonical v2",
66 );
67 }
68
69 None
70}
71
72#[derive(
73 Debug,
74 Clone,
75 PartialEq,
76 Eq,
77 serde::Serialize,
78 serde::Deserialize,
79 schemars::JsonSchema,
80 ts_rs::TS,
81)]
82#[serde(rename_all = "snake_case")]
83#[ts(rename_all = "snake_case")]
84pub struct CanonicalRouteSpec {
85 pub route_id: String,
95 pub from: String,
96 pub steps: Vec<CanonicalStepSpec>,
97 pub circuit_breaker: Option<CanonicalCircuitBreakerSpec>,
98 pub auto_startup: Option<bool>,
99 pub startup_order: Option<i32>,
100 pub concurrency: Option<CanonicalConcurrencySpec>,
101 pub version: u32,
102}
103
104#[derive(
105 Debug,
106 Clone,
107 PartialEq,
108 Eq,
109 serde::Serialize,
110 serde::Deserialize,
111 schemars::JsonSchema,
112 ts_rs::TS,
113)]
114#[serde(tag = "step", content = "config", rename_all = "snake_case")]
115#[ts(rename_all = "snake_case")]
116#[non_exhaustive]
117pub enum CanonicalStepSpec {
118 To {
119 uri: String,
120 },
121 Log {
122 message: String,
123 },
124 WireTap {
125 uri: String,
126 },
127 Script {
128 expression: LanguageExpressionDef,
129 },
130 Filter {
131 predicate: LanguageExpressionDef,
132 steps: Vec<CanonicalStepSpec>,
133 },
134 Choice {
135 whens: Vec<CanonicalWhenSpec>,
136 otherwise: Option<Vec<CanonicalStepSpec>>,
137 },
138 Split {
139 expression: CanonicalSplitExpressionSpec,
140 aggregation: CanonicalSplitAggregationSpec,
141 parallel: bool,
142 parallel_limit: Option<usize>,
143 stop_on_exception: bool,
144 steps: Vec<CanonicalStepSpec>,
145 },
146 Aggregate(CanonicalAggregateSpec),
147 Stop,
148 Delay {
149 #[ts(type = "number")]
150 delay_ms: u64,
151 dynamic_header: Option<String>,
152 },
153 Cache {
154 repository: Option<String>,
155 key: String,
156 ttl: Option<String>,
157 max_entry_bytes: Option<usize>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
161 coalesce_misses: Option<bool>,
162 on_miss: Vec<CanonicalStepSpec>,
163 },
164 CacheInvalidate {
165 repository: Option<String>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 key: Option<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 key_prefix: Option<String>,
172 },
173 CacheClear {
174 repository: Option<String>,
175 },
176 CacheStats {
177 repository: Option<String>,
178 },
179 CachePeekStale {
180 repository: Option<String>,
181 key: String,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 on_miss: Option<String>,
185 },
186}
187
188#[derive(
189 Debug,
190 Clone,
191 PartialEq,
192 Eq,
193 serde::Serialize,
194 serde::Deserialize,
195 schemars::JsonSchema,
196 ts_rs::TS,
197)]
198#[serde(rename_all = "snake_case")]
199#[ts(rename_all = "snake_case")]
200pub struct CanonicalWhenSpec {
201 pub predicate: LanguageExpressionDef,
202 pub steps: Vec<CanonicalStepSpec>,
203}
204
205#[derive(
206 Debug,
207 Clone,
208 PartialEq,
209 Eq,
210 serde::Serialize,
211 serde::Deserialize,
212 schemars::JsonSchema,
213 ts_rs::TS,
214)]
215#[serde(rename_all = "snake_case")]
216#[ts(rename_all = "snake_case")]
217#[non_exhaustive]
218pub enum CanonicalSplitExpressionSpec {
219 BodyLines,
220 BodyJsonArray,
221 Language(LanguageExpressionDef),
222 Stream(StreamSplitConfig),
223}
224
225#[derive(
226 Debug,
227 Clone,
228 PartialEq,
229 Eq,
230 serde::Serialize,
231 serde::Deserialize,
232 schemars::JsonSchema,
233 ts_rs::TS,
234)]
235#[serde(rename_all = "snake_case")]
236#[ts(rename_all = "snake_case")]
237#[non_exhaustive]
238pub enum CanonicalSplitAggregationSpec {
239 LastWins,
240 CollectAll,
241 Original,
242}
243
244#[derive(
245 Debug,
246 Clone,
247 PartialEq,
248 Eq,
249 serde::Serialize,
250 serde::Deserialize,
251 schemars::JsonSchema,
252 ts_rs::TS,
253)]
254#[serde(rename_all = "snake_case")]
255#[ts(rename_all = "snake_case")]
256#[non_exhaustive]
257pub enum CanonicalAggregateStrategySpec {
258 CollectAll,
259}
260
261#[derive(
262 Debug,
263 Clone,
264 PartialEq,
265 Eq,
266 serde::Serialize,
267 serde::Deserialize,
268 schemars::JsonSchema,
269 ts_rs::TS,
270)]
271#[serde(rename_all = "snake_case")]
272#[ts(rename_all = "snake_case")]
273pub struct CanonicalAggregateSpec {
274 pub header: String,
275 pub completion_size: Option<usize>,
276 #[ts(type = "number")]
277 pub completion_timeout_ms: Option<u64>,
278 pub correlation_key: Option<String>,
279 pub force_completion_on_stop: Option<bool>,
280 pub discard_on_timeout: Option<bool>,
281 pub strategy: CanonicalAggregateStrategySpec,
282 pub max_buckets: Option<usize>,
283 #[ts(type = "number")]
284 pub bucket_ttl_ms: Option<u64>,
285 #[serde(default)]
289 pub completion_predicate: Option<LanguageExpressionDef>,
290}
291
292#[derive(
293 Debug,
294 Clone,
295 PartialEq,
296 Eq,
297 serde::Serialize,
298 serde::Deserialize,
299 schemars::JsonSchema,
300 ts_rs::TS,
301)]
302#[serde(rename_all = "snake_case")]
303#[ts(rename_all = "snake_case")]
304pub struct CanonicalCircuitBreakerSpec {
305 pub failure_threshold: u32,
306 #[ts(type = "number")]
307 pub open_duration_ms: u64,
308 #[serde(default, skip_serializing_if = "Vec::is_empty")]
309 pub fallback: Vec<CanonicalStepSpec>,
310}
311
312#[derive(
313 Debug,
314 Clone,
315 PartialEq,
316 Eq,
317 serde::Serialize,
318 serde::Deserialize,
319 schemars::JsonSchema,
320 ts_rs::TS,
321)]
322#[serde(tag = "mode", rename_all = "snake_case")]
323#[non_exhaustive]
324pub enum CanonicalConcurrencySpec {
325 Sequential,
326 Concurrent { max: usize },
327}
328
329impl CanonicalRouteSpec {
330 pub fn new(route_id: impl Into<String>, from: impl Into<String>) -> Self {
331 Self {
332 route_id: route_id.into(),
333 from: from.into(),
334 steps: Vec::new(),
335 circuit_breaker: None,
336 auto_startup: None,
337 startup_order: None,
338 concurrency: None,
339 version: CANONICAL_CONTRACT_VERSION,
340 }
341 }
342
343 pub fn with_auto_startup(mut self, auto: bool) -> Self {
344 self.auto_startup = Some(auto);
345 self
346 }
347
348 pub fn with_startup_order(mut self, order: i32) -> Self {
349 self.startup_order = Some(order);
350 self
351 }
352
353 pub fn with_concurrency(mut self, concurrency: CanonicalConcurrencySpec) -> Self {
354 self.concurrency = Some(concurrency);
355 self
356 }
357
358 pub fn validate_contract(&self) -> Result<(), CamelError> {
359 if self.route_id.trim().is_empty() {
360 return Err(CamelError::RouteError(
361 "canonical contract violation: route_id cannot be empty".to_string(),
362 ));
363 }
364 if self.from.trim().is_empty() {
365 return Err(CamelError::RouteError(
366 "canonical contract violation: from cannot be empty".to_string(),
367 ));
368 }
369 if self.version == 0 || self.version > CANONICAL_CONTRACT_VERSION {
370 return Err(CamelError::RouteError(format!(
371 "canonical contract violation: expected version {}, got {}",
372 CANONICAL_CONTRACT_VERSION, self.version
373 )));
374 }
375 validate_steps(&self.steps)?;
376 if let Some(cb) = &self.circuit_breaker {
377 if cb.failure_threshold == 0 {
378 return Err(CamelError::RouteError(
379 "canonical contract violation: circuit_breaker.failure_threshold must be > 0"
380 .to_string(),
381 ));
382 }
383 if cb.open_duration_ms == 0 {
384 return Err(CamelError::RouteError(
385 "canonical contract violation: circuit_breaker.open_duration_ms must be > 0"
386 .to_string(),
387 ));
388 }
389 validate_steps(&cb.fallback)?;
390 }
391 if let Some(CanonicalConcurrencySpec::Concurrent { max: 0 }) = &self.concurrency {
392 return Err(CamelError::RouteError(
393 "canonical contract violation: concurrency max must be > 0".to_string(),
394 ));
395 }
396 Ok(())
397 }
398}
399
400#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
401pub struct CanonicalFieldLoss {
402 pub field: &'static str,
403 pub reason: String,
404 pub target_version: u32,
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
408pub struct CanonicalLossReport {
409 pub dropped_fields: Vec<CanonicalFieldLoss>,
410}
411
412impl CanonicalLossReport {
413 pub fn from_field(field: &'static str, reason: &str, target_version: u32) -> Self {
414 Self {
415 dropped_fields: vec![CanonicalFieldLoss {
416 field,
417 reason: reason.to_string(),
418 target_version,
419 }],
420 }
421 }
422
423 pub fn is_empty(&self) -> bool {
424 self.dropped_fields.is_empty()
425 }
426}
427
428fn validate_steps(steps: &[CanonicalStepSpec]) -> Result<(), CamelError> {
429 for step in steps {
430 match step {
431 CanonicalStepSpec::To { uri } | CanonicalStepSpec::WireTap { uri } => {
432 if uri.trim().is_empty() {
433 return Err(CamelError::RouteError(
434 "canonical contract violation: endpoint uri cannot be empty".to_string(),
435 ));
436 }
437 }
438 CanonicalStepSpec::Filter { steps, .. } => validate_steps(steps)?,
439 CanonicalStepSpec::Choice { whens, otherwise } => {
440 for when in whens {
441 validate_steps(&when.steps)?;
442 }
443 if let Some(otherwise) = otherwise {
444 validate_steps(otherwise)?;
445 }
446 }
447 CanonicalStepSpec::Split {
448 parallel_limit,
449 steps,
450 ..
451 } => {
452 if let Some(limit) = parallel_limit
453 && *limit == 0
454 {
455 return Err(CamelError::RouteError(
456 "canonical contract violation: split.parallel_limit must be > 0"
457 .to_string(),
458 ));
459 }
460 validate_steps(steps)?;
461 }
462 CanonicalStepSpec::Aggregate(config) => {
463 if config.header.trim().is_empty() {
464 return Err(CamelError::RouteError(
465 "canonical contract violation: aggregate.header cannot be empty"
466 .to_string(),
467 ));
468 }
469 if let Some(size) = config.completion_size
470 && size == 0
471 {
472 return Err(CamelError::RouteError(
473 "canonical contract violation: aggregate.completion_size must be > 0"
474 .to_string(),
475 ));
476 }
477 }
478 CanonicalStepSpec::Cache { on_miss, .. } => {
479 validate_steps(on_miss)?;
480 }
481 CanonicalStepSpec::Log { .. }
482 | CanonicalStepSpec::Script { .. }
483 | CanonicalStepSpec::Stop
484 | CanonicalStepSpec::Delay { .. }
485 | CanonicalStepSpec::CacheInvalidate { .. }
486 | CanonicalStepSpec::CacheClear { .. }
487 | CanonicalStepSpec::CacheStats { .. }
488 | CanonicalStepSpec::CachePeekStale { .. } => {}
489 }
490 }
491 Ok(())
492}
493
494#[derive(Debug, Clone, PartialEq, Eq)]
495#[non_exhaustive]
496pub enum RuntimeCommand {
497 RegisterRoute {
498 spec: CanonicalRouteSpec,
499 command_id: String,
500 causation_id: Option<String>,
501 },
502 StartRoute {
503 route_id: String,
504 command_id: String,
505 causation_id: Option<String>,
506 },
507 StopRoute {
508 route_id: String,
509 command_id: String,
510 causation_id: Option<String>,
511 },
512 SuspendRoute {
513 route_id: String,
514 command_id: String,
515 causation_id: Option<String>,
516 },
517 ResumeRoute {
518 route_id: String,
519 command_id: String,
520 causation_id: Option<String>,
521 },
522 ReloadRoute {
523 route_id: String,
524 command_id: String,
525 causation_id: Option<String>,
526 },
527 FailRoute {
531 route_id: String,
532 error: String,
533 command_id: String,
534 causation_id: Option<String>,
535 },
536 RemoveRoute {
537 route_id: String,
538 command_id: String,
539 causation_id: Option<String>,
540 },
541 ReloadTlsCerts {
542 scheme: String,
543 host: String,
544 port: u16,
545 command_id: String,
546 causation_id: Option<String>,
547 },
548 ReloadTemplates {
555 route_id: String,
556 command_id: String,
557 causation_id: Option<String>,
558 },
559}
560
561impl RuntimeCommand {
562 pub fn command_id(&self) -> &str {
563 match self {
564 RuntimeCommand::RegisterRoute { command_id, .. }
565 | RuntimeCommand::StartRoute { command_id, .. }
566 | RuntimeCommand::StopRoute { command_id, .. }
567 | RuntimeCommand::SuspendRoute { command_id, .. }
568 | RuntimeCommand::ResumeRoute { command_id, .. }
569 | RuntimeCommand::ReloadRoute { command_id, .. }
570 | RuntimeCommand::FailRoute { command_id, .. }
571 | RuntimeCommand::RemoveRoute { command_id, .. }
572 | RuntimeCommand::ReloadTlsCerts { command_id, .. }
573 | RuntimeCommand::ReloadTemplates { command_id, .. } => command_id,
574 }
575 }
576
577 pub fn causation_id(&self) -> Option<&str> {
578 match self {
579 RuntimeCommand::RegisterRoute { causation_id, .. }
580 | RuntimeCommand::StartRoute { causation_id, .. }
581 | RuntimeCommand::StopRoute { causation_id, .. }
582 | RuntimeCommand::SuspendRoute { causation_id, .. }
583 | RuntimeCommand::ResumeRoute { causation_id, .. }
584 | RuntimeCommand::ReloadRoute { causation_id, .. }
585 | RuntimeCommand::FailRoute { causation_id, .. }
586 | RuntimeCommand::RemoveRoute { causation_id, .. }
587 | RuntimeCommand::ReloadTlsCerts { causation_id, .. }
588 | RuntimeCommand::ReloadTemplates { causation_id, .. } => causation_id.as_deref(),
589 }
590 }
591}
592
593#[derive(Debug, Clone, PartialEq, Eq)]
594#[non_exhaustive]
595pub enum RuntimeCommandResult {
596 Accepted,
597 Duplicate {
598 command_id: String,
599 },
600 RouteRegistered {
601 route_id: String,
602 },
603 RouteStateChanged {
604 route_id: String,
605 status: String,
606 },
607 TlsCertsReloaded {
608 scheme: String,
609 host: String,
610 port: u16,
611 },
612 TemplatesReloaded {
613 route_id: String,
614 },
615}
616
617#[derive(Debug, Clone, PartialEq, Eq)]
618#[non_exhaustive]
619pub enum RuntimeQuery {
620 GetRouteStatus {
621 route_id: String,
622 },
623 InFlightCount {
627 route_id: String,
628 },
629 ListRoutes,
630}
631
632#[derive(Debug, Clone, PartialEq, Eq)]
633#[non_exhaustive]
634pub enum RuntimeQueryResult {
635 InFlightCount { route_id: String, count: u64 },
636 RouteNotFound { route_id: String },
637 RouteStatus { route_id: String, status: String },
638 Routes { route_ids: Vec<String> },
639}
640
641#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
642#[non_exhaustive]
643pub enum RuntimeEvent {
644 RouteRegistered { route_id: String },
645 RouteStartRequested { route_id: String },
646 RouteStarted { route_id: String },
647 RouteFailed { route_id: String, error: String },
648 RouteStopped { route_id: String },
649 RouteSuspended { route_id: String },
650 RouteResumed { route_id: String },
651 RouteReloaded { route_id: String },
652 RouteRemoved { route_id: String },
653}
654
655#[async_trait]
656pub trait RuntimeCommandBus: Send + Sync {
657 async fn execute(&self, cmd: RuntimeCommand) -> Result<RuntimeCommandResult, CamelError>;
658}
659
660#[async_trait]
661pub trait RuntimeQueryBus: Send + Sync {
662 async fn ask(&self, query: RuntimeQuery) -> Result<RuntimeQueryResult, CamelError>;
663}
664
665pub trait RuntimeHandle: RuntimeCommandBus + RuntimeQueryBus {}
666
667impl<T> RuntimeHandle for T where T: RuntimeCommandBus + RuntimeQueryBus {}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use async_trait::async_trait;
673 use futures::executor::block_on;
674
675 struct NoopRuntime;
676
677 #[async_trait]
678 impl RuntimeCommandBus for NoopRuntime {
679 async fn execute(&self, cmd: RuntimeCommand) -> Result<RuntimeCommandResult, CamelError> {
680 Ok(match cmd {
681 RuntimeCommand::RegisterRoute { spec, .. } => {
682 RuntimeCommandResult::RouteRegistered {
683 route_id: spec.route_id,
684 }
685 }
686 RuntimeCommand::StartRoute { route_id, .. }
687 | RuntimeCommand::StopRoute { route_id, .. }
688 | RuntimeCommand::SuspendRoute { route_id, .. }
689 | RuntimeCommand::ResumeRoute { route_id, .. }
690 | RuntimeCommand::ReloadRoute { route_id, .. }
691 | RuntimeCommand::FailRoute { route_id, .. }
692 | RuntimeCommand::RemoveRoute { route_id, .. } => {
693 RuntimeCommandResult::RouteStateChanged {
694 route_id,
695 status: "ok".to_string(),
696 }
697 }
698 RuntimeCommand::ReloadTlsCerts {
699 scheme, host, port, ..
700 } => RuntimeCommandResult::TlsCertsReloaded { scheme, host, port },
701 RuntimeCommand::ReloadTemplates { route_id, .. } => {
702 RuntimeCommandResult::TemplatesReloaded { route_id }
703 }
704 })
705 }
706 }
707
708 #[async_trait]
709 impl RuntimeQueryBus for NoopRuntime {
710 async fn ask(&self, query: RuntimeQuery) -> Result<RuntimeQueryResult, CamelError> {
711 Ok(match query {
712 RuntimeQuery::GetRouteStatus { route_id } => RuntimeQueryResult::RouteStatus {
713 route_id,
714 status: "Started".to_string(),
715 },
716 RuntimeQuery::InFlightCount { route_id } => {
717 RuntimeQueryResult::InFlightCount { route_id, count: 0 }
718 }
719 RuntimeQuery::ListRoutes => RuntimeQueryResult::Routes {
720 route_ids: vec!["r1".to_string()],
721 },
722 })
723 }
724 }
725
726 #[test]
727 fn command_and_query_ids_are_exposed() {
728 let cmd = RuntimeCommand::StartRoute {
729 route_id: "r1".into(),
730 command_id: "c1".into(),
731 causation_id: None,
732 };
733 assert_eq!(cmd.command_id(), "c1");
734 }
735
736 #[test]
737 fn canonical_spec_requires_route_id_and_from() {
738 let spec = CanonicalRouteSpec::new("r1", "timer:tick");
739 assert_eq!(spec.route_id, "r1");
740 assert_eq!(spec.from, "timer:tick");
741 assert_eq!(spec.version, CANONICAL_CONTRACT_VERSION);
742 assert!(spec.steps.is_empty());
743 assert!(spec.circuit_breaker.is_none());
744 }
745
746 #[test]
747 fn canonical_contract_rejects_invalid_version() {
748 let mut spec = CanonicalRouteSpec::new("r1", "timer:tick");
749 spec.version = 3;
750 let err = spec.validate_contract().unwrap_err().to_string();
751 assert!(err.contains("expected version"));
752 }
753
754 #[test]
755 fn canonical_contract_declares_subset_scope() {
756 assert!(canonical_contract_supports_step("to"));
757 assert!(canonical_contract_supports_step("split"));
758 assert!(!canonical_contract_supports_step("set_header"));
759 assert!(!canonical_contract_supports_step("set_property"));
760
761 assert!(CANONICAL_CONTRACT_DECLARATIVE_ONLY_STEPS.contains(&"split"));
762 assert!(CANONICAL_CONTRACT_EXCLUDED_DECLARATIVE_STEPS.contains(&"set_header"));
763 assert!(CANONICAL_CONTRACT_EXCLUDED_DECLARATIVE_STEPS.contains(&"set_property"));
764 assert!(CANONICAL_CONTRACT_RUST_ONLY_STEPS.contains(&"processor"));
765 }
766
767 #[test]
768 fn canonical_contract_rejection_reason_is_explicit() {
769 let set_header_reason = canonical_contract_rejection_reason("set_header")
770 .expect("set_header should have explicit reason");
771 assert!(set_header_reason.contains("out-of-scope"));
772
773 let set_property_reason = canonical_contract_rejection_reason("set_property")
774 .expect("set_property should have explicit reason");
775 assert!(set_property_reason.contains("out-of-scope"));
776
777 let processor_reason = canonical_contract_rejection_reason("processor")
778 .expect("processor should be rust-only");
779 assert!(processor_reason.contains("rust-only"));
780
781 let split_reason = canonical_contract_rejection_reason("split")
782 .expect("split should require declarative form");
783 assert!(split_reason.contains("declarative"));
784 }
785
786 #[test]
787 fn command_causation_id_is_exposed() {
788 let cmd = RuntimeCommand::StopRoute {
789 route_id: "r1".into(),
790 command_id: "c2".into(),
791 causation_id: Some("c1".into()),
792 };
793 assert_eq!(cmd.command_id(), "c2");
794 assert_eq!(cmd.causation_id(), Some("c1"));
795 }
796
797 #[test]
798 fn canonical_contract_rejects_empty_route_id_and_from() {
799 let spec = CanonicalRouteSpec::new(" ", "timer:tick");
800 let err = spec.validate_contract().unwrap_err().to_string();
801 assert!(err.contains("route_id cannot be empty"));
802
803 let spec = CanonicalRouteSpec::new("r1", " ");
804 let err = spec.validate_contract().unwrap_err().to_string();
805 assert!(err.contains("from cannot be empty"));
806 }
807
808 #[test]
809 fn canonical_contract_rejects_invalid_nested_steps() {
810 let mut spec = CanonicalRouteSpec::new("r1", "timer:tick");
811 spec.steps = vec![CanonicalStepSpec::Split {
812 expression: CanonicalSplitExpressionSpec::BodyLines,
813 aggregation: CanonicalSplitAggregationSpec::CollectAll,
814 parallel: true,
815 parallel_limit: Some(0),
816 stop_on_exception: false,
817 steps: vec![CanonicalStepSpec::To {
818 uri: "log:ok".to_string(),
819 }],
820 }];
821 let err = spec.validate_contract().unwrap_err().to_string();
822 assert!(err.contains("split.parallel_limit must be > 0"));
823
824 spec.steps = vec![CanonicalStepSpec::To {
825 uri: " ".to_string(),
826 }];
827 let err = spec.validate_contract().unwrap_err().to_string();
828 assert!(err.contains("endpoint uri cannot be empty"));
829 }
830
831 #[test]
832 fn canonical_contract_rejects_invalid_aggregate_and_circuit_breaker() {
833 let mut spec = CanonicalRouteSpec::new("r1", "timer:tick");
834 spec.steps = vec![CanonicalStepSpec::Aggregate(CanonicalAggregateSpec {
835 header: " ".to_string(),
836 completion_size: Some(1),
837 completion_timeout_ms: None,
838 correlation_key: None,
839 force_completion_on_stop: None,
840 discard_on_timeout: None,
841 strategy: CanonicalAggregateStrategySpec::CollectAll,
842 max_buckets: None,
843 bucket_ttl_ms: None,
844 completion_predicate: None,
845 })];
846 let err = spec.validate_contract().unwrap_err().to_string();
847 assert!(err.contains("aggregate.header cannot be empty"));
848
849 spec.steps = vec![CanonicalStepSpec::Aggregate(CanonicalAggregateSpec {
850 header: "k".to_string(),
851 completion_size: Some(0),
852 completion_timeout_ms: None,
853 correlation_key: None,
854 force_completion_on_stop: None,
855 discard_on_timeout: None,
856 strategy: CanonicalAggregateStrategySpec::CollectAll,
857 max_buckets: None,
858 bucket_ttl_ms: None,
859 completion_predicate: None,
860 })];
861 let err = spec.validate_contract().unwrap_err().to_string();
862 assert!(err.contains("aggregate.completion_size must be > 0"));
863
864 spec.steps = vec![];
865 spec.circuit_breaker = Some(CanonicalCircuitBreakerSpec {
866 failure_threshold: 0,
867 open_duration_ms: 10,
868 fallback: vec![],
869 });
870 let err = spec.validate_contract().unwrap_err().to_string();
871 assert!(err.contains("failure_threshold must be > 0"));
872
873 spec.circuit_breaker = Some(CanonicalCircuitBreakerSpec {
874 failure_threshold: 1,
875 open_duration_ms: 0,
876 fallback: vec![],
877 });
878 let err = spec.validate_contract().unwrap_err().to_string();
879 assert!(err.contains("open_duration_ms must be > 0"));
880 }
881
882 #[test]
883 fn canonical_contract_rejects_invalid_fallback_step() {
884 let mut spec = CanonicalRouteSpec::new("r1", "timer:tick");
885 spec.circuit_breaker = Some(CanonicalCircuitBreakerSpec {
886 failure_threshold: 1,
887 open_duration_ms: 10,
888 fallback: vec![CanonicalStepSpec::To {
889 uri: " ".to_string(),
890 }],
891 });
892 let err = spec.validate_contract().unwrap_err().to_string();
893 assert!(err.contains("endpoint uri cannot be empty"));
894 }
895
896 #[test]
897 fn canonical_contract_rejection_reason_none_for_regular_steps() {
898 assert!(canonical_contract_rejection_reason("to").is_none());
899 assert!(canonical_contract_rejection_reason("unknown-step").is_none());
900 }
901
902 #[test]
903 fn command_helpers_cover_all_variants() {
904 let spec = CanonicalRouteSpec::new("r1", "timer:tick");
905 let cmds = [
906 RuntimeCommand::RegisterRoute {
907 spec,
908 command_id: "c1".into(),
909 causation_id: Some("root".into()),
910 },
911 RuntimeCommand::StartRoute {
912 route_id: "r1".into(),
913 command_id: "c2".into(),
914 causation_id: None,
915 },
916 RuntimeCommand::StopRoute {
917 route_id: "r1".into(),
918 command_id: "c3".into(),
919 causation_id: None,
920 },
921 RuntimeCommand::SuspendRoute {
922 route_id: "r1".into(),
923 command_id: "c4".into(),
924 causation_id: None,
925 },
926 RuntimeCommand::ResumeRoute {
927 route_id: "r1".into(),
928 command_id: "c5".into(),
929 causation_id: None,
930 },
931 RuntimeCommand::ReloadRoute {
932 route_id: "r1".into(),
933 command_id: "c6".into(),
934 causation_id: None,
935 },
936 RuntimeCommand::FailRoute {
937 route_id: "r1".into(),
938 error: "boom".into(),
939 command_id: "c7".into(),
940 causation_id: None,
941 },
942 RuntimeCommand::RemoveRoute {
943 route_id: "r1".into(),
944 command_id: "c8".into(),
945 causation_id: None,
946 },
947 RuntimeCommand::ReloadTlsCerts {
948 scheme: "https".into(),
949 host: "example.com".into(),
950 port: 8443,
951 command_id: "c9".into(),
952 causation_id: None,
953 },
954 ];
955
956 let ids: Vec<&str> = cmds.iter().map(RuntimeCommand::command_id).collect();
957 assert_eq!(
958 ids,
959 vec!["c1", "c2", "c3", "c4", "c5", "c6", "c7", "c8", "c9"]
960 );
961 assert_eq!(cmds[0].causation_id(), Some("root"));
962 assert_eq!(cmds[1].causation_id(), None);
963 }
964
965 #[test]
966 fn canonical_route_spec_serde_roundtrip() {
967 let mut spec = CanonicalRouteSpec::new("test-route", "timer:tick?period=1000");
968 spec.steps.push(CanonicalStepSpec::Log {
969 message: "Hello".into(),
970 });
971 spec.steps.push(CanonicalStepSpec::To {
972 uri: "log:info".into(),
973 });
974 spec.steps.push(CanonicalStepSpec::Stop);
975
976 let json = serde_json::to_string(&spec).unwrap();
977 let deserialized: CanonicalRouteSpec = serde_json::from_str(&json).unwrap();
978 assert_eq!(spec, deserialized);
979 }
980
981 #[test]
982 fn canonical_step_spec_serde_variants() {
983 let steps = vec![
984 CanonicalStepSpec::To {
985 uri: "direct:a".into(),
986 },
987 CanonicalStepSpec::Log {
988 message: "msg".into(),
989 },
990 CanonicalStepSpec::WireTap {
991 uri: "direct:audit".into(),
992 },
993 CanonicalStepSpec::Stop,
994 CanonicalStepSpec::Delay {
995 delay_ms: 100,
996 dynamic_header: None,
997 },
998 ];
999 let json = serde_json::to_string_pretty(&steps).unwrap();
1000 let back: Vec<CanonicalStepSpec> = serde_json::from_str(&json).unwrap();
1001 assert_eq!(steps, back);
1002 }
1003
1004 #[test]
1005 fn canonical_cache_peek_stale_on_miss_round_trip() {
1006 let some = CanonicalStepSpec::CachePeekStale {
1007 repository: None,
1008 key: "k".into(),
1009 on_miss: Some("continue".into()),
1010 };
1011 let json = serde_json::to_string(&some).unwrap();
1012 assert!(json.contains("continue"));
1013 let back: CanonicalStepSpec = serde_json::from_str(&json).unwrap();
1014 assert_eq!(some, back);
1015
1016 let none = CanonicalStepSpec::CachePeekStale {
1017 repository: None,
1018 key: "k".into(),
1019 on_miss: None,
1020 };
1021 let json = serde_json::to_string(&none).unwrap();
1022 assert!(
1023 !json.contains("on_miss"),
1024 "on_miss: None must omit the key from JSON, got: {json}"
1025 );
1026 let back: CanonicalStepSpec = serde_json::from_str(&json).unwrap();
1027 assert_eq!(none, back);
1028 }
1029
1030 #[test]
1031 fn canonical_cache_invalidate_prefix_round_trip() {
1032 let prefixed = CanonicalStepSpec::CacheInvalidate {
1033 repository: Some("persistent".into()),
1034 key: None,
1035 key_prefix: Some("ns:".into()),
1036 };
1037 let json = serde_json::to_string(&prefixed).unwrap();
1038 assert!(json.contains("key_prefix"), "must emit key_prefix: {json}");
1039 let back: CanonicalStepSpec = serde_json::from_str(&json).unwrap();
1040 assert_eq!(prefixed, back);
1041
1042 let legacy = r#"{"step":"cache_invalidate","config":{"repository":null,"key":"k"}}"#;
1045 let parsed: CanonicalStepSpec = serde_json::from_str(legacy).unwrap();
1046 assert_eq!(
1047 parsed,
1048 CanonicalStepSpec::CacheInvalidate {
1049 repository: None,
1050 key: Some("k".into()),
1051 key_prefix: None,
1052 }
1053 );
1054 }
1055
1056 #[test]
1057 fn canonical_cache_clear_stats_round_trip() {
1058 let clear = CanonicalStepSpec::CacheClear {
1059 repository: Some("persistent".into()),
1060 };
1061 let json = serde_json::to_string(&clear).unwrap();
1062 assert!(json.contains("cache_clear"));
1063 let back: CanonicalStepSpec = serde_json::from_str(&json).unwrap();
1064 assert_eq!(clear, back);
1065
1066 let stats = CanonicalStepSpec::CacheStats { repository: None };
1067 let json = serde_json::to_string(&stats).unwrap();
1068 assert!(json.contains("cache_stats"));
1069 let back: CanonicalStepSpec = serde_json::from_str(&json).unwrap();
1070 assert_eq!(stats, back);
1071 }
1072
1073 #[test]
1074 fn canonical_circuit_breaker_fallback_roundtrip() {
1075 let mut spec = CanonicalRouteSpec::new("cb-fallback", "direct:start");
1076 spec.circuit_breaker = Some(CanonicalCircuitBreakerSpec {
1077 failure_threshold: 1,
1078 open_duration_ms: 60000,
1079 fallback: vec![CanonicalStepSpec::CachePeekStale {
1080 repository: Some("persistent".into()),
1081 key: "tile-xyz".into(),
1082 on_miss: None,
1083 }],
1084 });
1085
1086 let json = serde_json::to_string(&spec).unwrap();
1087 let back: CanonicalRouteSpec = serde_json::from_str(&json).unwrap();
1088 assert_eq!(spec, back);
1089 assert_eq!(back.circuit_breaker.as_ref().unwrap().fallback.len(), 1);
1090
1091 let without = CanonicalCircuitBreakerSpec {
1093 failure_threshold: 1,
1094 open_duration_ms: 60000,
1095 fallback: vec![],
1096 };
1097 let json = serde_json::to_string(&without).unwrap();
1098 assert!(
1099 !json.contains("fallback"),
1100 "empty fallback must omit the key from JSON, got: {json}"
1101 );
1102 let back: CanonicalCircuitBreakerSpec = serde_json::from_str(&json).unwrap();
1103 assert!(back.fallback.is_empty());
1104 }
1105
1106 #[test]
1107 fn canonical_route_spec_json_schema_generates() {
1108 let schema = schemars::schema_for!(CanonicalRouteSpec);
1109 let json = serde_json::to_string(&schema).unwrap();
1110 assert!(json.contains("CanonicalRouteSpec"));
1111 assert!(json.contains("route_id"));
1112 }
1113
1114 #[test]
1115 fn canonical_json_schema_has_no_function_step() {
1116 let schema = schemars::schema_for!(CanonicalRouteSpec);
1117 let json = serde_json::to_string(&schema).unwrap();
1118 assert!(
1119 !json.contains("\"function\""),
1120 "canonical JSON schema must not contain 'function' step"
1121 );
1122 }
1123
1124 #[test]
1125 fn canonical_contract_does_not_support_function() {
1126 assert!(
1127 !canonical_contract_supports_step("function"),
1128 "function must not be in CANONICAL_CONTRACT_SUPPORTED_STEPS"
1129 );
1130 }
1131
1132 #[test]
1133 fn runtime_command_result_all_variants_are_distinct() {
1134 let accepted = RuntimeCommandResult::Accepted;
1135 let dup = RuntimeCommandResult::Duplicate {
1136 command_id: "c1".into(),
1137 };
1138 let registered = RuntimeCommandResult::RouteRegistered {
1139 route_id: "r1".into(),
1140 };
1141 let changed = RuntimeCommandResult::RouteStateChanged {
1142 route_id: "r1".into(),
1143 status: "Started".into(),
1144 };
1145
1146 assert_ne!(accepted, dup);
1147 assert_ne!(dup, registered);
1148 assert_ne!(registered, changed);
1149
1150 let dup2 = RuntimeCommandResult::Duplicate {
1151 command_id: "c1".into(),
1152 };
1153 assert_eq!(dup, dup2);
1154 }
1155
1156 #[test]
1157 fn runtime_event_serialization_round_trip() {
1158 let event = RuntimeEvent::RouteFailed {
1159 route_id: "route-a".to_string(),
1160 error: "boom".to_string(),
1161 };
1162 let json = serde_json::to_string(&event).unwrap();
1163 let back: RuntimeEvent = serde_json::from_str(&json).unwrap();
1164 assert_eq!(event, back);
1165 }
1166
1167 #[test]
1168 fn noop_runtime_execute_and_ask_return_expected_shapes() {
1169 let rt = NoopRuntime;
1170 let cmd = RuntimeCommand::RegisterRoute {
1171 spec: CanonicalRouteSpec::new("r2", "timer:tick"),
1172 command_id: "c1".into(),
1173 causation_id: None,
1174 };
1175 let cmd_result = block_on(rt.execute(cmd)).unwrap();
1176 assert_eq!(
1177 cmd_result,
1178 RuntimeCommandResult::RouteRegistered {
1179 route_id: "r2".into()
1180 }
1181 );
1182
1183 let query_result = block_on(rt.ask(RuntimeQuery::GetRouteStatus {
1184 route_id: "r2".into(),
1185 }))
1186 .unwrap();
1187 assert_eq!(
1188 query_result,
1189 RuntimeQueryResult::RouteStatus {
1190 route_id: "r2".into(),
1191 status: "Started".into()
1192 }
1193 );
1194 }
1195
1196 #[test]
1197 fn canonical_contract_name_and_version_constants_match() {
1198 assert_eq!(CANONICAL_CONTRACT_NAME, "canonical-v1");
1199 assert_eq!(CANONICAL_CONTRACT_VERSION, 2);
1200 }
1201
1202 #[test]
1203 fn canonical_concurrency_spec_rejects_zero_max() {
1204 let spec = CanonicalRouteSpec::new("r1", "timer:tick")
1205 .with_concurrency(CanonicalConcurrencySpec::Concurrent { max: 0 });
1206 let err = spec.validate_contract().unwrap_err().to_string();
1207 assert!(err.contains("concurrency max must be > 0"), "{err}");
1208 }
1209
1210 #[test]
1211 fn canonical_v2_round_trip() {
1212 let spec = CanonicalRouteSpec::new("r1", "timer:tick")
1213 .with_auto_startup(false)
1214 .with_startup_order(42)
1215 .with_concurrency(CanonicalConcurrencySpec::Concurrent { max: 8 });
1216 spec.validate_contract().unwrap();
1217 }
1218
1219 #[test]
1220 fn canonical_v2_version_is_2() {
1221 assert_eq!(CANONICAL_CONTRACT_VERSION, 2);
1222 }
1223
1224 #[test]
1225 fn canonical_loss_report_builder() {
1226 let report =
1227 CanonicalLossReport::from_field("error_handler", "not supported by canonical path", 2);
1228 assert_eq!(report.dropped_fields.len(), 1);
1229 assert_eq!(report.dropped_fields[0].field, "error_handler");
1230 }
1231
1232 #[test]
1233 fn canonical_v1_json_deserializes_in_v2() {
1234 let json = r#"{"route_id":"r1","from":"timer:tick","steps":[],"version":1}"#;
1235 let spec: CanonicalRouteSpec = serde_json::from_str(json).unwrap();
1236 assert_eq!(spec.route_id, "r1");
1237 assert!(spec.auto_startup.is_none());
1238 assert!(spec.startup_order.is_none());
1239 assert!(spec.concurrency.is_none());
1240 spec.validate_contract().unwrap();
1242 }
1243}