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