1use 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#[derive(Debug, Clone, Default, Deserialize)]
43#[serde(default)]
44pub struct EngineConfig {
45 pub engine: EngineSection,
47 pub functions: FunctionsSection,
49 pub queries: QueriesSection,
51}
52
53#[derive(Debug, Clone, Default, Deserialize)]
55#[serde(default)]
56pub struct EngineSection {
57 pub strict: bool,
59}
60
61#[derive(Debug, Clone, Default, Deserialize)]
67#[serde(default)]
68pub struct FunctionsSection {
69 pub disabled_categories: Vec<String>,
71 pub disabled_functions: Vec<String>,
73 pub enabled_categories: Option<Vec<String>>,
76}
77
78#[derive(Debug, Clone, Default, Deserialize)]
80#[serde(default)]
81pub struct QueriesSection {
82 pub libraries: Vec<String>,
84 pub inline: HashMap<String, InlineQuery>,
86}
87
88#[derive(Debug, Clone, Deserialize)]
90pub struct InlineQuery {
91 pub expression: String,
93 #[serde(default)]
95 pub description: Option<String>,
96}
97
98impl EngineConfig {
99 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 pub fn discover() -> crate::Result<Self> {
116 let mut config = Self::default();
117
118 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 if let Some(local_path) = find_project_config() {
128 let local = Self::from_file(&local_path)?;
129 config = config.merge(local);
130 }
131
132 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 pub fn merge(mut self, other: Self) -> Self {
153 if other.engine.strict {
155 self.engine.strict = true;
156 }
157
158 if let Some(enabled) = other.functions.enabled_categories {
160 self.functions.enabled_categories = Some(enabled);
162 self.functions.disabled_categories.clear();
164 } else {
165 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 self.queries.libraries.extend(other.queries.libraries);
180 self.queries.inline.extend(other.queries.inline);
181
182 self
183 }
184}
185
186pub struct EngineBuilder {
201 config: EngineConfig,
202}
203
204impl EngineBuilder {
205 pub fn new() -> Self {
207 Self {
208 config: EngineConfig::default(),
209 }
210 }
211
212 pub fn strict(mut self, strict: bool) -> Self {
214 self.config.engine.strict = strict;
215 self
216 }
217
218 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 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 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 pub fn load_library(mut self, path: &str) -> Self {
245 self.config.queries.libraries.push(path.to_string());
246 self
247 }
248
249 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 pub fn config(mut self, config: EngineConfig) -> Self {
263 self.config = self.config.merge(config);
264 self
265 }
266
267 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
279pub 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 for cat_name in enabled_cats {
304 if let Some(cat) = parse_category(cat_name) {
305 registry.register_category(cat);
306 }
307 }
308 registry.register_category(jpx_core::Category::Standard);
310 } else {
311 registry.register_all();
313
314 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 for func_name in &functions_config.disabled_functions {
330 registry.disable_function(func_name);
331 }
332
333 if !strict {
335 registry.apply(&mut runtime);
336 }
337
338 (runtime, registry)
339}
340
341pub fn load_queries_into_store(
343 queries_config: &QueriesSection,
344 runtime: &Runtime,
345 queries: &Arc<RwLock<crate::QueryStore>>,
346) -> crate::Result<()> {
347 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; }
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 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 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 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
400fn global_config_path() -> Option<PathBuf> {
406 dirs::config_dir().map(|d| d.join("jpx").join("jpx.toml"))
407}
408
409fn 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
422fn 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); assert_eq!(merged.functions.disabled_functions.len(), 2); }
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 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 assert!(engine.describe_function("upper").is_none());
587
588 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 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 assert!(engine.describe_function("geo_distance").is_none());
674
675 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 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 assert!(
696 engine.describe_function("upper").is_some(),
697 "upper should be available when string category is enabled"
698 );
699
700 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 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 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 assert!(
798 registry.is_enabled("upper"),
799 "Registry should know about upper even in strict mode"
800 );
801
802 assert!(
805 runtime.compile("length(@)").is_ok(),
806 "Compiling a standard expression should succeed"
807 );
808
809 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 assert!(
830 !registry.is_enabled("geo_distance"),
831 "geo_distance should be disabled when Geo category is disabled"
832 );
833
834 assert!(
836 registry.is_enabled("upper"),
837 "upper should still be enabled when only Geo is disabled"
838 );
839 }
840}