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: Option<bool>,
63}
64
65#[derive(Debug, Clone, Default, Deserialize)]
71#[serde(default)]
72pub struct FunctionsSection {
73 pub disabled_categories: Vec<String>,
75 pub disabled_functions: Vec<String>,
77 pub enabled_categories: Option<Vec<String>>,
80}
81
82#[derive(Debug, Clone, Default, Deserialize)]
84#[serde(default)]
85pub struct QueriesSection {
86 pub libraries: Vec<String>,
88 pub inline: HashMap<String, InlineQuery>,
90}
91
92#[derive(Debug, Clone, Deserialize)]
94pub struct InlineQuery {
95 pub expression: String,
97 #[serde(default)]
99 pub description: Option<String>,
100}
101
102impl EngineConfig {
103 pub fn is_strict(&self) -> bool {
106 self.engine.strict.unwrap_or(false)
107 }
108
109 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 pub fn discover() -> crate::Result<Self> {
126 let mut config = Self::default();
127
128 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 if let Some(local_path) = find_project_config() {
138 let local = Self::from_file(&local_path)?;
139 config = config.merge(local);
140 }
141
142 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 pub fn merge(mut self, other: Self) -> Self {
163 if other.engine.strict.is_some() {
167 self.engine.strict = other.engine.strict;
168 }
169
170 if let Some(enabled) = other.functions.enabled_categories {
172 self.functions.enabled_categories = Some(enabled);
174 self.functions.disabled_categories.clear();
176 } else {
177 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 self.queries.libraries.extend(other.queries.libraries);
192 self.queries.inline.extend(other.queries.inline);
193
194 self
195 }
196}
197
198pub struct EngineBuilder {
213 config: EngineConfig,
214}
215
216impl EngineBuilder {
217 pub fn new() -> Self {
219 Self {
220 config: EngineConfig::default(),
221 }
222 }
223
224 pub fn strict(mut self, strict: bool) -> Self {
226 self.config.engine.strict = Some(strict);
227 self
228 }
229
230 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 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 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 pub fn load_library(mut self, path: &str) -> Self {
257 self.config.queries.libraries.push(path.to_string());
258 self
259 }
260
261 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 pub fn config(mut self, config: EngineConfig) -> Self {
275 self.config = self.config.merge(config);
276 self
277 }
278
279 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
291pub 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 for cat_name in enabled_cats {
316 if let Some(cat) = parse_category(cat_name) {
317 registry.register_category(cat);
318 }
319 }
320 registry.register_category(jpx_core::Category::Standard);
322 } else {
323 registry.register_all();
325
326 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 for func_name in &functions_config.disabled_functions {
342 registry.disable_function(func_name);
343 }
344
345 if !strict {
347 registry.apply(&mut runtime);
348 }
349
350 (runtime, registry)
351}
352
353pub fn load_queries_into_store(
355 queries_config: &QueriesSection,
356 runtime: &Runtime,
357 queries: &Arc<RwLock<crate::QueryStore>>,
358) -> crate::Result<()> {
359 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; }
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 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 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 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
412fn global_config_path() -> Option<PathBuf> {
418 dirs::config_dir().map(|d| d.join("jpx").join("jpx.toml"))
419}
420
421fn 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
434fn 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 assert!(!strict_on().merge(strict_off()).is_strict());
514 assert!(strict_off().merge(strict_on()).is_strict());
516 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); assert_eq!(merged.functions.disabled_functions.len(), 2); }
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 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 assert!(engine.describe_function("upper").is_none());
623
624 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 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 assert!(engine.describe_function("geo_distance").is_none());
710
711 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 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 assert!(
732 engine.describe_function("upper").is_some(),
733 "upper should be available when string category is enabled"
734 );
735
736 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 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 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 assert!(
834 registry.is_enabled("upper"),
835 "Registry should know about upper even in strict mode"
836 );
837
838 assert!(
841 runtime.compile("length(@)").is_ok(),
842 "Compiling a standard expression should succeed"
843 );
844
845 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 assert!(
866 !registry.is_enabled("geo_distance"),
867 "geo_distance should be disabled when Geo category is disabled"
868 );
869
870 assert!(
872 registry.is_enabled("upper"),
873 "upper should still be enabled when only Geo is disabled"
874 );
875 }
876}