Skip to main content

camel_dsl/
discovery.rs

1//! Route discovery module - finds and loads routes from YAML/JSON files using glob patterns.
2
3use 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/// Errors that can occur during route discovery.
20#[derive(Debug, thiserror::Error)]
21#[non_exhaustive]
22pub enum DiscoveryError {
23    /// Invalid glob pattern.
24    #[error("Glob pattern error: {0}")]
25    GlobPattern(#[from] glob::PatternError),
26
27    /// Error accessing file while iterating glob.
28    #[error("Glob error accessing {path}: {source}")]
29    GlobAccess { path: String, source: io::Error },
30
31    /// Error reading a file.
32    #[error("IO error reading {path}: {source}")]
33    Io { path: String, source: io::Error },
34
35    /// Error parsing YAML content.
36    #[error("YAML parse error in {path}: {error}")]
37    Yaml { path: String, error: String },
38
39    /// Environment variable not set during interpolation.
40    #[error("Environment variable '{var_name}' not set (required by {path})")]
41    Env { path: String, var_name: String },
42
43    /// Error parsing JSON content.
44    #[error("JSON parse error in {path}: {error}")]
45    Json { path: String, error: String },
46
47    /// File has an unsupported extension (not .yaml, .yml, or .json).
48    #[error("Unsupported file extension '{extension}' in {path}")]
49    UnsupportedExtension { path: String, extension: String },
50
51    /// JSON file matched by a broad glob pattern requires an explicit .json pattern.
52    #[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    /// Template reference points to a template id that was never defined.
58    #[error("Template '{template_id}' not found in {path}")]
59    TemplateNotFound { path: String, template_id: String },
60
61    /// A route id was produced more than once across regular + materialized routes.
62    #[error("Duplicate route id '{route_id}' in {path}")]
63    DuplicateRouteId { path: String, route_id: String },
64
65    /// Template parsing or materialization failed (invalid body, missing params, etc.).
66    #[error("Template error in {path}: {source}")]
67    MaterializationFailed {
68        path: String,
69        #[source]
70        source: TemplateError,
71    },
72
73    /// Duplicate template id across files, or invalid template spec in file.
74    #[error("Template error in {path}: {error}")]
75    TemplateSpec { path: String, error: String },
76}
77
78/// Maximum size for individual route files (YAML/JSON) during discovery.
79/// Prevents OOM from abnormally large files.
80const MAX_ROUTE_FILE_SIZE: u64 = 16 * 1024 * 1024;
81
82/// Read a file with a size cap. Stats first, rejects if too large.
83fn 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
108/// Returns true if the glob pattern explicitly targets `.json` files.
109///
110/// Only patterns whose **file target extension** is `.json` (case-insensitive) return true.
111/// A `.json` segment appearing only in a directory path (e.g. `config/.json/routes/*`)
112/// does **not** authorize JSON loading.
113fn pattern_targets_json(pattern: &str) -> bool {
114    let lower = pattern.to_lowercase();
115    // Extract the last path segment (the file/target portion) and check if it ends with .json
116    lower
117        .rsplit('/')
118        .next()
119        .is_some_and(|last_segment| last_segment.ends_with(".json"))
120}
121
122/// Extracts the lowercase file extension from a path, if any.
123fn file_extension(path: &Path) -> Option<String> {
124    path.extension()
125        .map(|ext| ext.to_string_lossy().to_lowercase())
126}
127
128/// Discovers routes from YAML/JSON files matching the given glob patterns.
129///
130/// # Arguments
131/// * `patterns` - Slice of glob patterns to match route definition files
132///
133/// # Returns
134/// A vector of all discovered route definitions, or an error.
135///
136/// # Supported formats
137/// - `.yaml` / `.yml` — parsed as YAML
138/// - `.json` — parsed as JSON, but only when the source pattern explicitly targets `.json`
139///
140/// # Example
141/// ```ignore
142/// let routes = discover_routes(&["routes/*.yaml".to_string(), "routes/*.json".to_string()])?;
143/// ```
144pub fn discover_routes(patterns: &[String]) -> Result<Vec<RouteDefinition>, DiscoveryError> {
145    discover_routes_inner(patterns, None, None)
146}
147
148/// Discovers routes with a custom stream-cache threshold.
149///
150/// Same as [`discover_routes`] but uses the given `stream_cache_threshold`
151/// instead of the default when compiling routes.
152pub 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
159/// Discovers routes with a custom stream-cache threshold and security compile context.
160///
161/// Same as [`discover_routes_with_threshold`] but also passes a
162/// [`SecurityCompileContext`] through to route compilation, allowing
163/// permission evaluators and security policy registries to be resolved
164/// during DSL compilation.
165pub 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    // (path_str, templated_spec) — materialized after all files scanned
181    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            // Validate extension and JSON explicit-pattern gate BEFORE reading or
195            // interpolating — rejects must not trigger env lookups.
196            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            // Read file content (only reached for accepted extensions)
222            let raw_content = read_file_capped(&path)?;
223
224            // Source hash is based on raw content before env interpolation
225            let mut hasher = DefaultHasher::new();
226            raw_content.hash(&mut hasher);
227            let source_hash = hasher.finish();
228
229            // Env interpolation happens before parsing for both YAML and JSON.
230            let content =
231                interpolate_env(&raw_content).map_err(|var_name| DiscoveryError::Env {
232                    path: path_str.clone(),
233                    var_name,
234                })?;
235
236            // Parse based on extension — collect templates, templated specs, and regular routes
237            match ext.as_deref() {
238                Some("yaml") | Some("yml") => {
239                    // Parse regular routes
240                    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                    // Parse templates
260                    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                    // Parse templated route specs for later materialization
278                    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                    // Parse regular routes
289                    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                    // Parse templates
309                    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                    // Parse templated route specs for later materialization
327                    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                // SAFETY: Unreachable. The validation block above returns early for
337                // any extension that is not yaml, yml, or json.
338                _ => unreachable!(
339                    "validated extension should be yaml/yml/json but was: {:?}",
340                    ext
341                ),
342            }
343        }
344    }
345
346    // Pass 2: materialize all templated specs using the collected templates
347    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    // ── pattern_targets_json ──────────────────────────────────────────
396
397    #[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        // .json in directory name but file targets .json — should still match
415        assert!(pattern_targets_json("config/.json/routes/*.json"));
416    }
417
418    #[test]
419    fn pattern_targets_json_dir_name_only_returns_false() {
420        // .json only appears in directory path, not as file extension
421        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    // ── YAML discovery ───────────────────────────────────────────────
450
451    #[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    // ── JSON discovery ───────────────────────────────────────────────
509
510    #[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    // ── Extension/gate validation before env interpolation ────────────
553
554    #[test]
555    fn unsupported_extension_with_env_var_returns_unsupported_not_env() {
556        // .xml file containing a real env var reference must fail with
557        // UnsupportedExtension, NOT Env — env interpolation must not run.
558        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        // Broad glob matching .json with missing env var must fail with
583        // JsonRequiresExplicitPattern, NOT Env — gate must fire before interpolation.
584        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    // ── Unsupported extension ────────────────────────────────────────
615
616    #[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    // ── Broad glob rejects JSON ──────────────────────────────────────
653
654    #[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        // Use a broad pattern that matches .json files but doesn't explicitly target .json
665        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    // ── JSON env interpolation ────────────────────────────────────────────
684
685    #[test]
686    fn json_env_interpolation_with_unescaped_quote_returns_json_error() {
687        // An env var containing a raw double-quote will break JSON parsing
688        // because interpolation is textual — the quote is injected verbatim
689        // into the JSON string, producing invalid JSON.
690        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        // The temp file path IS the pattern (already ends in .json)
708        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                // Error should mention the parse failure (caused by unescaped quote)
716                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    // ── Template-aware discovery ─────────────────────────────────────
755
756    #[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        // File A: defines the template
868        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        // File B: instantiates the template
887        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        // File A: defines template "dup-tpl"
943        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        // File B: also defines template "dup-tpl"
958        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}