jpx_engine/lib.rs
1//! # jpx-engine
2//!
3//! Protocol-agnostic JMESPath query engine with 400+ functions.
4//!
5//! This crate provides the core "brain" of jpx - everything you can do with JMESPath
6//! beyond basic compile and evaluate. It's designed to be transport-agnostic, allowing
7//! the CLI (`jpx`), MCP server (`jpx-mcp`), or any future REST/gRPC adapters to be
8//! thin wrappers over this engine.
9//!
10//! ## Features
11//!
12//! | Category | Description |
13//! |----------|-------------|
14//! | **Evaluation** | Single, batch, and string-based evaluation with validation |
15//! | **Introspection** | List functions, search by keyword, describe, find similar |
16//! | **Discovery** | Cross-server tool discovery with BM25 search indexing |
17//! | **Query Store** | Named queries for session-scoped reuse |
18//! | **Configuration** | Declarative `jpx.toml` config with layered discovery and merge |
19//! | **JSON Utilities** | Format, diff, patch, merge, stats, paths, keys |
20//! | **Arrow** | Apache Arrow conversion (optional, via `arrow` feature) |
21//!
22//! ## Cargo Features
23//!
24//! - **`arrow`** - Enables Apache Arrow support for columnar data conversion.
25//! This adds the `arrow` module with functions to convert between Arrow
26//! RecordBatches and JSON Values. Used by the CLI for Parquet I/O.
27//! - **`let-expr`** - Enables `let` expression support (variable bindings in
28//! JMESPath expressions). Forwarded from jpx-core. Enabled by default.
29//! - **`schema`** - Derives `JsonSchema` on discovery types for JSON Schema
30//! generation. Used by the MCP server for tool schemas.
31//!
32//! ## Quick Start
33//!
34//! ```rust
35//! use jpx_engine::JpxEngine;
36//! use serde_json::json;
37//!
38//! let engine = JpxEngine::new();
39//!
40//! // Evaluate a JMESPath expression
41//! let result = engine.evaluate("users[*].name", &json!({
42//! "users": [{"name": "alice"}, {"name": "bob"}]
43//! })).unwrap();
44//! assert_eq!(result, json!(["alice", "bob"]));
45//! ```
46//!
47//! ## Evaluation
48//!
49//! The engine supports multiple evaluation modes:
50//!
51//! ```rust
52//! use jpx_engine::JpxEngine;
53//! use serde_json::json;
54//!
55//! let engine = JpxEngine::new();
56//!
57//! // From parsed JSON
58//! let data = json!({"items": [1, 2, 3]});
59//! let result = engine.evaluate("length(items)", &data).unwrap();
60//! assert_eq!(result, json!(3));
61//!
62//! // From JSON string
63//! let result = engine.evaluate_str("length(@)", r#"[1, 2, 3]"#).unwrap();
64//! assert_eq!(result, json!(3));
65//!
66//! // Batch evaluation (multiple expressions, same input)
67//! let exprs = vec!["a".to_string(), "b".to_string()];
68//! let batch = engine.batch_evaluate(&exprs, &json!({"a": 1, "b": 2}));
69//! assert_eq!(batch.results[0].result, Some(json!(1)));
70//!
71//! // Validation without evaluation
72//! let valid = engine.validate("users[*].name");
73//! assert!(valid.valid);
74//! ```
75//!
76//! ## Function Introspection
77//!
78//! Discover and explore the 400+ available functions:
79//!
80//! ```rust
81//! use jpx_engine::JpxEngine;
82//!
83//! let engine = JpxEngine::new();
84//!
85//! // List all categories
86//! let categories = engine.categories();
87//! assert!(categories.contains(&"String".to_string()));
88//!
89//! // List functions in a category
90//! let string_funcs = engine.functions(Some("String"));
91//! assert!(string_funcs.iter().any(|f| f.name == "upper"));
92//!
93//! // Search by keyword (fuzzy matching, synonyms)
94//! let results = engine.search_functions("upper", 5);
95//! assert!(results.iter().any(|r| r.function.name == "upper"));
96//!
97//! // Get detailed function info
98//! let info = engine.describe_function("upper").unwrap();
99//! assert_eq!(info.category, "String");
100//!
101//! // Find similar functions
102//! let similar = engine.similar_functions("upper").unwrap();
103//! assert!(!similar.same_category.is_empty());
104//! ```
105//!
106//! ## JSON Utilities
107//!
108//! Beyond JMESPath evaluation, the engine provides JSON manipulation tools:
109//!
110//! ```rust
111//! use jpx_engine::JpxEngine;
112//!
113//! let engine = JpxEngine::new();
114//!
115//! // Pretty-print JSON
116//! let formatted = engine.format_json(r#"{"a":1}"#, 2).unwrap();
117//! assert!(formatted.contains('\n'));
118//!
119//! // Generate JSON Patch (RFC 6902)
120//! let patch = engine.diff(r#"{"a": 1}"#, r#"{"a": 2}"#).unwrap();
121//!
122//! // Apply JSON Patch
123//! let result = engine.patch(
124//! r#"{"a": 1}"#,
125//! r#"[{"op": "replace", "path": "/a", "value": 2}]"#
126//! ).unwrap();
127//!
128//! // Apply JSON Merge Patch (RFC 7396)
129//! let merged = engine.merge(
130//! r#"{"a": 1, "b": 2}"#,
131//! r#"{"b": 3, "c": 4}"#
132//! ).unwrap();
133//!
134//! // Analyze JSON structure
135//! let stats = engine.stats(r#"[1, 2, 3]"#).unwrap();
136//! assert_eq!(stats.root_type, "array");
137//! ```
138//!
139//! ## Query Store
140//!
141//! Store and reuse named queries within a session:
142//!
143//! ```rust
144//! use jpx_engine::JpxEngine;
145//! use serde_json::json;
146//!
147//! let engine = JpxEngine::new();
148//!
149//! // Define a reusable query
150//! engine.define_query(
151//! "active_users".to_string(),
152//! "users[?active].name".to_string(),
153//! Some("Get names of active users".to_string())
154//! ).unwrap();
155//!
156//! // Run it by name
157//! let data = json!({"users": [
158//! {"name": "alice", "active": true},
159//! {"name": "bob", "active": false}
160//! ]});
161//! let result = engine.run_query("active_users", &data).unwrap();
162//! assert_eq!(result, json!(["alice"]));
163//!
164//! // List all stored queries
165//! let queries = engine.list_queries().unwrap();
166//! assert_eq!(queries.len(), 1);
167//! ```
168//!
169//! ## Configuration
170//!
171//! Load engine settings from `jpx.toml` files with layered discovery:
172//!
173//! ```rust
174//! use jpx_engine::{JpxEngine, EngineConfig};
175//!
176//! // Discover config from standard locations
177//! // (~/.config/jpx/jpx.toml, ./jpx.toml, $JPX_CONFIG)
178//! let config = EngineConfig::discover().unwrap();
179//! let engine = JpxEngine::from_config(config).unwrap();
180//!
181//! // Or use the builder for programmatic configuration
182//! let engine = JpxEngine::builder()
183//! .strict(false)
184//! .disable_category("geo")
185//! .disable_function("env")
186//! .build()
187//! .unwrap();
188//! ```
189//!
190//! ## Tool Discovery
191//!
192//! Register and search tools across multiple servers (for MCP integration):
193//!
194//! ```rust
195//! use jpx_engine::{JpxEngine, DiscoverySpec};
196//! use serde_json::json;
197//!
198//! let engine = JpxEngine::new();
199//!
200//! // Register a server's tools
201//! let spec: DiscoverySpec = serde_json::from_value(json!({
202//! "server": {"name": "my-server", "version": "1.0.0"},
203//! "tools": [
204//! {"name": "create_user", "description": "Create a new user", "tags": ["write"]}
205//! ]
206//! })).unwrap();
207//!
208//! let result = engine.register_discovery(spec, false).unwrap();
209//! assert!(result.ok);
210//!
211//! // Search across registered tools
212//! let tools = engine.query_tools("user", 10).unwrap();
213//! assert!(!tools.is_empty());
214//! ```
215//!
216//! ## Strict Mode
217//!
218//! For standard JMESPath compliance without extensions:
219//!
220//! ```rust
221//! use jpx_engine::JpxEngine;
222//! use serde_json::json;
223//!
224//! let engine = JpxEngine::strict();
225//! assert!(engine.is_strict());
226//!
227//! // Standard functions work
228//! let result = engine.evaluate("length(@)", &json!([1, 2, 3])).unwrap();
229//! assert_eq!(result, json!(3));
230//!
231//! // Extension functions are not available for evaluation
232//! // (but introspection still works for documentation purposes)
233//! ```
234//!
235//! ## Architecture
236//!
237//! ```text
238//! jpx-core (parser, runtime, 400+ functions, registry)
239//! |
240//! jpx-engine (this crate - evaluation, search, discovery, config)
241//! |
242//! +----+----+
243//! | |
244//! jpx jpx-mcp (CLI and MCP transport)
245//! ```
246//!
247//! ## Thread Safety
248//!
249//! The engine uses interior mutability (`Arc<RwLock<...>>`) for the discovery
250//! registry and query store, making it safe to share across threads. The function
251//! registry is immutable after construction.
252
253mod bm25;
254pub mod config;
255mod discovery;
256mod error;
257mod eval;
258mod explain;
259mod introspection;
260mod json_utils;
261mod query_store;
262mod types;
263
264#[cfg(feature = "arrow")]
265pub mod arrow;
266
267pub use bm25::{Bm25Index, DocInfo, IndexOptions, SearchResult as Bm25SearchResult, TermInfo};
268pub use config::{EngineBuilder, EngineConfig, EngineSection, FunctionsSection, QueriesSection};
269pub use discovery::{
270 CategoryInfo, CategorySummary, DiscoveryRegistry, DiscoverySpec, ExampleSpec, IndexStats,
271 ParamSpec, RegistrationResult, ReturnSpec, ServerInfo, ServerSummary, ToolQueryResult,
272 ToolSpec,
273};
274pub use error::{EngineError, EvaluationErrorKind, Result};
275pub use explain::{ExplainResult, ExplainStep, has_let_nodes};
276pub use introspection::{FunctionDetail, SearchResult, SimilarFunctionsResult};
277pub use json_utils::{FieldAnalysis, PathInfo, StatsResult};
278pub use query_store::{QueryStore, StoredQuery};
279pub use types::{
280 BatchEvaluateResult, BatchExpressionResult, EvalRequest, EvalResponse, ValidationResult,
281};
282
283use discovery::DiscoveryRegistry as DiscoveryRegistryInner;
284use error::EngineError as EngineErrorInner;
285use query_store::QueryStore as QueryStoreInner;
286use serde_json::Value;
287use std::collections::HashMap;
288use std::sync::{Arc, RwLock};
289
290// Re-export commonly used types from jpx-core
291pub use jpx_core::ast;
292pub use jpx_core::query_library;
293pub use jpx_core::{Category, Expression, FunctionInfo, FunctionRegistry, Runtime, compile, parse};
294
295/// The JMESPath query engine.
296///
297/// `JpxEngine` is the main entry point for all jpx functionality. It combines:
298///
299/// - **JMESPath runtime** with 400+ extension functions
300/// - **Function registry** for introspection and search
301/// - **Discovery registry** for cross-server tool indexing
302/// - **Query store** for named query management
303///
304/// # Construction
305///
306/// ```rust
307/// use jpx_engine::JpxEngine;
308///
309/// // Full engine with all extensions
310/// let engine = JpxEngine::new();
311///
312/// // Strict mode (standard JMESPath only)
313/// let strict_engine = JpxEngine::strict();
314///
315/// // Or using Default
316/// let default_engine = JpxEngine::default();
317/// ```
318///
319/// # Thread Safety
320///
321/// The engine is designed to be shared across threads. The discovery registry
322/// and query store use `Arc<RwLock<...>>` for interior mutability, while the
323/// function registry is immutable after construction.
324///
325/// ```rust
326/// use jpx_engine::JpxEngine;
327/// use std::sync::Arc;
328///
329/// let engine = Arc::new(JpxEngine::new());
330///
331/// // Clone the Arc to share across threads
332/// let engine_clone = Arc::clone(&engine);
333/// std::thread::spawn(move || {
334/// let result = engine_clone.evaluate("length(@)", &serde_json::json!([1, 2, 3]));
335/// });
336/// ```
337pub struct JpxEngine {
338 /// JMESPath runtime with all extensions registered
339 pub(crate) runtime: Runtime,
340 /// Function registry for introspection
341 pub(crate) registry: FunctionRegistry,
342 /// Discovery registry for cross-server tool search
343 pub(crate) discovery: Arc<RwLock<DiscoveryRegistryInner>>,
344 /// Query store for named queries
345 pub(crate) queries: Arc<RwLock<QueryStoreInner>>,
346 /// Whether to use strict mode (standard JMESPath only)
347 pub(crate) strict: bool,
348}
349
350impl JpxEngine {
351 /// Creates a new engine with all extension functions enabled.
352 ///
353 /// This is the standard way to create an engine with full functionality,
354 /// including all 400+ extension functions.
355 ///
356 /// # Example
357 ///
358 /// ```rust
359 /// use jpx_engine::JpxEngine;
360 /// use serde_json::json;
361 ///
362 /// let engine = JpxEngine::new();
363 ///
364 /// // Standard JMESPath works
365 /// let result = engine.evaluate("name", &json!({"name": "alice"})).unwrap();
366 /// assert_eq!(result, json!("alice"));
367 ///
368 /// // Extension functions also work
369 /// let result = engine.evaluate("upper(name)", &json!({"name": "alice"})).unwrap();
370 /// assert_eq!(result, json!("ALICE"));
371 /// ```
372 pub fn new() -> Self {
373 Self::with_options(false)
374 }
375
376 /// Creates a new engine with configurable strict mode.
377 ///
378 /// # Arguments
379 ///
380 /// * `strict` - If `true`, only standard JMESPath functions are available
381 /// for evaluation. Introspection features still show all functions.
382 ///
383 /// # Example
384 ///
385 /// ```rust
386 /// use jpx_engine::JpxEngine;
387 ///
388 /// // Create engine with extensions
389 /// let full_engine = JpxEngine::with_options(false);
390 ///
391 /// // Create strict engine (standard JMESPath only)
392 /// let strict_engine = JpxEngine::with_options(true);
393 /// assert!(strict_engine.is_strict());
394 /// ```
395 pub fn with_options(strict: bool) -> Self {
396 let mut runtime = Runtime::new();
397 runtime.register_builtin_functions();
398
399 let mut registry = FunctionRegistry::new();
400 registry.register_all();
401
402 if !strict {
403 registry.apply(&mut runtime);
404 }
405
406 Self {
407 runtime,
408 registry,
409 discovery: Arc::new(RwLock::new(DiscoveryRegistryInner::new())),
410 queries: Arc::new(RwLock::new(QueryStoreInner::new())),
411 strict,
412 }
413 }
414
415 /// Creates a new engine in strict mode (standard JMESPath only).
416 ///
417 /// Equivalent to `JpxEngine::with_options(true)`.
418 ///
419 /// # Example
420 ///
421 /// ```rust
422 /// use jpx_engine::JpxEngine;
423 /// use serde_json::json;
424 ///
425 /// let engine = JpxEngine::strict();
426 ///
427 /// // Standard functions work
428 /// let result = engine.evaluate("length(@)", &json!([1, 2, 3])).unwrap();
429 /// assert_eq!(result, json!(3));
430 /// ```
431 pub fn strict() -> Self {
432 Self::with_options(true)
433 }
434
435 /// Creates a new engine from an [`EngineConfig`].
436 ///
437 /// Applies function filtering, loads query libraries and inline queries,
438 /// and sets engine options from the config.
439 ///
440 /// # Example
441 ///
442 /// ```rust
443 /// use jpx_engine::{JpxEngine, EngineConfig};
444 ///
445 /// let config = EngineConfig::default();
446 /// let engine = JpxEngine::from_config(config).unwrap();
447 /// ```
448 pub fn from_config(config: EngineConfig) -> Result<Self> {
449 let strict = config.engine.strict;
450 let (runtime, registry) = config::build_runtime_from_config(&config.functions, strict);
451
452 let discovery = Arc::new(RwLock::new(DiscoveryRegistryInner::new()));
453 let queries = Arc::new(RwLock::new(QueryStoreInner::new()));
454
455 // Load queries from config
456 config::load_queries_into_store(&config.queries, &runtime, &queries)?;
457
458 Ok(Self {
459 runtime,
460 registry,
461 discovery,
462 queries,
463 strict,
464 })
465 }
466
467 /// Returns a builder for constructing a `JpxEngine` with programmatic overrides.
468 ///
469 /// # Example
470 ///
471 /// ```rust
472 /// use jpx_engine::JpxEngine;
473 ///
474 /// let engine = JpxEngine::builder()
475 /// .strict(false)
476 /// .disable_category("geo")
477 /// .build()
478 /// .unwrap();
479 /// ```
480 pub fn builder() -> config::EngineBuilder {
481 config::EngineBuilder::new()
482 }
483
484 /// Returns `true` if the engine is in strict mode.
485 ///
486 /// In strict mode, only standard JMESPath functions are available for
487 /// evaluation. Extension functions will cause evaluation errors.
488 pub fn is_strict(&self) -> bool {
489 self.strict
490 }
491
492 /// Returns a reference to the underlying JMESPath runtime.
493 ///
494 /// This provides access to the low-level runtime for advanced use cases.
495 pub fn runtime(&self) -> &Runtime {
496 &self.runtime
497 }
498
499 /// Returns a reference to the function registry.
500 ///
501 /// The registry contains metadata about all available functions and can
502 /// be used for custom introspection beyond what the engine methods provide.
503 pub fn registry(&self) -> &FunctionRegistry {
504 &self.registry
505 }
506
507 /// Returns a reference to the discovery registry.
508 ///
509 /// The discovery registry manages cross-server tool indexing and search.
510 /// Access is through `Arc<RwLock<...>>` for thread-safe mutation.
511 pub fn discovery(&self) -> &Arc<RwLock<DiscoveryRegistryInner>> {
512 &self.discovery
513 }
514
515 /// Returns a reference to the query store.
516 ///
517 /// The query store manages named queries for the session.
518 /// Access is through `Arc<RwLock<...>>` for thread-safe mutation.
519 pub fn queries(&self) -> &Arc<RwLock<QueryStoreInner>> {
520 &self.queries
521 }
522
523 // =========================================================================
524 // Query store methods
525 // =========================================================================
526
527 /// Define (store) a named query.
528 pub fn define_query(
529 &self,
530 name: String,
531 expression: String,
532 description: Option<String>,
533 ) -> Result<Option<StoredQuery>> {
534 // Validate expression first
535 let validation = self.validate(&expression);
536 if !validation.valid {
537 return Err(EngineErrorInner::InvalidExpression(
538 validation
539 .error
540 .unwrap_or_else(|| "Invalid expression".to_string()),
541 ));
542 }
543
544 let query = StoredQuery {
545 name,
546 expression,
547 description,
548 };
549
550 self.queries
551 .write()
552 .map_err(|e| EngineErrorInner::Internal(e.to_string()))?
553 .define(query)
554 .pipe(Ok)
555 }
556
557 /// Get a stored query by name.
558 pub fn get_query(&self, name: &str) -> Result<Option<StoredQuery>> {
559 Ok(self
560 .queries
561 .read()
562 .map_err(|e| EngineErrorInner::Internal(e.to_string()))?
563 .get(name)
564 .cloned())
565 }
566
567 /// Delete a stored query.
568 pub fn delete_query(&self, name: &str) -> Result<Option<StoredQuery>> {
569 Ok(self
570 .queries
571 .write()
572 .map_err(|e| EngineErrorInner::Internal(e.to_string()))?
573 .delete(name))
574 }
575
576 /// List all stored queries.
577 pub fn list_queries(&self) -> Result<Vec<StoredQuery>> {
578 Ok(self
579 .queries
580 .read()
581 .map_err(|e| EngineErrorInner::Internal(e.to_string()))?
582 .list()
583 .into_iter()
584 .cloned()
585 .collect())
586 }
587
588 /// Run a stored query.
589 pub fn run_query(&self, name: &str, input: &Value) -> Result<Value> {
590 let query = self
591 .get_query(name)?
592 .ok_or_else(|| EngineErrorInner::QueryNotFound(name.to_string()))?;
593
594 self.evaluate(&query.expression, input)
595 }
596
597 // =========================================================================
598 // Discovery methods
599 // =========================================================================
600
601 /// Register a discovery spec.
602 pub fn register_discovery(
603 &self,
604 spec: DiscoverySpec,
605 replace: bool,
606 ) -> Result<RegistrationResult> {
607 Ok(self
608 .discovery
609 .write()
610 .map_err(|e| EngineErrorInner::Internal(e.to_string()))?
611 .register(spec, replace))
612 }
613
614 /// Unregister a server from discovery.
615 pub fn unregister_discovery(&self, server_name: &str) -> Result<bool> {
616 Ok(self
617 .discovery
618 .write()
619 .map_err(|e| EngineErrorInner::Internal(e.to_string()))?
620 .unregister(server_name))
621 }
622
623 /// Query tools across registered servers.
624 pub fn query_tools(&self, query: &str, top_k: usize) -> Result<Vec<ToolQueryResult>> {
625 Ok(self
626 .discovery
627 .read()
628 .map_err(|e| EngineErrorInner::Internal(e.to_string()))?
629 .query(query, top_k))
630 }
631
632 /// Find tools similar to a given tool.
633 pub fn similar_tools(&self, tool_id: &str, top_k: usize) -> Result<Vec<ToolQueryResult>> {
634 Ok(self
635 .discovery
636 .read()
637 .map_err(|e| EngineErrorInner::Internal(e.to_string()))?
638 .similar(tool_id, top_k))
639 }
640
641 /// List all registered discovery servers.
642 pub fn list_discovery_servers(&self) -> Result<Vec<ServerSummary>> {
643 Ok(self
644 .discovery
645 .read()
646 .map_err(|e| EngineErrorInner::Internal(e.to_string()))?
647 .list_servers())
648 }
649
650 /// List discovery categories.
651 pub fn list_discovery_categories(&self) -> Result<HashMap<String, CategorySummary>> {
652 Ok(self
653 .discovery
654 .read()
655 .map_err(|e| EngineErrorInner::Internal(e.to_string()))?
656 .list_categories())
657 }
658
659 /// Get discovery index stats.
660 pub fn discovery_index_stats(&self) -> Result<Option<IndexStats>> {
661 Ok(self
662 .discovery
663 .read()
664 .map_err(|e| EngineErrorInner::Internal(e.to_string()))?
665 .index_stats())
666 }
667
668 /// Get the discovery schema.
669 pub fn get_discovery_schema(&self) -> Value {
670 DiscoveryRegistryInner::get_schema()
671 }
672}
673
674impl Default for JpxEngine {
675 fn default() -> Self {
676 Self::new()
677 }
678}
679
680/// Extension trait for pipe-style method chaining
681trait Pipe: Sized {
682 fn pipe<T, F: FnOnce(Self) -> T>(self, f: F) -> T {
683 f(self)
684 }
685}
686
687impl<T> Pipe for T {}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692 use serde_json::json;
693
694 #[test]
695 fn test_engine_creation() {
696 let engine = JpxEngine::new();
697 assert!(!engine.is_strict());
698 }
699
700 #[test]
701 fn test_engine_strict_mode() {
702 let engine = JpxEngine::strict();
703 assert!(engine.is_strict());
704 }
705
706 #[test]
707 fn test_engine_default() {
708 let engine = JpxEngine::default();
709 assert!(!engine.is_strict());
710 }
711
712 #[test]
713 fn test_query_store() {
714 let engine = JpxEngine::new();
715
716 // Define a query
717 engine
718 .define_query("count".to_string(), "length(@)".to_string(), None)
719 .unwrap();
720
721 // Get it
722 let query = engine.get_query("count").unwrap().unwrap();
723 assert_eq!(query.expression, "length(@)");
724
725 // Run it
726 let result = engine.run_query("count", &json!([1, 2, 3])).unwrap();
727 assert_eq!(result, json!(3));
728
729 // List queries
730 let queries = engine.list_queries().unwrap();
731 assert_eq!(queries.len(), 1);
732
733 // Delete it
734 engine.delete_query("count").unwrap();
735 assert!(engine.get_query("count").unwrap().is_none());
736 }
737
738 #[test]
739 fn test_discovery() {
740 let engine = JpxEngine::new();
741
742 let spec: DiscoverySpec = serde_json::from_value(json!({
743 "server": {"name": "test-server", "version": "1.0.0"},
744 "tools": [
745 {"name": "test_tool", "description": "A test tool", "tags": ["test"]}
746 ]
747 }))
748 .unwrap();
749
750 // Register
751 let result = engine.register_discovery(spec, false).unwrap();
752 assert!(result.ok);
753 assert_eq!(result.tools_indexed, 1);
754
755 // List servers
756 let servers = engine.list_discovery_servers().unwrap();
757 assert_eq!(servers.len(), 1);
758
759 // Query tools
760 let tools = engine.query_tools("test", 10).unwrap();
761 assert!(!tools.is_empty());
762
763 // Unregister
764 assert!(engine.unregister_discovery("test-server").unwrap());
765 assert!(engine.list_discovery_servers().unwrap().is_empty());
766 }
767
768 // =========================================================================
769 // Construction tests
770 // =========================================================================
771
772 #[test]
773 fn test_with_options_non_strict() {
774 let engine = JpxEngine::with_options(false);
775 assert!(!engine.is_strict());
776 }
777
778 #[test]
779 fn test_with_options_strict() {
780 let engine = JpxEngine::with_options(true);
781 assert!(engine.is_strict());
782 }
783
784 #[test]
785 fn test_from_config_default() {
786 let config = EngineConfig::default();
787 let engine = JpxEngine::from_config(config).unwrap();
788 assert!(!engine.is_strict());
789 }
790
791 #[test]
792 fn test_builder_default() {
793 let engine = JpxEngine::builder().build().unwrap();
794 assert!(!engine.is_strict());
795 }
796
797 // =========================================================================
798 // Accessor tests
799 // =========================================================================
800
801 #[test]
802 fn test_runtime_accessor() {
803 let engine = JpxEngine::new();
804 let runtime = engine.runtime();
805 // Verify we can compile an expression through the runtime reference
806 let expr = runtime.compile("length(@)").unwrap();
807 let data = json!([1, 2, 3]);
808 let result = expr.search(&data).unwrap();
809 assert_eq!(result, json!(3));
810 }
811
812 #[test]
813 fn test_registry_accessor() {
814 let engine = JpxEngine::new();
815 let registry = engine.registry();
816 // The registry should contain functions after register_all()
817 assert!(registry.get_function("upper").is_some());
818 assert!(registry.get_function("lower").is_some());
819 assert!(registry.is_enabled("upper"));
820 }
821
822 #[test]
823 fn test_discovery_accessor() {
824 let engine = JpxEngine::new();
825 let discovery = engine.discovery();
826 // Should be able to acquire a read lock and inspect the empty registry
827 let guard = discovery.read().unwrap();
828 assert!(guard.list_servers().is_empty());
829 }
830
831 #[test]
832 fn test_queries_accessor() {
833 let engine = JpxEngine::new();
834 let queries = engine.queries();
835 // Should be able to acquire a read lock and inspect the empty store
836 let guard = queries.read().unwrap();
837 assert!(guard.is_empty());
838 }
839
840 // =========================================================================
841 // Query store tests (via engine)
842 // =========================================================================
843
844 #[test]
845 fn test_define_query_with_description() {
846 let engine = JpxEngine::new();
847 engine
848 .define_query(
849 "named".to_string(),
850 "length(@)".to_string(),
851 Some("Counts elements".to_string()),
852 )
853 .unwrap();
854
855 let query = engine.get_query("named").unwrap().unwrap();
856 assert_eq!(query.expression, "length(@)");
857 assert_eq!(query.description, Some("Counts elements".to_string()));
858 }
859
860 #[test]
861 fn test_define_query_invalid_expression() {
862 let engine = JpxEngine::new();
863 let result = engine.define_query("bad".to_string(), "invalid[".to_string(), None);
864 assert!(result.is_err());
865 match result.unwrap_err() {
866 EngineError::InvalidExpression(_) => {} // expected
867 other => panic!("Expected InvalidExpression, got {:?}", other),
868 }
869 }
870
871 #[test]
872 fn test_define_query_overwrite() {
873 let engine = JpxEngine::new();
874
875 // First define returns None (no previous query)
876 let first = engine
877 .define_query("q".to_string(), "length(@)".to_string(), None)
878 .unwrap();
879 assert!(first.is_none());
880
881 // Second define with same name returns Some(old)
882 let second = engine
883 .define_query("q".to_string(), "keys(@)".to_string(), None)
884 .unwrap();
885 assert!(second.is_some());
886 let old = second.unwrap();
887 assert_eq!(old.expression, "length(@)");
888
889 // Current value should be the new expression
890 let current = engine.get_query("q").unwrap().unwrap();
891 assert_eq!(current.expression, "keys(@)");
892 }
893
894 #[test]
895 fn test_get_query_nonexistent() {
896 let engine = JpxEngine::new();
897 let result = engine.get_query("nonexistent").unwrap();
898 assert!(result.is_none());
899 }
900
901 #[test]
902 fn test_delete_query_nonexistent() {
903 let engine = JpxEngine::new();
904 let result = engine.delete_query("nonexistent").unwrap();
905 assert!(result.is_none());
906 }
907
908 #[test]
909 fn test_run_query_not_found() {
910 let engine = JpxEngine::new();
911 let result = engine.run_query("nonexistent", &json!({}));
912 assert!(result.is_err());
913 match result.unwrap_err() {
914 EngineError::QueryNotFound(name) => assert_eq!(name, "nonexistent"),
915 other => panic!("Expected QueryNotFound, got {:?}", other),
916 }
917 }
918
919 #[test]
920 fn test_list_queries_empty() {
921 let engine = JpxEngine::new();
922 let queries = engine.list_queries().unwrap();
923 assert!(queries.is_empty());
924 }
925
926 #[test]
927 fn test_list_queries_multiple() {
928 let engine = JpxEngine::new();
929 engine
930 .define_query("alpha".to_string(), "a".to_string(), None)
931 .unwrap();
932 engine
933 .define_query("beta".to_string(), "b".to_string(), None)
934 .unwrap();
935 engine
936 .define_query("gamma".to_string(), "c".to_string(), None)
937 .unwrap();
938
939 let queries = engine.list_queries().unwrap();
940 assert_eq!(queries.len(), 3);
941 }
942
943 // =========================================================================
944 // Discovery tests (via engine)
945 // =========================================================================
946
947 #[test]
948 fn test_register_discovery_duplicate() {
949 let engine = JpxEngine::new();
950
951 let spec: DiscoverySpec = serde_json::from_value(json!({
952 "server": {"name": "dup-server", "version": "1.0.0"},
953 "tools": [
954 {"name": "tool_a", "description": "Tool A", "tags": ["test"]}
955 ]
956 }))
957 .unwrap();
958
959 // First registration succeeds
960 let first = engine.register_discovery(spec.clone(), false).unwrap();
961 assert!(first.ok);
962
963 // Second registration without replace fails
964 let second = engine.register_discovery(spec, false).unwrap();
965 assert!(!second.ok);
966 assert!(second.warnings[0].contains("already registered"));
967 }
968
969 #[test]
970 fn test_unregister_nonexistent() {
971 let engine = JpxEngine::new();
972 let result = engine.unregister_discovery("nonexistent").unwrap();
973 assert!(!result);
974 }
975
976 #[test]
977 fn test_discovery_index_stats_empty() {
978 let engine = JpxEngine::new();
979 let stats = engine.discovery_index_stats().unwrap();
980 assert!(stats.is_none());
981 }
982
983 #[test]
984 fn test_get_discovery_schema() {
985 let engine = JpxEngine::new();
986 let schema = engine.get_discovery_schema();
987 assert!(schema.is_object());
988 assert!(schema.get("$schema").is_some());
989 assert_eq!(
990 schema.get("$schema").unwrap().as_str().unwrap(),
991 "http://json-schema.org/draft-07/schema#"
992 );
993 assert!(schema.get("title").is_some());
994 assert!(schema.get("properties").is_some());
995 }
996}