Skip to main content

jpx_engine/
config.rs

1//! Engine configuration via `jpx.toml`.
2//!
3//! Provides declarative configuration for the jpx engine, supporting function
4//! filtering, query libraries, and engine settings. Configuration is loaded
5//! from multiple sources with layered overrides:
6//!
7//! 1. **Defaults** -- `EngineConfig::default()` (strict=false, all functions enabled)
8//! 2. **Global** -- `~/.config/jpx/jpx.toml` (via `dirs::config_dir()`)
9//! 3. **Project-local** -- Walk up from CWD looking for `jpx.toml`
10//! 4. **Env override** -- `$JPX_CONFIG` points to a specific file
11//! 5. **Programmatic** -- CLI flags, MCP args, builder calls
12//!
13//! # Example
14//!
15//! ```toml
16//! [engine]
17//! strict = false
18//!
19//! [functions]
20//! disabled_categories = ["geo", "phonetic"]
21//! disabled_functions = ["env"]
22//!
23//! [queries]
24//! libraries = ["~/.config/jpx/common.jpx"]
25//!
26//! [queries.inline]
27//! active-users = { expression = "users[?active].name", description = "Get active user names" }
28//! ```
29
30use crate::JpxEngine;
31use crate::error::EngineError;
32use jpx_core::query_library::QueryLibrary;
33use jpx_core::{FunctionRegistry, Runtime};
34use serde::Deserialize;
35use std::collections::HashMap;
36use std::path::{Path, PathBuf};
37use std::sync::{Arc, RwLock};
38
39/// Top-level configuration for the jpx engine.
40///
41/// Parsed from `jpx.toml` files. All fields have sensible defaults.
42#[derive(Debug, Clone, Default, Deserialize)]
43#[serde(default)]
44pub struct EngineConfig {
45    /// Engine-level settings.
46    pub engine: EngineSection,
47    /// Function filtering settings.
48    pub functions: FunctionsSection,
49    /// Query library and inline query settings.
50    pub queries: QueriesSection,
51}
52
53/// Engine-level settings.
54#[derive(Debug, Clone, Default, Deserialize)]
55#[serde(default)]
56pub struct EngineSection {
57    /// If `Some(true)`, only standard JMESPath functions are available for
58    /// evaluation. `None` means "not specified", which lets a higher-precedence
59    /// config layer override the value in either direction during [`EngineConfig::merge`]
60    /// (a plain `bool` could only ever be turned *on* by a later layer). Resolves
61    /// to `false` when unset; see [`EngineConfig::is_strict`].
62    pub strict: Option<bool>,
63}
64
65/// Function filtering configuration.
66///
67/// Supports two mutually exclusive approaches:
68/// - **Blocklist** (default): everything enabled, opt out with `disabled_categories`/`disabled_functions`
69/// - **Allowlist**: only specified categories enabled via `enabled_categories`
70#[derive(Debug, Clone, Default, Deserialize)]
71#[serde(default)]
72pub struct FunctionsSection {
73    /// Categories to disable (blocklist approach).
74    pub disabled_categories: Vec<String>,
75    /// Individual functions to disable.
76    pub disabled_functions: Vec<String>,
77    /// If set, only these categories are enabled (allowlist approach).
78    /// Mutually exclusive with `disabled_categories`.
79    pub enabled_categories: Option<Vec<String>>,
80}
81
82/// Query configuration.
83#[derive(Debug, Clone, Default, Deserialize)]
84#[serde(default)]
85pub struct QueriesSection {
86    /// Paths to `.jpx` query library files to load.
87    pub libraries: Vec<String>,
88    /// Inline named queries.
89    pub inline: HashMap<String, InlineQuery>,
90}
91
92/// An inline named query defined in the config file.
93#[derive(Debug, Clone, Deserialize)]
94pub struct InlineQuery {
95    /// The JMESPath expression.
96    pub expression: String,
97    /// Optional description.
98    #[serde(default)]
99    pub description: Option<String>,
100}
101
102impl EngineConfig {
103    /// Returns whether strict mode is enabled, treating an unspecified value as
104    /// `false`.
105    pub fn is_strict(&self) -> bool {
106        self.engine.strict.unwrap_or(false)
107    }
108
109    /// Parses an `EngineConfig` from a TOML file.
110    pub fn from_file(path: &Path) -> crate::Result<Self> {
111        let content = std::fs::read_to_string(path).map_err(|e| {
112            EngineError::ConfigError(format!("Failed to read {}: {}", path.display(), e))
113        })?;
114        toml::from_str(&content).map_err(|e| {
115            EngineError::ConfigError(format!("Failed to parse {}: {}", path.display(), e))
116        })
117    }
118
119    /// Discovers and merges configuration from standard locations.
120    ///
121    /// Loads configs in order (later overrides earlier):
122    /// 1. Global: `~/.config/jpx/jpx.toml`
123    /// 2. Project-local: `jpx.toml` found by walking up from CWD
124    /// 3. Env override: `$JPX_CONFIG`
125    pub fn discover() -> crate::Result<Self> {
126        let mut config = Self::default();
127
128        // 1. Global config
129        if let Some(global_path) = global_config_path()
130            && global_path.exists()
131        {
132            let global = Self::from_file(&global_path)?;
133            config = config.merge(global);
134        }
135
136        // 2. Project-local config (walk up from CWD)
137        if let Some(local_path) = find_project_config() {
138            let local = Self::from_file(&local_path)?;
139            config = config.merge(local);
140        }
141
142        // 3. Env override
143        if let Ok(env_path) = std::env::var("JPX_CONFIG") {
144            let path = PathBuf::from(&env_path);
145            if path.exists() {
146                let env_config = Self::from_file(&path)?;
147                config = config.merge(env_config);
148            }
149        }
150
151        Ok(config)
152    }
153
154    /// Merges another config into this one.
155    ///
156    /// Merge semantics:
157    /// - Scalars (`strict`): later wins
158    /// - `disabled_categories` / `disabled_functions`: union
159    /// - `enabled_categories`: later replaces
160    /// - `queries.libraries`: concatenate
161    /// - `queries.inline`: later keys override same-name
162    pub fn merge(mut self, other: Self) -> Self {
163        // Engine section: later wins in BOTH directions -- a later layer that
164        // explicitly sets `strict` (to true OR false) overrides; one that omits
165        // it leaves the earlier value intact.
166        if other.engine.strict.is_some() {
167            self.engine.strict = other.engine.strict;
168        }
169
170        // Functions section
171        if let Some(enabled) = other.functions.enabled_categories {
172            // Allowlist: later replaces entirely
173            self.functions.enabled_categories = Some(enabled);
174            // Clear blocklist when switching to allowlist
175            self.functions.disabled_categories.clear();
176        } else {
177            // Blocklist: union
178            for cat in other.functions.disabled_categories {
179                if !self.functions.disabled_categories.contains(&cat) {
180                    self.functions.disabled_categories.push(cat);
181                }
182            }
183        }
184        for func in other.functions.disabled_functions {
185            if !self.functions.disabled_functions.contains(&func) {
186                self.functions.disabled_functions.push(func);
187            }
188        }
189
190        // Queries section
191        self.queries.libraries.extend(other.queries.libraries);
192        self.queries.inline.extend(other.queries.inline);
193
194        self
195    }
196}
197
198/// Builds a `JpxEngine` from configuration with programmatic overrides.
199///
200/// # Example
201///
202/// ```rust
203/// use jpx_engine::config::EngineBuilder;
204///
205/// let engine = EngineBuilder::new()
206///     .strict(false)
207///     .disable_category("geo")
208///     .disable_function("env")
209///     .build()
210///     .unwrap();
211/// ```
212pub struct EngineBuilder {
213    config: EngineConfig,
214}
215
216impl EngineBuilder {
217    /// Creates a new builder with default configuration.
218    pub fn new() -> Self {
219        Self {
220            config: EngineConfig::default(),
221        }
222    }
223
224    /// Sets strict mode.
225    pub fn strict(mut self, strict: bool) -> Self {
226        self.config.engine.strict = Some(strict);
227        self
228    }
229
230    /// Adds a category to the disabled list.
231    pub fn disable_category(mut self, cat: &str) -> Self {
232        let cat = cat.to_string();
233        if !self.config.functions.disabled_categories.contains(&cat) {
234            self.config.functions.disabled_categories.push(cat);
235        }
236        self
237    }
238
239    /// Adds a function to the disabled list.
240    pub fn disable_function(mut self, name: &str) -> Self {
241        let name = name.to_string();
242        if !self.config.functions.disabled_functions.contains(&name) {
243            self.config.functions.disabled_functions.push(name);
244        }
245        self
246    }
247
248    /// Sets the allowlist of enabled categories (replaces any blocklist).
249    pub fn enable_categories(mut self, cats: Vec<String>) -> Self {
250        self.config.functions.enabled_categories = Some(cats);
251        self.config.functions.disabled_categories.clear();
252        self
253    }
254
255    /// Adds a query library path.
256    pub fn load_library(mut self, path: &str) -> Self {
257        self.config.queries.libraries.push(path.to_string());
258        self
259    }
260
261    /// Adds an inline query.
262    pub fn inline_query(mut self, name: &str, expr: &str, desc: Option<&str>) -> Self {
263        self.config.queries.inline.insert(
264            name.to_string(),
265            InlineQuery {
266                expression: expr.to_string(),
267                description: desc.map(|s| s.to_string()),
268            },
269        );
270        self
271    }
272
273    /// Applies an `EngineConfig` (merges into the builder's config).
274    pub fn config(mut self, config: EngineConfig) -> Self {
275        self.config = self.config.merge(config);
276        self
277    }
278
279    /// Builds the `JpxEngine`.
280    pub fn build(self) -> crate::Result<JpxEngine> {
281        JpxEngine::from_config(self.config)
282    }
283}
284
285impl Default for EngineBuilder {
286    fn default() -> Self {
287        Self::new()
288    }
289}
290
291// =============================================================================
292// Shared helpers for building Runtime + Registry from config
293// =============================================================================
294
295/// Builds a `Runtime` and `FunctionRegistry` from function configuration.
296///
297/// This is the shared logic used by both `JpxEngine::from_config` and the CLI's
298/// `create_configured_runtime`. It handles:
299/// - Registering builtin functions
300/// - Applying allowlist/blocklist filtering
301/// - Registering enabled extension functions on the runtime
302pub fn build_runtime_from_config(
303    functions_config: &FunctionsSection,
304    strict: bool,
305) -> (Runtime, FunctionRegistry) {
306    use crate::introspection::parse_category;
307
308    let mut runtime = Runtime::new();
309    runtime.register_builtin_functions();
310
311    let mut registry = FunctionRegistry::new();
312
313    if let Some(ref enabled_cats) = functions_config.enabled_categories {
314        // Allowlist mode: only register specified categories
315        for cat_name in enabled_cats {
316            if let Some(cat) = parse_category(cat_name) {
317                registry.register_category(cat);
318            }
319        }
320        // Always include Standard category
321        registry.register_category(jpx_core::Category::Standard);
322    } else {
323        // Default: register all, then disable
324        registry.register_all();
325
326        // Disable categories
327        for cat_name in &functions_config.disabled_categories {
328            if let Some(cat) = parse_category(cat_name) {
329                let names: Vec<String> = registry
330                    .functions_in_category(cat)
331                    .map(|f| f.name.to_string())
332                    .collect();
333                for name in &names {
334                    registry.disable_function(name);
335                }
336            }
337        }
338    }
339
340    // Disable individual functions
341    for func_name in &functions_config.disabled_functions {
342        registry.disable_function(func_name);
343    }
344
345    // Apply to runtime (unless strict)
346    if !strict {
347        registry.apply(&mut runtime);
348    }
349
350    (runtime, registry)
351}
352
353/// Loads query libraries and inline queries into a query store.
354pub fn load_queries_into_store(
355    queries_config: &QueriesSection,
356    runtime: &Runtime,
357    queries: &Arc<RwLock<crate::QueryStore>>,
358) -> crate::Result<()> {
359    // Load .jpx library files
360    for lib_path in &queries_config.libraries {
361        let expanded = expand_tilde(lib_path);
362        let path = Path::new(&expanded);
363        if !path.exists() {
364            continue; // silently skip missing libraries
365        }
366
367        let content = std::fs::read_to_string(path).map_err(|e| {
368            EngineError::ConfigError(format!("Failed to read {}: {}", path.display(), e))
369        })?;
370
371        let library = QueryLibrary::parse(&content).map_err(|e| {
372            EngineError::ConfigError(format!("Failed to parse {}: {}", path.display(), e))
373        })?;
374
375        let mut store = queries
376            .write()
377            .map_err(|e| EngineError::Internal(e.to_string()))?;
378
379        for named_query in library.list() {
380            // Validate expression
381            if runtime.compile(&named_query.expression).is_ok() {
382                store.define(crate::StoredQuery {
383                    name: named_query.name.clone(),
384                    expression: named_query.expression.clone(),
385                    description: named_query.description.clone(),
386                });
387            }
388        }
389    }
390
391    // Load inline queries
392    if !queries_config.inline.is_empty() {
393        let mut store = queries
394            .write()
395            .map_err(|e| EngineError::Internal(e.to_string()))?;
396
397        for (name, query) in &queries_config.inline {
398            // Validate expression
399            if runtime.compile(&query.expression).is_ok() {
400                store.define(crate::StoredQuery {
401                    name: name.clone(),
402                    expression: query.expression.clone(),
403                    description: query.description.clone(),
404                });
405            }
406        }
407    }
408
409    Ok(())
410}
411
412// =============================================================================
413// Path helpers
414// =============================================================================
415
416/// Returns the global config path: `~/.config/jpx/jpx.toml`
417fn global_config_path() -> Option<PathBuf> {
418    dirs::config_dir().map(|d| d.join("jpx").join("jpx.toml"))
419}
420
421/// Walks up from CWD looking for `jpx.toml`.
422fn find_project_config() -> Option<PathBuf> {
423    let cwd = std::env::current_dir().ok()?;
424    let mut dir = cwd.as_path();
425    loop {
426        let candidate = dir.join("jpx.toml");
427        if candidate.exists() {
428            return Some(candidate);
429        }
430        dir = dir.parent()?;
431    }
432}
433
434/// Expands `~` at the start of a path to the user's home directory.
435fn expand_tilde(path: &str) -> String {
436    if let Some(rest) = path.strip_prefix("~/")
437        && let Some(home) = dirs::home_dir()
438    {
439        return home.join(rest).to_string_lossy().into_owned();
440    }
441    path.to_string()
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn test_default_config() {
450        let config = EngineConfig::default();
451        assert!(!config.is_strict());
452        assert_eq!(config.engine.strict, None);
453        assert!(config.functions.disabled_categories.is_empty());
454        assert!(config.functions.disabled_functions.is_empty());
455        assert!(config.functions.enabled_categories.is_none());
456        assert!(config.queries.libraries.is_empty());
457        assert!(config.queries.inline.is_empty());
458    }
459
460    #[test]
461    fn test_parse_config() {
462        let toml = r#"
463[engine]
464strict = true
465
466[functions]
467disabled_categories = ["geo", "phonetic"]
468disabled_functions = ["env"]
469
470[queries]
471libraries = ["~/.config/jpx/common.jpx"]
472
473[queries.inline]
474active-users = { expression = "users[?active].name", description = "Get active user names" }
475"#;
476        let config: EngineConfig = toml::from_str(toml).unwrap();
477        assert_eq!(config.engine.strict, Some(true));
478        assert_eq!(
479            config.functions.disabled_categories,
480            vec!["geo", "phonetic"]
481        );
482        assert_eq!(config.functions.disabled_functions, vec!["env"]);
483        assert_eq!(config.queries.libraries.len(), 1);
484        assert!(config.queries.inline.contains_key("active-users"));
485    }
486
487    #[test]
488    fn test_merge_scalars() {
489        let base = EngineConfig::default();
490        let overlay = EngineConfig {
491            engine: EngineSection { strict: Some(true) },
492            ..Default::default()
493        };
494        let merged = base.merge(overlay);
495        assert!(merged.is_strict());
496    }
497
498    #[test]
499    fn test_merge_strict_later_wins_both_directions() {
500        let strict_on = || EngineConfig {
501            engine: EngineSection { strict: Some(true) },
502            ..Default::default()
503        };
504        let strict_off = || EngineConfig {
505            engine: EngineSection {
506                strict: Some(false),
507            },
508            ..Default::default()
509        };
510
511        // A later layer that sets strict = false must override an earlier
512        // strict = true (the previous bug could only ever turn strict *on*).
513        assert!(!strict_on().merge(strict_off()).is_strict());
514        // ...and a later strict = true overrides an earlier false.
515        assert!(strict_off().merge(strict_on()).is_strict());
516        // A later layer that omits strict leaves the earlier value intact.
517        assert!(strict_on().merge(EngineConfig::default()).is_strict());
518        assert!(!strict_off().merge(EngineConfig::default()).is_strict());
519    }
520
521    #[test]
522    fn test_merge_disabled_union() {
523        let base = EngineConfig {
524            functions: FunctionsSection {
525                disabled_categories: vec!["geo".to_string()],
526                disabled_functions: vec!["env".to_string()],
527                ..Default::default()
528            },
529            ..Default::default()
530        };
531        let overlay = EngineConfig {
532            functions: FunctionsSection {
533                disabled_categories: vec!["geo".to_string(), "phonetic".to_string()],
534                disabled_functions: vec!["uuid".to_string()],
535                ..Default::default()
536            },
537            ..Default::default()
538        };
539        let merged = base.merge(overlay);
540        assert_eq!(merged.functions.disabled_categories.len(), 2); // geo + phonetic (no dups)
541        assert_eq!(merged.functions.disabled_functions.len(), 2); // env + uuid
542    }
543
544    #[test]
545    fn test_merge_enabled_replaces() {
546        let base = EngineConfig {
547            functions: FunctionsSection {
548                disabled_categories: vec!["geo".to_string()],
549                ..Default::default()
550            },
551            ..Default::default()
552        };
553        let overlay = EngineConfig {
554            functions: FunctionsSection {
555                enabled_categories: Some(vec!["string".to_string(), "math".to_string()]),
556                ..Default::default()
557            },
558            ..Default::default()
559        };
560        let merged = base.merge(overlay);
561        assert_eq!(
562            merged.functions.enabled_categories,
563            Some(vec!["string".to_string(), "math".to_string()])
564        );
565        assert!(merged.functions.disabled_categories.is_empty());
566    }
567
568    #[test]
569    fn test_merge_queries_concat() {
570        let base = EngineConfig {
571            queries: QueriesSection {
572                libraries: vec!["a.jpx".to_string()],
573                ..Default::default()
574            },
575            ..Default::default()
576        };
577        let overlay = EngineConfig {
578            queries: QueriesSection {
579                libraries: vec!["b.jpx".to_string()],
580                ..Default::default()
581            },
582            ..Default::default()
583        };
584        let merged = base.merge(overlay);
585        assert_eq!(merged.queries.libraries, vec!["a.jpx", "b.jpx"]);
586    }
587
588    #[test]
589    fn test_builder() {
590        let engine = EngineBuilder::new()
591            .strict(false)
592            .disable_category("geo")
593            .disable_function("env")
594            .build()
595            .unwrap();
596
597        // Engine should work
598        let result = engine
599            .evaluate("length(@)", &serde_json::json!([1, 2, 3]))
600            .unwrap();
601        assert_eq!(result, serde_json::json!(3));
602    }
603
604    #[test]
605    fn test_builder_strict() {
606        let engine = EngineBuilder::new().strict(true).build().unwrap();
607        assert!(engine.is_strict());
608    }
609
610    #[test]
611    fn test_from_config_with_disabled_functions() {
612        let config = EngineConfig {
613            functions: FunctionsSection {
614                disabled_functions: vec!["upper".to_string()],
615                ..Default::default()
616            },
617            ..Default::default()
618        };
619        let engine = JpxEngine::from_config(config).unwrap();
620
621        // upper should be disabled in introspection
622        assert!(engine.describe_function("upper").is_none());
623
624        // But standard functions still work
625        let result = engine
626            .evaluate("length(@)", &serde_json::json!([1, 2, 3]))
627            .unwrap();
628        assert_eq!(result, serde_json::json!(3));
629    }
630
631    #[test]
632    fn test_from_config_with_inline_queries() {
633        let config = EngineConfig {
634            queries: QueriesSection {
635                inline: {
636                    let mut m = HashMap::new();
637                    m.insert(
638                        "count".to_string(),
639                        InlineQuery {
640                            expression: "length(@)".to_string(),
641                            description: Some("Count items".to_string()),
642                        },
643                    );
644                    m
645                },
646                ..Default::default()
647            },
648            ..Default::default()
649        };
650        let engine = JpxEngine::from_config(config).unwrap();
651
652        let result = engine
653            .run_query("count", &serde_json::json!([1, 2, 3]))
654            .unwrap();
655        assert_eq!(result, serde_json::json!(3));
656    }
657
658    #[test]
659    fn test_expand_tilde() {
660        let result = expand_tilde("/absolute/path");
661        assert_eq!(result, "/absolute/path");
662
663        let result = expand_tilde("relative/path");
664        assert_eq!(result, "relative/path");
665
666        // ~ expansion depends on home dir being available
667        let result = expand_tilde("~/some/path");
668        if let Some(home) = dirs::home_dir() {
669            assert_eq!(result, home.join("some/path").to_string_lossy().as_ref());
670        }
671    }
672
673    #[test]
674    fn test_invalid_toml() {
675        let bad_toml = r#"
676[engine
677strict = true
678"#;
679        let result: Result<EngineConfig, _> = toml::from_str(bad_toml);
680        assert!(
681            result.is_err(),
682            "Parsing invalid TOML should return an error"
683        );
684    }
685
686    #[test]
687    fn test_from_file_missing() {
688        let path = Path::new("/tmp/nonexistent_jpx_config_test_file.toml");
689        let result = EngineConfig::from_file(path);
690        assert!(
691            result.is_err(),
692            "from_file on nonexistent path should return Err"
693        );
694    }
695
696    #[test]
697    fn test_builder_chaining() {
698        let builder = EngineBuilder::new()
699            .disable_category("geo")
700            .disable_category("phonetic")
701            .disable_category("semver")
702            .disable_function("env")
703            .disable_function("upper")
704            .disable_function("lower");
705
706        let engine = builder.build().unwrap();
707
708        // All three categories should be disabled -- geo functions should not resolve
709        assert!(engine.describe_function("geo_distance").is_none());
710
711        // All three individually disabled functions should be gone
712        assert!(engine.describe_function("env").is_none());
713        assert!(engine.describe_function("upper").is_none());
714        assert!(engine.describe_function("lower").is_none());
715
716        // Standard JMESPath functions still work
717        let result = engine
718            .evaluate("length(@)", &serde_json::json!([1, 2, 3]))
719            .unwrap();
720        assert_eq!(result, serde_json::json!(3));
721    }
722
723    #[test]
724    fn test_builder_enable_categories() {
725        let engine = EngineBuilder::new()
726            .enable_categories(vec!["string".to_string(), "math".to_string()])
727            .build()
728            .unwrap();
729
730        // String-category functions should be available
731        assert!(
732            engine.describe_function("upper").is_some(),
733            "upper should be available when string category is enabled"
734        );
735
736        // Geo-category functions should NOT be available
737        assert!(
738            engine.describe_function("geo_distance").is_none(),
739            "geo_distance should not be available when only string and math are enabled"
740        );
741    }
742
743    #[test]
744    fn test_builder_inline_query() {
745        let engine = EngineBuilder::new()
746            .inline_query("count", "length(@)", Some("Count items"))
747            .inline_query("names", "people[*].name", None)
748            .build()
749            .unwrap();
750
751        // First inline query should be stored and runnable
752        let result = engine
753            .run_query("count", &serde_json::json!([1, 2, 3]))
754            .unwrap();
755        assert_eq!(result, serde_json::json!(3));
756
757        // Second inline query should also work
758        let data = serde_json::json!({"people": [{"name": "alice"}, {"name": "bob"}]});
759        let result = engine.run_query("names", &data).unwrap();
760        assert_eq!(result, serde_json::json!(["alice", "bob"]));
761    }
762
763    #[test]
764    fn test_merge_deep_both_empty() {
765        let a = EngineConfig::default();
766        let b = EngineConfig::default();
767        let merged = a.merge(b);
768
769        assert!(!merged.is_strict());
770        assert!(merged.functions.disabled_categories.is_empty());
771        assert!(merged.functions.disabled_functions.is_empty());
772        assert!(merged.functions.enabled_categories.is_none());
773        assert!(merged.queries.libraries.is_empty());
774        assert!(merged.queries.inline.is_empty());
775    }
776
777    #[test]
778    fn test_merge_inline_queries_override() {
779        let base = EngineConfig {
780            queries: QueriesSection {
781                inline: {
782                    let mut m = HashMap::new();
783                    m.insert(
784                        "count".to_string(),
785                        InlineQuery {
786                            expression: "length(@)".to_string(),
787                            description: Some("Original".to_string()),
788                        },
789                    );
790                    m
791                },
792                ..Default::default()
793            },
794            ..Default::default()
795        };
796        let overlay = EngineConfig {
797            queries: QueriesSection {
798                inline: {
799                    let mut m = HashMap::new();
800                    m.insert(
801                        "count".to_string(),
802                        InlineQuery {
803                            expression: "length(keys(@))".to_string(),
804                            description: Some("Overridden".to_string()),
805                        },
806                    );
807                    m
808                },
809                ..Default::default()
810            },
811            ..Default::default()
812        };
813        let merged = base.merge(overlay);
814
815        let count_query = merged.queries.inline.get("count").unwrap();
816        assert_eq!(
817            count_query.expression, "length(keys(@))",
818            "Later inline query should override earlier one with same name"
819        );
820        assert_eq!(
821            count_query.description.as_deref(),
822            Some("Overridden"),
823            "Description should also be from the later config"
824        );
825    }
826
827    #[test]
828    fn test_build_runtime_strict() {
829        let functions_config = FunctionsSection::default();
830        let (runtime, registry) = build_runtime_from_config(&functions_config, true);
831
832        // Registry should have extension functions registered
833        assert!(
834            registry.is_enabled("upper"),
835            "Registry should know about upper even in strict mode"
836        );
837
838        // But the runtime should NOT have extension functions applied.
839        // Compiling a standard expression should work (the parser is always available).
840        assert!(
841            runtime.compile("length(@)").is_ok(),
842            "Compiling a standard expression should succeed"
843        );
844
845        // Evaluating with an extension function should fail because it was never
846        // applied to the runtime.
847        let expr = runtime.compile("upper('hello')").unwrap();
848        let data = serde_json::json!("ignored");
849        let result = expr.search(&data);
850        assert!(
851            result.is_err(),
852            "upper should not be callable on the runtime in strict mode"
853        );
854    }
855
856    #[test]
857    fn test_build_runtime_disabled_category() {
858        let functions_config = FunctionsSection {
859            disabled_categories: vec!["Geo".to_string()],
860            ..Default::default()
861        };
862        let (_runtime, registry) = build_runtime_from_config(&functions_config, false);
863
864        // Geo functions should be disabled in the registry
865        assert!(
866            !registry.is_enabled("geo_distance"),
867            "geo_distance should be disabled when Geo category is disabled"
868        );
869
870        // Non-geo functions should still be enabled
871        assert!(
872            registry.is_enabled("upper"),
873            "upper should still be enabled when only Geo is disabled"
874        );
875    }
876}