1use camel_api::template::{RouteTemplateSpec, TemplateError, TemplatedRouteSpec};
4use camel_core::route::RouteDefinition;
5use glob::glob;
6use std::collections::hash_map::DefaultHasher;
7use std::collections::{HashMap, HashSet};
8use std::fs;
9use std::hash::{Hash, Hasher};
10use std::io;
11use std::path::Path;
12
13use crate::env_interpolation::interpolate_env;
14use crate::json::{parse_json, parse_json_with_threshold_and_security};
15use crate::model::SecurityCompileContext;
16use crate::template::materializer::materialize_and_compile;
17use crate::yaml::{parse_yaml, parse_yaml_with_threshold_and_security};
18
19#[derive(Debug, thiserror::Error)]
21#[non_exhaustive]
22pub enum DiscoveryError {
23 #[error("Glob pattern error: {0}")]
25 GlobPattern(#[from] glob::PatternError),
26
27 #[error("Glob error accessing {path}: {source}")]
29 GlobAccess { path: String, source: io::Error },
30
31 #[error("IO error reading {path}: {source}")]
33 Io { path: String, source: io::Error },
34
35 #[error("YAML parse error in {path}: {error}")]
37 Yaml { path: String, error: String },
38
39 #[error("Environment variable '{var_name}' not set (required by {path})")]
41 Env { path: String, var_name: String },
42
43 #[error("JSON parse error in {path}: {error}")]
45 Json { path: String, error: String },
46
47 #[error("Unsupported file extension '{extension}' in {path}")]
49 UnsupportedExtension { path: String, extension: String },
50
51 #[error(
53 "JSON file {path} matched by broad pattern '{pattern}' — use an explicit .json glob like 'routes/*.json'"
54 )]
55 JsonRequiresExplicitPattern { path: String, pattern: String },
56
57 #[error("Template '{template_id}' not found in {path}")]
59 TemplateNotFound { path: String, template_id: String },
60
61 #[error("Duplicate route id '{route_id}' in {path}")]
63 DuplicateRouteId { path: String, route_id: String },
64
65 #[error("Template error in {path}: {source}")]
67 MaterializationFailed {
68 path: String,
69 #[source]
70 source: TemplateError,
71 },
72
73 #[error("Template error in {path}: {error}")]
75 TemplateSpec { path: String, error: String },
76}
77
78const MAX_ROUTE_FILE_SIZE: u64 = 16 * 1024 * 1024;
81
82fn read_file_capped(path: &Path) -> Result<String, DiscoveryError> {
84 let metadata = fs::metadata(path).map_err(|e| DiscoveryError::Io {
85 path: path.to_string_lossy().to_string(),
86 source: e,
87 })?;
88 if metadata.len() > MAX_ROUTE_FILE_SIZE {
89 return Err(DiscoveryError::Io {
90 path: path.to_string_lossy().to_string(),
91 source: io::Error::new(
92 io::ErrorKind::InvalidData,
93 format!(
94 "Route file `{}` is {} bytes, exceeds max {} bytes",
95 path.display(),
96 metadata.len(),
97 MAX_ROUTE_FILE_SIZE
98 ),
99 ),
100 });
101 }
102 fs::read_to_string(path).map_err(|e| DiscoveryError::Io {
103 path: path.to_string_lossy().to_string(),
104 source: e,
105 })
106}
107
108fn pattern_targets_json(pattern: &str) -> bool {
114 let lower = pattern.to_lowercase();
115 lower
117 .rsplit('/')
118 .next()
119 .is_some_and(|last_segment| last_segment.ends_with(".json"))
120}
121
122fn file_extension(path: &Path) -> Option<String> {
124 path.extension()
125 .map(|ext| ext.to_string_lossy().to_lowercase())
126}
127
128pub fn discover_routes(patterns: &[String]) -> Result<Vec<RouteDefinition>, DiscoveryError> {
145 discover_routes_inner(patterns, None, None)
146}
147
148pub fn discover_routes_with_threshold(
153 patterns: &[String],
154 stream_cache_threshold: usize,
155) -> Result<Vec<RouteDefinition>, DiscoveryError> {
156 discover_routes_inner(patterns, Some(stream_cache_threshold), None)
157}
158
159pub fn discover_routes_with_threshold_and_security(
166 patterns: &[String],
167 stream_cache_threshold: usize,
168 security_ctx: SecurityCompileContext,
169) -> Result<Vec<RouteDefinition>, DiscoveryError> {
170 discover_routes_inner(patterns, Some(stream_cache_threshold), Some(security_ctx))
171}
172
173fn discover_routes_inner(
174 patterns: &[String],
175 stream_cache_threshold: Option<usize>,
176 security_ctx: Option<SecurityCompileContext>,
177) -> Result<Vec<RouteDefinition>, DiscoveryError> {
178 let mut routes = Vec::new();
179 let mut templates: HashMap<String, RouteTemplateSpec> = HashMap::new();
180 let mut templated_specs: Vec<(String, TemplatedRouteSpec)> = Vec::new();
182
183 for pattern in patterns {
184 let is_json_pattern = pattern_targets_json(pattern);
185 let entries = glob(pattern)?;
186
187 for entry in entries {
188 let path = entry.map_err(|e| DiscoveryError::GlobAccess {
189 path: e.path().to_string_lossy().to_string(),
190 source: e.into(),
191 })?;
192 let path_str = path.to_string_lossy().to_string();
193
194 let ext = file_extension(&path);
197 match ext.as_deref() {
198 Some("yaml") | Some("yml") => {}
199 Some("json") => {
200 if !is_json_pattern {
201 return Err(DiscoveryError::JsonRequiresExplicitPattern {
202 path: path_str,
203 pattern: pattern.clone(),
204 });
205 }
206 }
207 Some(other) => {
208 return Err(DiscoveryError::UnsupportedExtension {
209 path: path_str,
210 extension: other.to_string(),
211 });
212 }
213 None => {
214 return Err(DiscoveryError::UnsupportedExtension {
215 path: path_str,
216 extension: String::new(),
217 });
218 }
219 }
220
221 let raw_content = read_file_capped(&path)?;
223
224 let mut hasher = DefaultHasher::new();
226 raw_content.hash(&mut hasher);
227 let source_hash = hasher.finish();
228
229 let content =
231 interpolate_env(&raw_content).map_err(|var_name| DiscoveryError::Env {
232 path: path_str.clone(),
233 var_name,
234 })?;
235
236 match ext.as_deref() {
238 Some("yaml") | Some("yml") => {
239 let file_routes = match stream_cache_threshold {
241 Some(threshold) => parse_yaml_with_threshold_and_security(
242 &content,
243 threshold,
244 security_ctx.clone().unwrap_or_default(),
245 )
246 .map_err(|e| DiscoveryError::Yaml {
247 path: path_str.clone(),
248 error: e.to_string(),
249 })?,
250 None => parse_yaml(&content).map_err(|e| DiscoveryError::Yaml {
251 path: path_str.clone(),
252 error: e.to_string(),
253 })?,
254 };
255 for route in file_routes {
256 routes.push(route.with_source_hash(source_hash));
257 }
258
259 let tpls =
261 crate::template::yaml::parse_yaml_templates(&content).map_err(|e| {
262 DiscoveryError::MaterializationFailed {
263 path: path_str.clone(),
264 source: e,
265 }
266 })?;
267 for tpl in tpls {
268 if templates.contains_key(&tpl.id) {
269 return Err(DiscoveryError::TemplateSpec {
270 path: path_str.clone(),
271 error: format!("duplicate template id '{}'", tpl.id),
272 });
273 }
274 templates.insert(tpl.id.clone(), tpl);
275 }
276
277 let specs = crate::template::yaml::parse_yaml_templated_routes(&content)
279 .map_err(|e| DiscoveryError::MaterializationFailed {
280 path: path_str.clone(),
281 source: e,
282 })?;
283 for spec in specs {
284 templated_specs.push((path_str.clone(), spec));
285 }
286 }
287 Some("json") => {
288 let file_routes = match stream_cache_threshold {
290 Some(threshold) => parse_json_with_threshold_and_security(
291 &content,
292 threshold,
293 security_ctx.clone().unwrap_or_default(),
294 )
295 .map_err(|e| DiscoveryError::Json {
296 path: path_str.clone(),
297 error: e.to_string(),
298 })?,
299 None => parse_json(&content).map_err(|e| DiscoveryError::Json {
300 path: path_str.clone(),
301 error: e.to_string(),
302 })?,
303 };
304 for route in file_routes {
305 routes.push(route.with_source_hash(source_hash));
306 }
307
308 let tpls =
310 crate::template::json::parse_json_templates(&content).map_err(|e| {
311 DiscoveryError::MaterializationFailed {
312 path: path_str.clone(),
313 source: e,
314 }
315 })?;
316 for tpl in tpls {
317 if templates.contains_key(&tpl.id) {
318 return Err(DiscoveryError::TemplateSpec {
319 path: path_str.clone(),
320 error: format!("duplicate template id '{}'", tpl.id),
321 });
322 }
323 templates.insert(tpl.id.clone(), tpl);
324 }
325
326 let specs = crate::template::json::parse_json_templated_routes(&content)
328 .map_err(|e| DiscoveryError::MaterializationFailed {
329 path: path_str.clone(),
330 source: e,
331 })?;
332 for spec in specs {
333 templated_specs.push((path_str.clone(), spec));
334 }
335 }
336 _ => unreachable!(
339 "validated extension should be yaml/yml/json but was: {:?}",
340 ext
341 ),
342 }
343 }
344 }
345
346 let mut seen_route_ids: HashSet<String> =
348 routes.iter().map(|r| r.route_id().to_string()).collect();
349
350 for (path_str, spec) in &templated_specs {
351 let template = templates.get(&spec.route_template_ref).ok_or_else(|| {
352 DiscoveryError::TemplateNotFound {
353 path: path_str.clone(),
354 template_id: spec.route_template_ref.clone(),
355 }
356 })?;
357
358 let compiled = materialize_and_compile(template, spec).map_err(|e| {
359 let source = match &e {
360 camel_api::CamelError::Config(msg) => TemplateError::InvalidBody(msg.clone()),
361 other => TemplateError::InvalidBody(other.to_string()),
362 };
363 DiscoveryError::MaterializationFailed {
364 path: path_str.clone(),
365 source,
366 }
367 })?;
368
369 for result in compiled {
370 let rid = result.route_def.route_id().to_string();
371 if !seen_route_ids.insert(rid.clone()) {
372 return Err(DiscoveryError::DuplicateRouteId {
373 path: path_str.clone(),
374 route_id: rid,
375 });
376 }
377 let route_def = match result.source_hash {
378 Some(h) => result.route_def.with_source_hash(h),
379 None => result.route_def,
380 };
381 routes.push(route_def);
382 }
383 }
384
385 Ok(routes)
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use std::env;
392 use std::io::Write;
393 use tempfile::NamedTempFile;
394
395 #[test]
398 fn pattern_targets_json_explicit() {
399 assert!(pattern_targets_json("routes/*.json"));
400 }
401
402 #[test]
403 fn pattern_targets_json_recursive() {
404 assert!(pattern_targets_json("routes/**/*.json"));
405 }
406
407 #[test]
408 fn pattern_targets_json_uppercase() {
409 assert!(pattern_targets_json("routes/*.JSON"));
410 }
411
412 #[test]
413 fn pattern_targets_json_with_trailing_slash() {
414 assert!(pattern_targets_json("config/.json/routes/*.json"));
416 }
417
418 #[test]
419 fn pattern_targets_json_dir_name_only_returns_false() {
420 assert!(!pattern_targets_json("config/.json/routes/*"));
422 }
423
424 #[test]
425 fn pattern_targets_json_dir_name_recursive_returns_false() {
426 assert!(!pattern_targets_json("config/.json/routes/**/*"));
427 }
428
429 #[test]
430 fn pattern_targets_json_brace_expansion() {
431 assert!(pattern_targets_json("routes/{a,b}.json"));
432 }
433
434 #[test]
435 fn pattern_targets_json_uppercase_extension() {
436 assert!(pattern_targets_json("routes/*.JSON"));
437 }
438
439 #[test]
440 fn pattern_targets_json_broad_returns_false() {
441 assert!(!pattern_targets_json("routes/*"));
442 }
443
444 #[test]
445 fn pattern_targets_json_broad_recursive_returns_false() {
446 assert!(!pattern_targets_json("routes/**/*"));
447 }
448
449 #[test]
452 fn discovers_route_with_env_var_in_uri_yaml() {
453 unsafe { env::set_var("TEST_DISC_TIMER_NAME", "my-tick") };
454
455 let mut f = NamedTempFile::with_suffix(".yaml").unwrap();
456 writeln!(f, "routes:").unwrap();
457 writeln!(f, " - id: \"disc-route-1\"").unwrap();
458 writeln!(f, " from: \"timer:${{env:TEST_DISC_TIMER_NAME}}\"").unwrap();
459 writeln!(f, " steps:").unwrap();
460 writeln!(f, " - to: \"log:out\"").unwrap();
461
462 let pattern = f.path().to_string_lossy().to_string();
463 let routes = discover_routes(&[pattern]).unwrap();
464 assert_eq!(routes.len(), 1);
465 assert_eq!(routes[0].from_uri(), "timer:my-tick");
466
467 unsafe { env::remove_var("TEST_DISC_TIMER_NAME") };
468 }
469
470 #[test]
471 fn discover_fails_when_env_var_missing_yaml() {
472 unsafe { env::remove_var("TEST_DISC_MISSING_VAR") };
473
474 let mut f = NamedTempFile::with_suffix(".yaml").unwrap();
475 writeln!(f, "routes:").unwrap();
476 writeln!(f, " - id: \"disc-route-missing\"").unwrap();
477 writeln!(f, " from: \"timer:${{env:TEST_DISC_MISSING_VAR}}\"").unwrap();
478 writeln!(f, " steps: []").unwrap();
479
480 let pattern = f.path().to_string_lossy().to_string();
481 let err = match discover_routes(&[pattern]) {
482 Ok(_) => panic!("expected error"),
483 Err(e) => e,
484 };
485 match &err {
486 DiscoveryError::Env { path: _, var_name } => {
487 assert_eq!(var_name, "TEST_DISC_MISSING_VAR");
488 }
489 other => panic!("expected Env error, got: {other:?}"),
490 }
491 }
492
493 #[test]
494 fn discovers_yml_extension() {
495 let mut f = NamedTempFile::with_suffix(".yml").unwrap();
496 writeln!(f, "routes:").unwrap();
497 writeln!(f, " - id: \"yml-route\"").unwrap();
498 writeln!(f, " from: \"timer:tick\"").unwrap();
499 writeln!(f, " steps:").unwrap();
500 writeln!(f, " - to: \"log:info\"").unwrap();
501
502 let pattern = f.path().to_string_lossy().to_string();
503 let routes = discover_routes(&[pattern]).unwrap();
504 assert_eq!(routes.len(), 1);
505 assert_eq!(routes[0].route_id(), "yml-route");
506 }
507
508 #[test]
511 fn discovers_explicit_json_route() {
512 let mut f = NamedTempFile::with_suffix(".json").unwrap();
513 write!(
514 f,
515 r#"{{
516 "routes": [
517 {{
518 "id": "json-route-1",
519 "from": "timer:tick?period=1000",
520 "steps": [
521 {{ "to": "log:info" }}
522 ]
523 }}
524 ]
525}}"#
526 )
527 .unwrap();
528
529 let pattern = f.path().to_string_lossy().to_string();
530 let routes = discover_routes(&[pattern]).unwrap();
531 assert_eq!(routes.len(), 1);
532 assert_eq!(routes[0].route_id(), "json-route-1");
533 assert_eq!(routes[0].from_uri(), "timer:tick?period=1000");
534 }
535
536 #[test]
537 fn discovers_json_with_glob_pattern() {
538 let dir = tempfile::tempdir().unwrap();
539 let file_path = dir.path().join("route.json");
540 fs::write(
541 &file_path,
542 r#"{"routes":[{"id":"glob-json","from":"direct:start","steps":[{"to":"log:out"}]}]}"#,
543 )
544 .unwrap();
545
546 let pattern = dir.path().join("*.json").to_string_lossy().to_string();
547 let routes = discover_routes(&[pattern]).unwrap();
548 assert_eq!(routes.len(), 1);
549 assert_eq!(routes[0].route_id(), "glob-json");
550 }
551
552 #[test]
555 fn unsupported_extension_with_env_var_returns_unsupported_not_env() {
556 unsafe { env::remove_var("TASK3_SHOULD_NOT_READ_ENV") };
559
560 let f = NamedTempFile::with_suffix(".xml").unwrap();
561 let content = "content: ${env:TASK3_SHOULD_NOT_READ_ENV}";
562 fs::write(f.path(), content).unwrap();
563
564 let pattern = f.path().to_string_lossy().to_string();
565 let err = match discover_routes(&[pattern]) {
566 Ok(_) => panic!("expected error"),
567 Err(e) => e,
568 };
569 match &err {
570 DiscoveryError::UnsupportedExtension { path: _, extension } => {
571 assert_eq!(extension, "xml");
572 }
573 other => panic!(
574 "expected UnsupportedExtension, got: {:?} — env interpolation ran before extension check",
575 other
576 ),
577 }
578 }
579
580 #[test]
581 fn broad_glob_json_with_missing_env_returns_gate_not_env() {
582 unsafe { env::remove_var("TASK3_SHOULD_NOT_READ_ENV") };
585
586 let dir = tempfile::tempdir().unwrap();
587 let file_path = dir.path().join("route.json");
588 fs::write(
589 &file_path,
590 r#"{"routes":[{"id":"x","from":"timer:${env:TASK3_SHOULD_NOT_READ_ENV}","steps":[]}]}"#,
591 )
592 .unwrap();
593
594 let pattern = dir.path().join("*").to_string_lossy().to_string();
595 let err = match discover_routes(&[pattern]) {
596 Ok(_) => panic!("expected error"),
597 Err(e) => e,
598 };
599 match &err {
600 DiscoveryError::JsonRequiresExplicitPattern {
601 path: p,
602 pattern: pat,
603 } => {
604 assert!(p.ends_with("route.json"), "path was: {p}");
605 assert!(!pat.contains(".json"), "pattern was: {pat}");
606 }
607 other => panic!(
608 "expected JsonRequiresExplicitPattern, got: {:?} — gate did not fire before env interpolation",
609 other
610 ),
611 }
612 }
613
614 #[test]
617 fn unsupported_extension_returns_error() {
618 let mut f = NamedTempFile::with_suffix(".xml").unwrap();
619 writeln!(f, "<routes/>").unwrap();
620
621 let pattern = f.path().to_string_lossy().to_string();
622 let err = match discover_routes(&[pattern]) {
623 Ok(_) => panic!("expected error"),
624 Err(e) => e,
625 };
626 match &err {
627 DiscoveryError::UnsupportedExtension { path: _, extension } => {
628 assert_eq!(extension, "xml");
629 }
630 other => panic!("expected UnsupportedExtension, got: {other:?}"),
631 }
632 }
633
634 #[test]
635 fn no_extension_returns_error() {
636 let mut f = NamedTempFile::new().unwrap();
637 writeln!(f, "routes:").unwrap();
638
639 let pattern = f.path().to_string_lossy().to_string();
640 let err = match discover_routes(&[pattern]) {
641 Ok(_) => panic!("expected error"),
642 Err(e) => e,
643 };
644 match &err {
645 DiscoveryError::UnsupportedExtension { path: _, extension } => {
646 assert!(extension.is_empty());
647 }
648 other => panic!("expected UnsupportedExtension, got: {other:?}"),
649 }
650 }
651
652 #[test]
655 fn broad_glob_rejects_json_with_explicit_pattern_error() {
656 let dir = tempfile::tempdir().unwrap();
657 let file_path = dir.path().join("route.json");
658 fs::write(
659 &file_path,
660 r#"{"routes":[{"id":"broad-json","from":"direct:start","steps":[]}]}"#,
661 )
662 .unwrap();
663
664 let pattern = dir.path().join("*").to_string_lossy().to_string();
666 let err = match discover_routes(&[pattern]) {
667 Ok(_) => panic!("expected error"),
668 Err(e) => e,
669 };
670 match &err {
671 DiscoveryError::JsonRequiresExplicitPattern {
672 path: p,
673 pattern: pat,
674 } => {
675 assert!(p.ends_with("route.json"), "path was: {p}");
676 assert!(pat.ends_with('*'), "pattern was: {pat}");
677 assert!(!pat.contains(".json"), "pattern was: {pat}");
678 }
679 other => panic!("expected JsonRequiresExplicitPattern, got: {other:?}"),
680 }
681 }
682
683 #[test]
686 fn json_env_interpolation_with_unescaped_quote_returns_json_error() {
687 unsafe { env::set_var("TEST_JSON_BAD_QUOTE", r#"has"quote"#) };
691
692 let mut f = NamedTempFile::with_suffix(".json").unwrap();
693 write!(
694 f,
695 r#"{{
696 "routes": [
697 {{
698 "id": "bad-quote",
699 "from": "timer:${{env:TEST_JSON_BAD_QUOTE}}",
700 "steps": []
701 }}
702 ]
703}}"#
704 )
705 .unwrap();
706
707 let pattern = f.path().to_string_lossy().to_string();
709 let err = match discover_routes(&[pattern]) {
710 Ok(_) => panic!("expected JSON parse error"),
711 Err(e) => e,
712 };
713 match &err {
714 DiscoveryError::Json { path: _, error } => {
715 assert!(
717 !error.is_empty(),
718 "JSON parse error should describe the issue"
719 );
720 }
721 other => panic!("expected DiscoveryError::Json, got: {:?}", other),
722 }
723
724 unsafe { env::remove_var("TEST_JSON_BAD_QUOTE") };
725 }
726
727 #[test]
728 fn json_env_interpolation_with_valid_value_succeeds() {
729 unsafe { env::set_var("TEST_JSON_GOOD_VAL", "tick") };
730
731 let mut f = NamedTempFile::with_suffix(".json").unwrap();
732 write!(
733 f,
734 r#"{{
735 "routes": [
736 {{
737 "id": "good-env",
738 "from": "timer:${{env:TEST_JSON_GOOD_VAL}}",
739 "steps": []
740 }}
741 ]
742}}"#
743 )
744 .unwrap();
745
746 let pattern = f.path().to_string_lossy().to_string();
747 let routes = discover_routes(&[pattern]).unwrap();
748 assert_eq!(routes.len(), 1);
749 assert_eq!(routes[0].from_uri(), "timer:tick");
750
751 unsafe { env::remove_var("TEST_JSON_GOOD_VAL") };
752 }
753
754 #[test]
757 fn discovers_yaml_template_and_materializes() {
758 let dir = tempfile::tempdir().unwrap();
759 let file_path = dir.path().join("routes.yaml");
760 fs::write(
761 &file_path,
762 r#"
763routes: []
764templates:
765 - id: http-route
766 parameters:
767 - name: path
768 routes:
769 - id: "materialized-http"
770 from: "rest:{{path}}"
771 steps:
772 - to: "log:info"
773templated_routes:
774 - route_template_ref: http-route
775 route_id: "my-http"
776 parameters:
777 path: /api/users
778"#,
779 )
780 .unwrap();
781
782 let pattern = file_path.to_string_lossy().to_string();
783 let routes = discover_routes(&[pattern]).unwrap();
784 assert_eq!(routes.len(), 1);
785 assert_eq!(routes[0].route_id(), "my-http");
786 assert_eq!(routes[0].from_uri(), "rest:/api/users");
787 }
788
789 #[test]
790 fn discovers_json_template_and_materializes() {
791 let dir = tempfile::tempdir().unwrap();
792 let file_path = dir.path().join("routes.json");
793 fs::write(
794 &file_path,
795 r#"{
796 "routes": [],
797 "templates": [
798 {
799 "id": "timer-route",
800 "parameters": [{"name": "period"}],
801 "routes": [
802 {
803 "id": "materialized-timer",
804 "from": "timer:tick?period={{period}}",
805 "steps": []
806 }
807 ]
808 }
809 ],
810 "templated_routes": [
811 {
812 "route_template_ref": "timer-route",
813 "parameters": {"period": "5000"}
814 }
815 ]
816}"#,
817 )
818 .unwrap();
819
820 let pattern = file_path.to_string_lossy().to_string();
821 let routes = discover_routes(&[pattern]).unwrap();
822 assert_eq!(routes.len(), 1);
823 assert_eq!(routes[0].route_id(), "materialized-timer");
824 assert_eq!(routes[0].from_uri(), "timer:tick?period=5000");
825 }
826
827 #[test]
828 fn discovers_mixed_regular_routes_and_templates() {
829 let dir = tempfile::tempdir().unwrap();
830 let file_path = dir.path().join("mixed.yaml");
831 fs::write(
832 &file_path,
833 r#"
834routes:
835 - id: regular-route
836 from: direct:start
837 steps:
838 - to: log:info
839templates:
840 - id: log-route
841 parameters:
842 - name: level
843 routes:
844 - id: "materialized-log"
845 from: "direct:log"
846 steps:
847 - to: "log:{{level}}"
848templated_routes:
849 - route_template_ref: log-route
850 parameters:
851 level: warn
852"#,
853 )
854 .unwrap();
855
856 let pattern = file_path.to_string_lossy().to_string();
857 let routes = discover_routes(&[pattern]).unwrap();
858 assert_eq!(routes.len(), 2);
859 let ids: Vec<&str> = routes.iter().map(|r| r.route_id()).collect();
860 assert!(ids.contains(&"regular-route"));
861 assert!(ids.contains(&"materialized-log"));
862 }
863
864 #[test]
865 fn discovers_cross_file_template_reference() {
866 let dir = tempfile::tempdir().unwrap();
867 let file_a = dir.path().join("templates.yaml");
869 fs::write(
870 &file_a,
871 r#"
872routes: []
873templates:
874 - id: shared-http
875 parameters:
876 - name: path
877 routes:
878 - id: "shared-route"
879 from: "rest:{{path}}"
880 steps:
881 - to: "log:shared"
882"#,
883 )
884 .unwrap();
885
886 let file_b = dir.path().join("instances.yaml");
888 fs::write(
889 &file_b,
890 r#"
891routes: []
892templated_routes:
893 - route_template_ref: shared-http
894 parameters:
895 path: /cross-file
896"#,
897 )
898 .unwrap();
899
900 let pattern = dir.path().join("*.yaml").to_string_lossy().to_string();
901 let routes = discover_routes(&[pattern]).unwrap();
902 assert_eq!(routes.len(), 1);
903 assert_eq!(routes[0].route_id(), "shared-route");
904 assert_eq!(routes[0].from_uri(), "rest:/cross-file");
905 }
906
907 #[test]
908 fn missing_template_ref_returns_error() {
909 let dir = tempfile::tempdir().unwrap();
910 let file_path = dir.path().join("missing.yaml");
911 fs::write(
912 &file_path,
913 r#"
914routes: []
915templated_routes:
916 - route_template_ref: nonexistent-template
917 parameters:
918 path: /test
919"#,
920 )
921 .unwrap();
922
923 let pattern = file_path.to_string_lossy().to_string();
924 let err = match discover_routes(&[pattern]) {
925 Ok(_) => panic!("expected error"),
926 Err(e) => e,
927 };
928 match &err {
929 DiscoveryError::TemplateNotFound {
930 path: _,
931 template_id,
932 } => {
933 assert_eq!(template_id, "nonexistent-template");
934 }
935 other => panic!("expected TemplateNotFound error, got: {other:?}"),
936 }
937 }
938
939 #[test]
940 fn duplicate_template_ids_returns_error() {
941 let dir = tempfile::tempdir().unwrap();
942 let file_a = dir.path().join("a.yaml");
944 fs::write(
945 &file_a,
946 r#"
947routes: []
948templates:
949 - id: dup-tpl
950 routes:
951 - id: "route-a"
952 from: "direct:a"
953"#,
954 )
955 .unwrap();
956
957 let file_b = dir.path().join("b.yaml");
959 fs::write(
960 &file_b,
961 r#"
962routes: []
963templates:
964 - id: dup-tpl
965 routes:
966 - id: "route-b"
967 from: "direct:b"
968"#,
969 )
970 .unwrap();
971
972 let pattern = dir.path().join("*.yaml").to_string_lossy().to_string();
973 let err = match discover_routes(&[pattern]) {
974 Ok(_) => panic!("expected error"),
975 Err(e) => e,
976 };
977 match &err {
978 DiscoveryError::TemplateSpec { path: _, error } => {
979 assert!(error.contains("dup-tpl"));
980 assert!(error.contains("duplicate"));
981 }
982 other => panic!("expected TemplateSpec error, got: {other:?}"),
983 }
984 }
985
986 #[test]
987 fn materialized_routes_preserve_source_hash() {
988 let dir = tempfile::tempdir().unwrap();
989 let file_path = dir.path().join("hash-test.yaml");
990 fs::write(
991 &file_path,
992 r#"
993routes: []
994templates:
995 - id: hash-tpl
996 routes:
997 - id: "hash-route"
998 from: "direct:hash"
999 steps: []
1000templated_routes:
1001 - route_template_ref: hash-tpl
1002 parameters: {}
1003"#,
1004 )
1005 .unwrap();
1006
1007 let pattern = file_path.to_string_lossy().to_string();
1008 let routes = discover_routes(&[pattern]).unwrap();
1009 assert_eq!(routes.len(), 1);
1010 let hash = routes[0].source_hash();
1011 assert!(hash.is_some(), "materialized route should have source_hash");
1012 assert_ne!(hash.unwrap(), 0, "source_hash should be non-zero");
1013 }
1014
1015 #[test]
1016 fn materialized_source_hash_reflects_template_body_not_instance_file() {
1017 let dir = tempfile::tempdir().unwrap();
1018
1019 let template_body = serde_json::json!([{
1020 "id": "same-route",
1021 "from": "direct:x",
1022 "steps": []
1023 }]);
1024 let template_hash = {
1025 let s = serde_json::to_string(&template_body).unwrap();
1026 let mut h = std::collections::hash_map::DefaultHasher::new();
1027 s.hash(&mut h);
1028 h.finish()
1029 };
1030
1031 let file_path = dir.path().join("two-instances.yaml");
1032 fs::write(
1033 &file_path,
1034 r#"
1035routes: []
1036templates:
1037 - id: shared-tpl
1038 routes:
1039 - id: "same-route"
1040 from: "direct:x"
1041 steps: []
1042templated_routes:
1043 - route_template_ref: shared-tpl
1044 route_id: "inst-a"
1045 parameters: {}
1046 - route_template_ref: shared-tpl
1047 route_id: "inst-b"
1048 parameters: {}
1049"#,
1050 )
1051 .unwrap();
1052
1053 let pattern = file_path.to_string_lossy().to_string();
1054 let routes = discover_routes(&[pattern]).unwrap();
1055 assert_eq!(routes.len(), 2);
1056
1057 for route in &routes {
1058 let hash = route.source_hash().expect("should have source_hash");
1059 assert_eq!(
1060 hash, template_hash,
1061 "materialized route source_hash must match template body hash, not instance file hash"
1062 );
1063 }
1064 }
1065
1066 #[test]
1067 fn template_only_file_without_routes_key() {
1068 let dir = tempfile::tempdir().unwrap();
1069 let file_path = dir.path().join("tpl-only.yaml");
1070 fs::write(
1071 &file_path,
1072 r#"
1073templates:
1074 - id: solo-tpl
1075 parameters:
1076 - name: target
1077 routes:
1078 - id: "solo-{{target}}"
1079 from: "direct:start"
1080 steps:
1081 - to: "{{target}}"
1082templated_routes:
1083 - route_template_ref: solo-tpl
1084 parameters:
1085 target: "log:info"
1086"#,
1087 )
1088 .unwrap();
1089
1090 let pattern = file_path.to_string_lossy().to_string();
1091 let routes = discover_routes(&[pattern]).unwrap();
1092 assert_eq!(routes.len(), 1);
1093 assert_eq!(routes[0].from_uri(), "direct:start");
1094 }
1095
1096 #[test]
1097 fn duplicate_route_ids_returns_error() {
1098 let dir = tempfile::tempdir().unwrap();
1099 let file_path = dir.path().join("dup-rid.yaml");
1100 fs::write(
1101 &file_path,
1102 r#"
1103routes:
1104 - id: "shared-id"
1105 from: "direct:a"
1106 steps: []
1107templates:
1108 - id: tpl
1109 routes:
1110 - id: "tpl-route"
1111 from: "direct:b"
1112 steps: []
1113templated_routes:
1114 - route_template_ref: tpl
1115 route_id: "shared-id"
1116 parameters: {}
1117"#,
1118 )
1119 .unwrap();
1120
1121 let pattern = file_path.to_string_lossy().to_string();
1122 let err = match discover_routes(&[pattern]) {
1123 Ok(_) => panic!("expected duplicate route id error"),
1124 Err(e) => e,
1125 };
1126 let msg = err.to_string();
1127 assert!(
1128 msg.contains("shared-id"),
1129 "expected duplicate route id error, got: {msg}"
1130 );
1131 match &err {
1132 DiscoveryError::DuplicateRouteId { route_id, .. } => {
1133 assert_eq!(route_id, "shared-id");
1134 }
1135 other => panic!("expected DuplicateRouteId error, got: {other:?}"),
1136 }
1137 }
1138
1139 #[test]
1140 fn discovers_multi_route_template() {
1141 let dir = tempfile::tempdir().unwrap();
1142 let file_path = dir.path().join("multi.yaml");
1143 fs::write(
1144 &file_path,
1145 r#"
1146routes: []
1147templates:
1148 - id: chain
1149 parameters:
1150 - name: PROV
1151 routes:
1152 - id: "step1-{{PROV}}"
1153 from: "direct:start"
1154 steps:
1155 - to: "controlbus:route?routeId=step2-{{PROV}}&action=start"
1156 - id: "step2-{{PROV}}"
1157 from: "direct:step2"
1158 steps:
1159 - to: "log:done"
1160templated_routes:
1161 - route_template_ref: chain
1162 parameters:
1163 PROV: granada
1164"#,
1165 )
1166 .unwrap();
1167
1168 let pattern = file_path.to_string_lossy().to_string();
1169 let routes = discover_routes(&[pattern]).unwrap();
1170 assert_eq!(routes.len(), 2);
1171 assert_eq!(routes[0].route_id(), "step1-granada");
1172 assert_eq!(routes[1].route_id(), "step2-granada");
1173 }
1174}