Skip to main content

jpx_engine/
introspection.rs

1//! Function introspection, search, and similarity.
2//!
3//! This module provides the function discovery and exploration capabilities
4//! of the [`JpxEngine`]: listing categories, searching by keyword with fuzzy
5//! matching and synonyms, describing functions, and finding similar functions.
6
7use crate::JpxEngine;
8use jpx_core::registry::{Category, FunctionInfo, expand_search_terms, lookup_synonyms};
9use serde::{Deserialize, Serialize};
10use strsim::jaro_winkler;
11
12/// Detailed information about a JMESPath function.
13///
14/// This struct provides a serializable representation of function metadata,
15/// suitable for API responses, documentation generation, and introspection tools.
16///
17/// # Example
18///
19/// ```rust
20/// use jpx_engine::JpxEngine;
21///
22/// let engine = JpxEngine::new();
23/// let info = engine.describe_function("upper").unwrap();
24///
25/// println!("Function: {}", info.name);
26/// println!("Category: {}", info.category);
27/// println!("Signature: {}", info.signature);
28/// println!("Example: {}", info.example);
29/// ```
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct FunctionDetail {
32    /// Function name (e.g., "upper", "sum", "now")
33    pub name: String,
34    /// Category name (e.g., "String", "Math", "Datetime")
35    pub category: String,
36    /// Human-readable description of what the function does
37    pub description: String,
38    /// Function signature showing parameter types (e.g., "string -> string")
39    pub signature: String,
40    /// Example usage demonstrating the function
41    pub example: String,
42    /// Whether this is a standard JMESPath function (vs extension)
43    pub is_standard: bool,
44    /// JMESPath Enhancement Proposal number, if applicable
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub jep: Option<String>,
47    /// Alternative names for this function
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub aliases: Vec<String>,
50}
51
52impl From<&FunctionInfo> for FunctionDetail {
53    fn from(info: &FunctionInfo) -> Self {
54        Self {
55            name: info.name.to_string(),
56            category: format!("{:?}", info.category),
57            description: info.description.to_string(),
58            signature: info.signature.to_string(),
59            example: info.example.to_string(),
60            is_standard: info.is_standard,
61            jep: info.jep.map(|s| s.to_string()),
62            aliases: info.aliases.iter().map(|s| s.to_string()).collect(),
63        }
64    }
65}
66
67/// Result from searching for functions.
68///
69/// Contains the matched function along with information about how it matched
70/// the search query and its relevance score.
71///
72/// # Scoring
73///
74/// Match types and approximate scores:
75/// - `exact_name` (1000): Query exactly matches function name
76/// - `alias` (900): Query matches a function alias
77/// - `name_prefix` (800): Function name starts with query
78/// - `name_contains` (700): Function name contains query
79/// - `category` (600): Query matches category name
80/// - `description` (100-300): Query found in description
81/// - `fuzzy_name` (variable): Jaro-Winkler similarity > 0.8
82/// - `synonym` (300): Query synonym found in name/description
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct SearchResult {
85    /// The matched function's details
86    pub function: FunctionDetail,
87    /// How the function matched (e.g., "exact_name", "description")
88    pub match_type: String,
89    /// Relevance score (higher = better match)
90    pub score: i32,
91}
92
93/// Result from finding functions similar to a given function.
94///
95/// Groups similar functions by relationship type: same category,
96/// similar signature, or related concepts in descriptions.
97///
98/// # Example
99///
100/// ```rust
101/// use jpx_engine::JpxEngine;
102///
103/// let engine = JpxEngine::new();
104/// let similar = engine.similar_functions("upper").unwrap();
105///
106/// // Functions in the same category (String)
107/// for f in &similar.same_category {
108///     println!("Same category: {}", f.name);
109/// }
110///
111/// // Functions with similar signatures
112/// for f in &similar.similar_signature {
113///     println!("Similar signature: {}", f.name);
114/// }
115/// ```
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct SimilarFunctionsResult {
118    /// Functions in the same category as the target
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub same_category: Vec<FunctionDetail>,
121    /// Functions with similar parameter/return types
122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
123    pub similar_signature: Vec<FunctionDetail>,
124    /// Functions with overlapping description keywords
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub related_concepts: Vec<FunctionDetail>,
127}
128
129// =============================================================================
130// JpxEngine introspection methods
131// =============================================================================
132
133impl JpxEngine {
134    /// Lists all available function categories.
135    ///
136    /// Returns category names like "String", "Math", "Datetime", etc.
137    ///
138    /// # Example
139    ///
140    /// ```rust
141    /// use jpx_engine::JpxEngine;
142    ///
143    /// let engine = JpxEngine::new();
144    /// let categories = engine.categories();
145    ///
146    /// assert!(categories.contains(&"String".to_string()));
147    /// assert!(categories.contains(&"Math".to_string()));
148    /// assert!(categories.contains(&"Array".to_string()));
149    /// ```
150    pub fn categories(&self) -> Vec<String> {
151        Category::all().iter().map(|c| format!("{:?}", c)).collect()
152    }
153
154    /// Lists functions, optionally filtered by category.
155    ///
156    /// # Arguments
157    ///
158    /// * `category` - Optional category name to filter by (case-insensitive)
159    ///
160    /// # Example
161    ///
162    /// ```rust
163    /// use jpx_engine::JpxEngine;
164    ///
165    /// let engine = JpxEngine::new();
166    ///
167    /// // All functions
168    /// let all = engine.functions(None);
169    /// assert!(all.len() > 100);
170    ///
171    /// // Just string functions
172    /// let string_funcs = engine.functions(Some("String"));
173    /// assert!(string_funcs.iter().all(|f| f.category == "String"));
174    /// ```
175    pub fn functions(&self, category: Option<&str>) -> Vec<FunctionDetail> {
176        match category.and_then(parse_category) {
177            Some(cat) => self
178                .registry
179                .functions_in_category(cat)
180                .map(FunctionDetail::from)
181                .collect(),
182            None => self
183                .registry
184                .functions()
185                .map(FunctionDetail::from)
186                .collect(),
187        }
188    }
189
190    /// Gets detailed information about a function by name or alias.
191    ///
192    /// # Arguments
193    ///
194    /// * `name` - Function name or alias (e.g., "upper", "md5", "len")
195    ///
196    /// # Returns
197    ///
198    /// `Some(FunctionDetail)` if found, `None` if no matching function exists.
199    ///
200    /// # Example
201    ///
202    /// ```rust
203    /// use jpx_engine::JpxEngine;
204    ///
205    /// let engine = JpxEngine::new();
206    ///
207    /// let info = engine.describe_function("upper").unwrap();
208    /// assert_eq!(info.name, "upper");
209    /// assert_eq!(info.category, "String");
210    /// println!("Signature: {}", info.signature);
211    /// println!("Example: {}", info.example);
212    ///
213    /// // Also works with aliases
214    /// let info = engine.describe_function("len");  // alias for "length"
215    /// ```
216    pub fn describe_function(&self, name: &str) -> Option<FunctionDetail> {
217        self.registry.get_function(name).map(FunctionDetail::from)
218    }
219
220    /// Searches for functions matching a query string.
221    ///
222    /// Uses fuzzy matching, synonyms, and searches across names, descriptions,
223    /// categories, and signatures. Results are ranked by relevance.
224    ///
225    /// # Arguments
226    ///
227    /// * `query` - Search term (e.g., "hash", "string manipulation", "date")
228    /// * `limit` - Maximum number of results to return
229    ///
230    /// # Example
231    ///
232    /// ```rust
233    /// use jpx_engine::JpxEngine;
234    ///
235    /// let engine = JpxEngine::new();
236    ///
237    /// // Search by concept
238    /// let results = engine.search_functions("hash", 10);
239    /// assert!(results.iter().any(|r| r.function.name == "md5"));
240    /// assert!(results.iter().any(|r| r.function.name == "sha256"));
241    ///
242    /// // Results are ranked by relevance
243    /// for result in &results {
244    ///     println!("{}: {} (score: {})",
245    ///         result.function.name,
246    ///         result.match_type,
247    ///         result.score
248    ///     );
249    /// }
250    /// ```
251    pub fn search_functions(&self, query: &str, limit: usize) -> Vec<SearchResult> {
252        let query_lower = query.to_lowercase();
253
254        // Expand query terms using synonyms
255        let expanded_terms = expand_search_terms(&query_lower);
256
257        let all_functions: Vec<_> = self.registry.functions().collect();
258        let mut results: Vec<SearchResult> = Vec::new();
259
260        for info in &all_functions {
261            let name_lower = info.name.to_lowercase();
262            let desc_lower = info.description.to_lowercase();
263            let category_lower = format!("{:?}", info.category).to_lowercase();
264            let signature_lower = info.signature.to_lowercase();
265            let aliases_lower: Vec<String> = info
266                .aliases
267                .iter()
268                .map(|a: &&str| a.to_lowercase())
269                .collect();
270
271            // Calculate match score and type
272            let (score, match_type) = calculate_match_score(
273                &query_lower,
274                &expanded_terms,
275                &MatchContext {
276                    name: &name_lower,
277                    aliases: &aliases_lower,
278                    category: &category_lower,
279                    description: &desc_lower,
280                    signature: &signature_lower,
281                },
282            );
283
284            if score > 0 {
285                results.push(SearchResult {
286                    function: FunctionDetail::from(*info),
287                    match_type,
288                    score,
289                });
290            }
291        }
292
293        // Sort by score descending, then by name
294        results.sort_by(|a, b| {
295            b.score
296                .cmp(&a.score)
297                .then_with(|| a.function.name.cmp(&b.function.name))
298        });
299
300        results.truncate(limit);
301        results
302    }
303
304    /// Finds functions similar to a given function.
305    ///
306    /// Returns functions grouped by relationship type:
307    /// - Same category (e.g., other string functions if input is "upper")
308    /// - Similar signature (same parameter/return types)
309    /// - Related concepts (overlapping description keywords)
310    ///
311    /// # Arguments
312    ///
313    /// * `name` - Name of the function to find similar functions for
314    ///
315    /// # Returns
316    ///
317    /// `Some(SimilarFunctionsResult)` if the function exists, `None` otherwise.
318    ///
319    /// # Example
320    ///
321    /// ```rust
322    /// use jpx_engine::JpxEngine;
323    ///
324    /// let engine = JpxEngine::new();
325    ///
326    /// let similar = engine.similar_functions("upper").unwrap();
327    ///
328    /// // Other string functions
329    /// println!("Same category:");
330    /// for f in &similar.same_category {
331    ///     println!("  - {}", f.name);
332    /// }
333    /// ```
334    pub fn similar_functions(&self, name: &str) -> Option<SimilarFunctionsResult> {
335        let info = self.registry.get_function(name)?;
336        let all_functions: Vec<_> = self.registry.functions().collect();
337
338        // Same category
339        let same_category: Vec<FunctionDetail> = all_functions
340            .iter()
341            .filter(|f| f.category == info.category && f.name != info.name)
342            .take(5)
343            .map(|f| FunctionDetail::from(*f))
344            .collect();
345
346        // Similar signature (same arity)
347        let this_arity = count_params(info.signature);
348        let similar_signature: Vec<FunctionDetail> = all_functions
349            .iter()
350            .filter(|f| {
351                f.name != info.name
352                    && f.category != info.category
353                    && count_params(f.signature) == this_arity
354            })
355            .take(5)
356            .map(|f| FunctionDetail::from(*f))
357            .collect();
358
359        // Related concepts (description keyword overlap)
360        let keywords = extract_keywords(info.description);
361        let mut concept_scores: Vec<(&FunctionInfo, usize)> = all_functions
362            .iter()
363            .filter(|f| f.name != info.name)
364            .map(|f| {
365                let f_keywords = extract_keywords(f.description);
366                let overlap = keywords.iter().filter(|k| f_keywords.contains(*k)).count();
367                (*f, overlap)
368            })
369            .filter(|(_, score)| *score > 0)
370            .collect();
371
372        concept_scores.sort_by(|a, b| b.1.cmp(&a.1));
373
374        let related_concepts: Vec<FunctionDetail> = concept_scores
375            .into_iter()
376            .take(5)
377            .map(|(f, _)| FunctionDetail::from(f))
378            .collect();
379
380        Some(SimilarFunctionsResult {
381            same_category,
382            similar_signature,
383            related_concepts,
384        })
385    }
386}
387
388// =============================================================================
389// Helper functions
390// =============================================================================
391
392/// Context for calculating match scores
393struct MatchContext<'a> {
394    name: &'a str,
395    aliases: &'a [String],
396    category: &'a str,
397    description: &'a str,
398    signature: &'a str,
399}
400
401/// Calculate match score and type for a function
402fn calculate_match_score(
403    query: &str,
404    expanded_terms: &[String],
405    ctx: &MatchContext,
406) -> (i32, String) {
407    // Exact name match
408    if ctx.name == query {
409        return (1000, "exact_name".to_string());
410    }
411
412    // Alias match
413    if ctx.aliases.iter().any(|a| a == query) {
414        return (900, "alias".to_string());
415    }
416
417    // Name starts with query
418    if ctx.name.starts_with(query) {
419        return (800, "name_prefix".to_string());
420    }
421
422    // Name contains query
423    if ctx.name.contains(query) {
424        return (700, "name_contains".to_string());
425    }
426
427    // Category match
428    if ctx.category == query {
429        return (600, "category".to_string());
430    }
431
432    // Check expanded terms in description/signature
433    let mut desc_score = 0;
434    let mut matched_terms = Vec::new();
435
436    for term in expanded_terms {
437        if ctx.description.contains(term) || ctx.signature.contains(term) {
438            desc_score += 100;
439            matched_terms.push(term.clone());
440        }
441    }
442
443    if desc_score > 0 {
444        return (
445            desc_score,
446            format!("description ({})", matched_terms.join(", ")),
447        );
448    }
449
450    // Fuzzy name match using Jaro-Winkler
451    let similarity = jaro_winkler(query, ctx.name);
452    if similarity > 0.8 {
453        return ((similarity * 500.0) as i32, "fuzzy_name".to_string());
454    }
455
456    // Check synonyms
457    if let Some(synonyms) = lookup_synonyms(query) {
458        for syn in synonyms {
459            if ctx.name.contains(syn) || ctx.description.contains(syn) {
460                return (300, format!("synonym ({})", syn));
461            }
462        }
463    }
464
465    (0, String::new())
466}
467
468/// Parse category string to Category enum
469pub(crate) fn parse_category(name: &str) -> Option<Category> {
470    Category::all()
471        .iter()
472        .find(|cat| format!("{:?}", cat).to_lowercase() == name.to_lowercase())
473        .copied()
474}
475
476/// Count parameters in a function signature, ignoring commas inside brackets.
477///
478/// Handles signatures like `array, array[[string, string]] -> array` correctly
479/// by tracking bracket depth and only counting top-level commas before `->`.
480fn count_params(signature: &str) -> usize {
481    // Only look at the input side (before "->")
482    let input = match signature.find("->") {
483        Some(pos) => &signature[..pos],
484        None => signature,
485    };
486    let input = input.trim();
487    if input.is_empty() {
488        return 0;
489    }
490
491    let mut params = 1;
492    let mut depth = 0;
493    for ch in input.chars() {
494        match ch {
495            '[' | '(' => depth += 1,
496            ']' | ')' => depth -= 1,
497            ',' if depth == 0 => params += 1,
498            _ => {}
499        }
500    }
501    params
502}
503
504/// Extract keywords from a description for related concept matching
505fn extract_keywords(description: &str) -> Vec<&str> {
506    let stopwords = [
507        "a",
508        "an",
509        "the",
510        "is",
511        "are",
512        "was",
513        "were",
514        "be",
515        "been",
516        "being",
517        "have",
518        "has",
519        "had",
520        "do",
521        "does",
522        "did",
523        "will",
524        "would",
525        "could",
526        "should",
527        "may",
528        "might",
529        "must",
530        "shall",
531        "can",
532        "to",
533        "of",
534        "in",
535        "for",
536        "on",
537        "with",
538        "at",
539        "by",
540        "from",
541        "or",
542        "and",
543        "as",
544        "if",
545        "that",
546        "which",
547        "this",
548        "these",
549        "those",
550        "it",
551        "its",
552        "such",
553        "when",
554        "where",
555        "how",
556        "all",
557        "each",
558        "every",
559        "both",
560        "few",
561        "more",
562        "most",
563        "other",
564        "some",
565        "any",
566        "no",
567        "not",
568        "only",
569        "same",
570        "than",
571        "very",
572        "just",
573        "also",
574        "into",
575        "over",
576        "after",
577        "before",
578        "between",
579        "under",
580        "again",
581        "further",
582        "then",
583        "once",
584        "here",
585        "there",
586        "why",
587        "because",
588        "while",
589        "although",
590        "though",
591        "unless",
592        "until",
593        "whether",
594        "returns",
595        "return",
596        "value",
597        "values",
598        "given",
599        "input",
600        "output",
601        "function",
602        "functions",
603        "used",
604        "using",
605        "use",
606    ];
607
608    description
609        .split(|c: char| !c.is_alphanumeric())
610        .filter(|w| w.len() > 2 && !stopwords.contains(&w.to_lowercase().as_str()))
611        .collect()
612}
613
614#[cfg(test)]
615mod tests {
616    use crate::JpxEngine;
617
618    #[test]
619    fn test_categories() {
620        let engine = JpxEngine::new();
621        let cats = engine.categories();
622        assert!(!cats.is_empty());
623        assert!(cats.iter().any(|c| c == "String"));
624    }
625
626    #[test]
627    fn test_functions() {
628        let engine = JpxEngine::new();
629
630        // All functions
631        let all = engine.functions(None);
632        assert!(!all.is_empty());
633
634        // Filtered by category
635        let string_funcs = engine.functions(Some("String"));
636        assert!(!string_funcs.is_empty());
637        assert!(string_funcs.iter().all(|f| f.category == "String"));
638    }
639
640    #[test]
641    fn test_describe_function() {
642        let engine = JpxEngine::new();
643
644        let info = engine.describe_function("upper").unwrap();
645        assert_eq!(info.name, "upper");
646        assert_eq!(info.category, "String");
647
648        let missing = engine.describe_function("nonexistent");
649        assert!(missing.is_none());
650    }
651
652    #[test]
653    fn test_search_functions() {
654        let engine = JpxEngine::new();
655
656        let results = engine.search_functions("string", 10);
657        assert!(!results.is_empty());
658    }
659
660    #[test]
661    fn test_similar_functions() {
662        let engine = JpxEngine::new();
663
664        let result = engine.similar_functions("upper").unwrap();
665        // Should have functions in same category
666        assert!(!result.same_category.is_empty());
667    }
668
669    // =========================================================================
670    // Categories tests
671    // =========================================================================
672
673    #[test]
674    fn test_categories_contains_common() {
675        let engine = JpxEngine::new();
676        let cats = engine.categories();
677        for expected in &["Math", "Array", "Object", "Utility"] {
678            assert!(
679                cats.iter().any(|c| c == expected),
680                "Expected categories to contain {:?}",
681                expected
682            );
683        }
684    }
685
686    #[test]
687    fn test_categories_no_duplicates() {
688        let engine = JpxEngine::new();
689        let cats = engine.categories();
690        let mut seen = std::collections::HashSet::new();
691        for cat in &cats {
692            assert!(
693                seen.insert(cat.clone()),
694                "Duplicate category found: {:?}",
695                cat
696            );
697        }
698    }
699
700    // =========================================================================
701    // Functions tests
702    // =========================================================================
703
704    #[test]
705    fn test_functions_all() {
706        let engine = JpxEngine::new();
707        let all = engine.functions(None);
708        assert!(
709            !all.is_empty(),
710            "functions(None) should return a non-empty list"
711        );
712        // There should be a substantial number of functions (400+)
713        assert!(
714            all.len() > 100,
715            "Expected at least 100 functions, got {}",
716            all.len()
717        );
718    }
719
720    #[test]
721    fn test_functions_invalid_category() {
722        let engine = JpxEngine::new();
723        // When a category string does not match any known category, parse_category
724        // returns None, and functions() falls through to returning all functions.
725        let invalid = engine.functions(Some("NonexistentCategory"));
726        let all = engine.functions(None);
727        assert_eq!(
728            invalid.len(),
729            all.len(),
730            "Invalid category should fall back to returning all functions"
731        );
732    }
733
734    #[test]
735    fn test_functions_case_insensitive() {
736        let engine = JpxEngine::new();
737        let lower = engine.functions(Some("string"));
738        let upper = engine.functions(Some("String"));
739        assert!(
740            !lower.is_empty(),
741            "functions(Some(\"string\")) should return results"
742        );
743        assert_eq!(
744            lower.len(),
745            upper.len(),
746            "Case-insensitive category lookup should return the same number of results"
747        );
748        // Verify same function names in both results
749        let lower_names: Vec<&str> = lower.iter().map(|f| f.name.as_str()).collect();
750        let upper_names: Vec<&str> = upper.iter().map(|f| f.name.as_str()).collect();
751        assert_eq!(lower_names, upper_names);
752    }
753
754    // =========================================================================
755    // describe_function tests
756    // =========================================================================
757
758    #[test]
759    fn test_describe_function_detail_fields() {
760        let engine = JpxEngine::new();
761        let info = engine.describe_function("length").unwrap();
762        assert_eq!(info.name, "length");
763        assert!(!info.category.is_empty(), "category should not be empty");
764        assert!(info.is_standard, "length should be a standard function");
765        assert!(
766            !info.description.is_empty(),
767            "description should not be empty"
768        );
769        assert!(!info.signature.is_empty(), "signature should not be empty");
770    }
771
772    #[test]
773    fn test_describe_function_extension() {
774        let engine = JpxEngine::new();
775        let info = engine.describe_function("upper").unwrap();
776        assert_eq!(info.name, "upper");
777        assert!(!info.is_standard, "upper should not be a standard function");
778    }
779
780    #[test]
781    fn test_describe_function_by_alias() {
782        let engine = JpxEngine::new();
783        // "all_expr" has alias "every"
784        let info = engine.describe_function("all_expr").unwrap();
785        assert!(
786            info.aliases.contains(&"every".to_string()),
787            "all_expr should have alias 'every', aliases: {:?}",
788            info.aliases
789        );
790        // Verify that searching for the alias via search_functions finds it
791        let results = engine.search_functions("every", 5);
792        assert!(
793            results.iter().any(|r| r.function.name == "all_expr"),
794            "Searching for alias 'every' should find 'all_expr'"
795        );
796        let alias_result = results
797            .iter()
798            .find(|r| r.function.name == "all_expr")
799            .unwrap();
800        assert_eq!(
801            alias_result.match_type, "alias",
802            "Match type for alias search should be 'alias'"
803        );
804        assert_eq!(alias_result.score, 900, "Alias match score should be 900");
805    }
806
807    // =========================================================================
808    // search_functions tests
809    // =========================================================================
810
811    #[test]
812    fn test_search_exact_name_match() {
813        let engine = JpxEngine::new();
814        let results = engine.search_functions("upper", 10);
815        assert!(!results.is_empty(), "Should find results for 'upper'");
816        let first = &results[0];
817        assert_eq!(first.function.name, "upper");
818        assert_eq!(first.match_type, "exact_name");
819        assert_eq!(first.score, 1000);
820    }
821
822    #[test]
823    fn test_search_name_prefix_match() {
824        let engine = JpxEngine::new();
825        let results = engine.search_functions("to_", 20);
826        assert!(!results.is_empty(), "Should find results for prefix 'to_'");
827        // All top results should have name_prefix match type
828        let prefix_results: Vec<_> = results
829            .iter()
830            .filter(|r| r.match_type == "name_prefix")
831            .collect();
832        assert!(
833            !prefix_results.is_empty(),
834            "Should have at least one name_prefix match"
835        );
836        for r in &prefix_results {
837            assert_eq!(r.score, 800, "name_prefix score should be 800");
838            assert!(
839                r.function.name.starts_with("to_"),
840                "Function '{}' should start with 'to_'",
841                r.function.name
842            );
843        }
844    }
845
846    #[test]
847    fn test_search_name_contains_match() {
848        let engine = JpxEngine::new();
849        // "left" appears in "pad_left" but "pad_left" does not start with "left"
850        let results = engine.search_functions("left", 20);
851        let contains_results: Vec<_> = results
852            .iter()
853            .filter(|r| r.match_type == "name_contains")
854            .collect();
855        assert!(
856            !contains_results.is_empty(),
857            "Should have at least one name_contains match for 'left'"
858        );
859        for r in &contains_results {
860            assert_eq!(r.score, 700, "name_contains score should be 700");
861            assert!(
862                r.function.name.contains("left"),
863                "Function '{}' should contain 'left'",
864                r.function.name
865            );
866        }
867    }
868
869    #[test]
870    fn test_search_description_match() {
871        let engine = JpxEngine::new();
872        // "converts" is a word likely found in descriptions but not as a function name
873        let results = engine.search_functions("converts", 10);
874        if !results.is_empty() {
875            // If we got description matches, verify the match type
876            let desc_matches: Vec<_> = results
877                .iter()
878                .filter(|r| r.match_type.starts_with("description"))
879                .collect();
880            assert!(
881                !desc_matches.is_empty(),
882                "Matches for 'converts' should include description matches"
883            );
884            for r in &desc_matches {
885                assert!(
886                    r.score >= 100,
887                    "Description match score should be at least 100"
888                );
889            }
890        }
891    }
892
893    #[test]
894    fn test_search_case_insensitive() {
895        let engine = JpxEngine::new();
896        let upper_results = engine.search_functions("UPPER", 10);
897        let lower_results = engine.search_functions("upper", 10);
898        // Both should find the "upper" function
899        assert!(
900            upper_results.iter().any(|r| r.function.name == "upper"),
901            "Searching for 'UPPER' should find 'upper'"
902        );
903        assert!(
904            lower_results.iter().any(|r| r.function.name == "upper"),
905            "Searching for 'upper' should find 'upper'"
906        );
907    }
908
909    #[test]
910    fn test_search_respects_limit() {
911        let engine = JpxEngine::new();
912        let results = engine.search_functions("string", 3);
913        assert!(
914            results.len() <= 3,
915            "Results should be limited to 3, got {}",
916            results.len()
917        );
918    }
919
920    #[test]
921    fn test_search_results_ordered_by_score() {
922        let engine = JpxEngine::new();
923        let results = engine.search_functions("string", 20);
924        for window in results.windows(2) {
925            assert!(
926                window[0].score >= window[1].score,
927                "Results should be sorted by score descending: {} (score {}) came before {} (score {})",
928                window[0].function.name,
929                window[0].score,
930                window[1].function.name,
931                window[1].score,
932            );
933        }
934    }
935
936    #[test]
937    fn test_search_empty_query() {
938        let engine = JpxEngine::new();
939        // Empty string search should not panic
940        let results = engine.search_functions("", 10);
941        // Empty query may or may not return results, but it must not panic
942        let _ = results;
943    }
944
945    #[test]
946    fn test_search_no_results() {
947        let engine = JpxEngine::new();
948        let results = engine.search_functions("xyzqwerty123", 10);
949        assert!(
950            results.is_empty(),
951            "Should find no results for nonsense query, got {}",
952            results.len()
953        );
954    }
955
956    // =========================================================================
957    // similar_functions tests
958    // =========================================================================
959
960    #[test]
961    fn test_similar_functions_nonexistent() {
962        let engine = JpxEngine::new();
963        let result = engine.similar_functions("nonexistent");
964        assert!(
965            result.is_none(),
966            "similar_functions for nonexistent function should return None"
967        );
968    }
969
970    #[test]
971    fn test_similar_same_category_populated() {
972        let engine = JpxEngine::new();
973        let result = engine.similar_functions("upper").unwrap();
974        assert!(
975            !result.same_category.is_empty(),
976            "same_category should be populated for 'upper'"
977        );
978        // All same_category results should be String functions (same as upper)
979        for f in &result.same_category {
980            assert_eq!(
981                f.category, "String",
982                "same_category function '{}' should be in String category, got '{}'",
983                f.name, f.category
984            );
985        }
986        // "upper" itself should not appear in the same_category list
987        assert!(
988            !result.same_category.iter().any(|f| f.name == "upper"),
989            "same_category should not contain the function itself"
990        );
991    }
992
993    #[test]
994    fn test_count_params_simple() {
995        assert_eq!(super::count_params("string -> string"), 1);
996        assert_eq!(super::count_params("string, string -> string"), 2);
997        assert_eq!(super::count_params("string, number, string -> string"), 3);
998    }
999
1000    #[test]
1001    fn test_count_params_brackets() {
1002        // order_by: array, array[[string, string]] -> array
1003        assert_eq!(
1004            super::count_params("array, array[[string, string]] -> array"),
1005            2
1006        );
1007        // nested brackets
1008        assert_eq!(
1009            super::count_params("array[object[string, number]] -> array"),
1010            1
1011        );
1012    }
1013
1014    #[test]
1015    fn test_count_params_optional_and_variadic() {
1016        assert_eq!(super::count_params("string, string? -> string"), 2);
1017        assert_eq!(super::count_params("string, ...any -> array"), 2);
1018    }
1019
1020    #[test]
1021    fn test_count_params_empty() {
1022        assert_eq!(super::count_params("-> string"), 0);
1023    }
1024
1025    #[test]
1026    fn test_similar_signature_uses_correct_arity() {
1027        let engine = super::super::JpxEngine::new();
1028        // order_by has 2 params; similar_signature should match other 2-param functions
1029        let result = engine.similar_functions("order_by").unwrap();
1030        for f in &result.similar_signature {
1031            let arity = super::count_params(&f.signature);
1032            assert_eq!(
1033                arity, 2,
1034                "similar_signature match '{}' (sig: '{}') should have arity 2, got {}",
1035                f.name, f.signature, arity
1036            );
1037        }
1038    }
1039
1040    #[test]
1041    fn test_similar_functions_serde() {
1042        let engine = JpxEngine::new();
1043        let result = engine.similar_functions("upper").unwrap();
1044        // Should serialize to JSON without error
1045        let json = serde_json::to_string(&result)
1046            .expect("SimilarFunctionsResult should serialize to JSON");
1047        assert!(!json.is_empty(), "Serialized JSON should not be empty");
1048        // Should deserialize back
1049        let deserialized: super::SimilarFunctionsResult = serde_json::from_str(&json)
1050            .expect("SimilarFunctionsResult should deserialize from JSON");
1051        assert_eq!(
1052            result.same_category.len(),
1053            deserialized.same_category.len(),
1054            "Round-trip should preserve same_category length"
1055        );
1056    }
1057}