use crate::JpxEngine;
use crate::error::EngineError;
use jpx_core::query_library::QueryLibrary;
use jpx_core::{FunctionRegistry, Runtime};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct EngineConfig {
pub engine: EngineSection,
pub functions: FunctionsSection,
pub queries: QueriesSection,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct EngineSection {
pub strict: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct FunctionsSection {
pub disabled_categories: Vec<String>,
pub disabled_functions: Vec<String>,
pub enabled_categories: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct QueriesSection {
pub libraries: Vec<String>,
pub inline: HashMap<String, InlineQuery>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct InlineQuery {
pub expression: String,
#[serde(default)]
pub description: Option<String>,
}
impl EngineConfig {
pub fn from_file(path: &Path) -> crate::Result<Self> {
let content = std::fs::read_to_string(path).map_err(|e| {
EngineError::ConfigError(format!("Failed to read {}: {}", path.display(), e))
})?;
toml::from_str(&content).map_err(|e| {
EngineError::ConfigError(format!("Failed to parse {}: {}", path.display(), e))
})
}
pub fn discover() -> crate::Result<Self> {
let mut config = Self::default();
if let Some(global_path) = global_config_path()
&& global_path.exists()
{
let global = Self::from_file(&global_path)?;
config = config.merge(global);
}
if let Some(local_path) = find_project_config() {
let local = Self::from_file(&local_path)?;
config = config.merge(local);
}
if let Ok(env_path) = std::env::var("JPX_CONFIG") {
let path = PathBuf::from(&env_path);
if path.exists() {
let env_config = Self::from_file(&path)?;
config = config.merge(env_config);
}
}
Ok(config)
}
pub fn merge(mut self, other: Self) -> Self {
if other.engine.strict {
self.engine.strict = true;
}
if let Some(enabled) = other.functions.enabled_categories {
self.functions.enabled_categories = Some(enabled);
self.functions.disabled_categories.clear();
} else {
for cat in other.functions.disabled_categories {
if !self.functions.disabled_categories.contains(&cat) {
self.functions.disabled_categories.push(cat);
}
}
}
for func in other.functions.disabled_functions {
if !self.functions.disabled_functions.contains(&func) {
self.functions.disabled_functions.push(func);
}
}
self.queries.libraries.extend(other.queries.libraries);
self.queries.inline.extend(other.queries.inline);
self
}
}
pub struct EngineBuilder {
config: EngineConfig,
}
impl EngineBuilder {
pub fn new() -> Self {
Self {
config: EngineConfig::default(),
}
}
pub fn strict(mut self, strict: bool) -> Self {
self.config.engine.strict = strict;
self
}
pub fn disable_category(mut self, cat: &str) -> Self {
let cat = cat.to_string();
if !self.config.functions.disabled_categories.contains(&cat) {
self.config.functions.disabled_categories.push(cat);
}
self
}
pub fn disable_function(mut self, name: &str) -> Self {
let name = name.to_string();
if !self.config.functions.disabled_functions.contains(&name) {
self.config.functions.disabled_functions.push(name);
}
self
}
pub fn enable_categories(mut self, cats: Vec<String>) -> Self {
self.config.functions.enabled_categories = Some(cats);
self.config.functions.disabled_categories.clear();
self
}
pub fn load_library(mut self, path: &str) -> Self {
self.config.queries.libraries.push(path.to_string());
self
}
pub fn inline_query(mut self, name: &str, expr: &str, desc: Option<&str>) -> Self {
self.config.queries.inline.insert(
name.to_string(),
InlineQuery {
expression: expr.to_string(),
description: desc.map(|s| s.to_string()),
},
);
self
}
pub fn config(mut self, config: EngineConfig) -> Self {
self.config = self.config.merge(config);
self
}
pub fn build(self) -> crate::Result<JpxEngine> {
JpxEngine::from_config(self.config)
}
}
impl Default for EngineBuilder {
fn default() -> Self {
Self::new()
}
}
pub fn build_runtime_from_config(
functions_config: &FunctionsSection,
strict: bool,
) -> (Runtime, FunctionRegistry) {
use crate::introspection::parse_category;
let mut runtime = Runtime::new();
runtime.register_builtin_functions();
let mut registry = FunctionRegistry::new();
if let Some(ref enabled_cats) = functions_config.enabled_categories {
for cat_name in enabled_cats {
if let Some(cat) = parse_category(cat_name) {
registry.register_category(cat);
}
}
registry.register_category(jpx_core::Category::Standard);
} else {
registry.register_all();
for cat_name in &functions_config.disabled_categories {
if let Some(cat) = parse_category(cat_name) {
let names: Vec<String> = registry
.functions_in_category(cat)
.map(|f| f.name.to_string())
.collect();
for name in &names {
registry.disable_function(name);
}
}
}
}
for func_name in &functions_config.disabled_functions {
registry.disable_function(func_name);
}
if !strict {
registry.apply(&mut runtime);
}
(runtime, registry)
}
pub fn load_queries_into_store(
queries_config: &QueriesSection,
runtime: &Runtime,
queries: &Arc<RwLock<crate::QueryStore>>,
) -> crate::Result<()> {
for lib_path in &queries_config.libraries {
let expanded = expand_tilde(lib_path);
let path = Path::new(&expanded);
if !path.exists() {
continue; }
let content = std::fs::read_to_string(path).map_err(|e| {
EngineError::ConfigError(format!("Failed to read {}: {}", path.display(), e))
})?;
let library = QueryLibrary::parse(&content).map_err(|e| {
EngineError::ConfigError(format!("Failed to parse {}: {}", path.display(), e))
})?;
let mut store = queries
.write()
.map_err(|e| EngineError::Internal(e.to_string()))?;
for named_query in library.list() {
if runtime.compile(&named_query.expression).is_ok() {
store.define(crate::StoredQuery {
name: named_query.name.clone(),
expression: named_query.expression.clone(),
description: named_query.description.clone(),
});
}
}
}
if !queries_config.inline.is_empty() {
let mut store = queries
.write()
.map_err(|e| EngineError::Internal(e.to_string()))?;
for (name, query) in &queries_config.inline {
if runtime.compile(&query.expression).is_ok() {
store.define(crate::StoredQuery {
name: name.clone(),
expression: query.expression.clone(),
description: query.description.clone(),
});
}
}
}
Ok(())
}
fn global_config_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("jpx").join("jpx.toml"))
}
fn find_project_config() -> Option<PathBuf> {
let cwd = std::env::current_dir().ok()?;
let mut dir = cwd.as_path();
loop {
let candidate = dir.join("jpx.toml");
if candidate.exists() {
return Some(candidate);
}
dir = dir.parent()?;
}
}
fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest).to_string_lossy().into_owned();
}
path.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = EngineConfig::default();
assert!(!config.engine.strict);
assert!(config.functions.disabled_categories.is_empty());
assert!(config.functions.disabled_functions.is_empty());
assert!(config.functions.enabled_categories.is_none());
assert!(config.queries.libraries.is_empty());
assert!(config.queries.inline.is_empty());
}
#[test]
fn test_parse_config() {
let toml = r#"
[engine]
strict = true
[functions]
disabled_categories = ["geo", "phonetic"]
disabled_functions = ["env"]
[queries]
libraries = ["~/.config/jpx/common.jpx"]
[queries.inline]
active-users = { expression = "users[?active].name", description = "Get active user names" }
"#;
let config: EngineConfig = toml::from_str(toml).unwrap();
assert!(config.engine.strict);
assert_eq!(
config.functions.disabled_categories,
vec!["geo", "phonetic"]
);
assert_eq!(config.functions.disabled_functions, vec!["env"]);
assert_eq!(config.queries.libraries.len(), 1);
assert!(config.queries.inline.contains_key("active-users"));
}
#[test]
fn test_merge_scalars() {
let base = EngineConfig::default();
let overlay = EngineConfig {
engine: EngineSection { strict: true },
..Default::default()
};
let merged = base.merge(overlay);
assert!(merged.engine.strict);
}
#[test]
fn test_merge_disabled_union() {
let base = EngineConfig {
functions: FunctionsSection {
disabled_categories: vec!["geo".to_string()],
disabled_functions: vec!["env".to_string()],
..Default::default()
},
..Default::default()
};
let overlay = EngineConfig {
functions: FunctionsSection {
disabled_categories: vec!["geo".to_string(), "phonetic".to_string()],
disabled_functions: vec!["uuid".to_string()],
..Default::default()
},
..Default::default()
};
let merged = base.merge(overlay);
assert_eq!(merged.functions.disabled_categories.len(), 2); assert_eq!(merged.functions.disabled_functions.len(), 2); }
#[test]
fn test_merge_enabled_replaces() {
let base = EngineConfig {
functions: FunctionsSection {
disabled_categories: vec!["geo".to_string()],
..Default::default()
},
..Default::default()
};
let overlay = EngineConfig {
functions: FunctionsSection {
enabled_categories: Some(vec!["string".to_string(), "math".to_string()]),
..Default::default()
},
..Default::default()
};
let merged = base.merge(overlay);
assert_eq!(
merged.functions.enabled_categories,
Some(vec!["string".to_string(), "math".to_string()])
);
assert!(merged.functions.disabled_categories.is_empty());
}
#[test]
fn test_merge_queries_concat() {
let base = EngineConfig {
queries: QueriesSection {
libraries: vec!["a.jpx".to_string()],
..Default::default()
},
..Default::default()
};
let overlay = EngineConfig {
queries: QueriesSection {
libraries: vec!["b.jpx".to_string()],
..Default::default()
},
..Default::default()
};
let merged = base.merge(overlay);
assert_eq!(merged.queries.libraries, vec!["a.jpx", "b.jpx"]);
}
#[test]
fn test_builder() {
let engine = EngineBuilder::new()
.strict(false)
.disable_category("geo")
.disable_function("env")
.build()
.unwrap();
let result = engine
.evaluate("length(@)", &serde_json::json!([1, 2, 3]))
.unwrap();
assert_eq!(result, serde_json::json!(3));
}
#[test]
fn test_builder_strict() {
let engine = EngineBuilder::new().strict(true).build().unwrap();
assert!(engine.is_strict());
}
#[test]
fn test_from_config_with_disabled_functions() {
let config = EngineConfig {
functions: FunctionsSection {
disabled_functions: vec!["upper".to_string()],
..Default::default()
},
..Default::default()
};
let engine = JpxEngine::from_config(config).unwrap();
assert!(engine.describe_function("upper").is_none());
let result = engine
.evaluate("length(@)", &serde_json::json!([1, 2, 3]))
.unwrap();
assert_eq!(result, serde_json::json!(3));
}
#[test]
fn test_from_config_with_inline_queries() {
let config = EngineConfig {
queries: QueriesSection {
inline: {
let mut m = HashMap::new();
m.insert(
"count".to_string(),
InlineQuery {
expression: "length(@)".to_string(),
description: Some("Count items".to_string()),
},
);
m
},
..Default::default()
},
..Default::default()
};
let engine = JpxEngine::from_config(config).unwrap();
let result = engine
.run_query("count", &serde_json::json!([1, 2, 3]))
.unwrap();
assert_eq!(result, serde_json::json!(3));
}
#[test]
fn test_expand_tilde() {
let result = expand_tilde("/absolute/path");
assert_eq!(result, "/absolute/path");
let result = expand_tilde("relative/path");
assert_eq!(result, "relative/path");
let result = expand_tilde("~/some/path");
if let Some(home) = dirs::home_dir() {
assert_eq!(result, home.join("some/path").to_string_lossy().as_ref());
}
}
#[test]
fn test_invalid_toml() {
let bad_toml = r#"
[engine
strict = true
"#;
let result: Result<EngineConfig, _> = toml::from_str(bad_toml);
assert!(
result.is_err(),
"Parsing invalid TOML should return an error"
);
}
#[test]
fn test_from_file_missing() {
let path = Path::new("/tmp/nonexistent_jpx_config_test_file.toml");
let result = EngineConfig::from_file(path);
assert!(
result.is_err(),
"from_file on nonexistent path should return Err"
);
}
#[test]
fn test_builder_chaining() {
let builder = EngineBuilder::new()
.disable_category("geo")
.disable_category("phonetic")
.disable_category("semver")
.disable_function("env")
.disable_function("upper")
.disable_function("lower");
let engine = builder.build().unwrap();
assert!(engine.describe_function("geo_distance").is_none());
assert!(engine.describe_function("env").is_none());
assert!(engine.describe_function("upper").is_none());
assert!(engine.describe_function("lower").is_none());
let result = engine
.evaluate("length(@)", &serde_json::json!([1, 2, 3]))
.unwrap();
assert_eq!(result, serde_json::json!(3));
}
#[test]
fn test_builder_enable_categories() {
let engine = EngineBuilder::new()
.enable_categories(vec!["string".to_string(), "math".to_string()])
.build()
.unwrap();
assert!(
engine.describe_function("upper").is_some(),
"upper should be available when string category is enabled"
);
assert!(
engine.describe_function("geo_distance").is_none(),
"geo_distance should not be available when only string and math are enabled"
);
}
#[test]
fn test_builder_inline_query() {
let engine = EngineBuilder::new()
.inline_query("count", "length(@)", Some("Count items"))
.inline_query("names", "people[*].name", None)
.build()
.unwrap();
let result = engine
.run_query("count", &serde_json::json!([1, 2, 3]))
.unwrap();
assert_eq!(result, serde_json::json!(3));
let data = serde_json::json!({"people": [{"name": "alice"}, {"name": "bob"}]});
let result = engine.run_query("names", &data).unwrap();
assert_eq!(result, serde_json::json!(["alice", "bob"]));
}
#[test]
fn test_merge_deep_both_empty() {
let a = EngineConfig::default();
let b = EngineConfig::default();
let merged = a.merge(b);
assert!(!merged.engine.strict);
assert!(merged.functions.disabled_categories.is_empty());
assert!(merged.functions.disabled_functions.is_empty());
assert!(merged.functions.enabled_categories.is_none());
assert!(merged.queries.libraries.is_empty());
assert!(merged.queries.inline.is_empty());
}
#[test]
fn test_merge_inline_queries_override() {
let base = EngineConfig {
queries: QueriesSection {
inline: {
let mut m = HashMap::new();
m.insert(
"count".to_string(),
InlineQuery {
expression: "length(@)".to_string(),
description: Some("Original".to_string()),
},
);
m
},
..Default::default()
},
..Default::default()
};
let overlay = EngineConfig {
queries: QueriesSection {
inline: {
let mut m = HashMap::new();
m.insert(
"count".to_string(),
InlineQuery {
expression: "length(keys(@))".to_string(),
description: Some("Overridden".to_string()),
},
);
m
},
..Default::default()
},
..Default::default()
};
let merged = base.merge(overlay);
let count_query = merged.queries.inline.get("count").unwrap();
assert_eq!(
count_query.expression, "length(keys(@))",
"Later inline query should override earlier one with same name"
);
assert_eq!(
count_query.description.as_deref(),
Some("Overridden"),
"Description should also be from the later config"
);
}
#[test]
fn test_build_runtime_strict() {
let functions_config = FunctionsSection::default();
let (runtime, registry) = build_runtime_from_config(&functions_config, true);
assert!(
registry.is_enabled("upper"),
"Registry should know about upper even in strict mode"
);
assert!(
runtime.compile("length(@)").is_ok(),
"Compiling a standard expression should succeed"
);
let expr = runtime.compile("upper('hello')").unwrap();
let data = serde_json::json!("ignored");
let result = expr.search(&data);
assert!(
result.is_err(),
"upper should not be callable on the runtime in strict mode"
);
}
#[test]
fn test_build_runtime_disabled_category() {
let functions_config = FunctionsSection {
disabled_categories: vec!["Geo".to_string()],
..Default::default()
};
let (_runtime, registry) = build_runtime_from_config(&functions_config, false);
assert!(
!registry.is_enabled("geo_distance"),
"geo_distance should be disabled when Geo category is disabled"
);
assert!(
registry.is_enabled("upper"),
"upper should still be enabled when only Geo is disabled"
);
}
}