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 {
177            Some(name) => match parse_category(name) {
178                Some(cat) => self
179                    .registry
180                    .functions_in_category(cat)
181                    .map(FunctionDetail::from)
182                    .collect(),
183                None => Vec::new(),
184            },
185            None => self
186                .registry
187                .functions()
188                .map(FunctionDetail::from)
189                .collect(),
190        }
191    }
192
193    /// Gets detailed information about a function by name or alias.
194    ///
195    /// # Arguments
196    ///
197    /// * `name` - Function name or alias (e.g., "upper", "md5", "len")
198    ///
199    /// # Returns
200    ///
201    /// `Some(FunctionDetail)` if found, `None` if no matching function exists.
202    ///
203    /// # Example
204    ///
205    /// ```rust
206    /// use jpx_engine::JpxEngine;
207    ///
208    /// let engine = JpxEngine::new();
209    ///
210    /// let info = engine.describe_function("upper").unwrap();
211    /// assert_eq!(info.name, "upper");
212    /// assert_eq!(info.category, "String");
213    /// println!("Signature: {}", info.signature);
214    /// println!("Example: {}", info.example);
215    ///
216    /// // Also works with aliases
217    /// let info = engine.describe_function("len");  // alias for "length"
218    /// ```
219    pub fn describe_function(&self, name: &str) -> Option<FunctionDetail> {
220        self.registry.get_function(name).map(FunctionDetail::from)
221    }
222
223    /// Describes multiple functions in a single call.
224    ///
225    /// Returns one `(name, detail)` entry per requested name, preserving input
226    /// order, with `None` for names that do not resolve to a function (rather
227    /// than failing the whole batch). Names may be aliases, just like
228    /// [`describe_function`](Self::describe_function).
229    ///
230    /// # Example
231    ///
232    /// ```rust
233    /// use jpx_engine::JpxEngine;
234    ///
235    /// let engine = JpxEngine::new();
236    /// let results = engine.describe_functions(&["upper", "not_a_function"]);
237    /// assert_eq!(results.len(), 2);
238    /// assert_eq!(results[0].0, "upper");
239    /// assert!(results[0].1.is_some());
240    /// assert_eq!(results[1].0, "not_a_function");
241    /// assert!(results[1].1.is_none());
242    /// ```
243    pub fn describe_functions(&self, names: &[&str]) -> Vec<(String, Option<FunctionDetail>)> {
244        names
245            .iter()
246            .map(|&name| (name.to_string(), self.describe_function(name)))
247            .collect()
248    }
249
250    /// Searches for functions matching a query string.
251    ///
252    /// Uses fuzzy matching, synonyms, and searches across names, descriptions,
253    /// categories, and signatures. Results are ranked by relevance.
254    ///
255    /// # Arguments
256    ///
257    /// * `query` - Search term (e.g., "hash", "string manipulation", "date")
258    /// * `limit` - Maximum number of results to return
259    ///
260    /// # Example
261    ///
262    /// ```rust
263    /// use jpx_engine::JpxEngine;
264    ///
265    /// let engine = JpxEngine::new();
266    ///
267    /// // Search by concept
268    /// let results = engine.search_functions("hash", 10);
269    /// assert!(results.iter().any(|r| r.function.name == "md5"));
270    /// assert!(results.iter().any(|r| r.function.name == "sha256"));
271    ///
272    /// // Results are ranked by relevance
273    /// for result in &results {
274    ///     println!("{}: {} (score: {})",
275    ///         result.function.name,
276    ///         result.match_type,
277    ///         result.score
278    ///     );
279    /// }
280    /// ```
281    pub fn search_functions(&self, query: &str, limit: usize) -> Vec<SearchResult> {
282        let query_lower = query.to_lowercase();
283
284        // Expand query terms using synonyms
285        let expanded_terms = expand_search_terms(&query_lower);
286
287        let all_functions: Vec<_> = self.registry.functions().collect();
288        let mut results: Vec<SearchResult> = Vec::new();
289
290        for info in &all_functions {
291            let name_lower = info.name.to_lowercase();
292            let desc_lower = info.description.to_lowercase();
293            let category_lower = format!("{:?}", info.category).to_lowercase();
294            let signature_lower = info.signature.to_lowercase();
295            let aliases_lower: Vec<String> = info
296                .aliases
297                .iter()
298                .map(|a: &&str| a.to_lowercase())
299                .collect();
300
301            // Calculate match score and type
302            let (score, match_type) = calculate_match_score(
303                &query_lower,
304                &expanded_terms,
305                &MatchContext {
306                    name: &name_lower,
307                    aliases: &aliases_lower,
308                    category: &category_lower,
309                    description: &desc_lower,
310                    signature: &signature_lower,
311                },
312            );
313
314            if score > 0 {
315                results.push(SearchResult {
316                    function: FunctionDetail::from(*info),
317                    match_type,
318                    score,
319                });
320            }
321        }
322
323        // Sort by score descending, then by name
324        results.sort_by(|a, b| {
325            b.score
326                .cmp(&a.score)
327                .then_with(|| a.function.name.cmp(&b.function.name))
328        });
329
330        results.truncate(limit);
331        results
332    }
333
334    /// Finds functions similar to a given function.
335    ///
336    /// Returns functions grouped by relationship type:
337    /// - Same category (e.g., other string functions if input is "upper")
338    /// - Similar signature (same parameter/return types)
339    /// - Related concepts (overlapping description keywords)
340    ///
341    /// # Arguments
342    ///
343    /// * `name` - Name of the function to find similar functions for
344    ///
345    /// # Returns
346    ///
347    /// `Some(SimilarFunctionsResult)` if the function exists, `None` otherwise.
348    ///
349    /// # Example
350    ///
351    /// ```rust
352    /// use jpx_engine::JpxEngine;
353    ///
354    /// let engine = JpxEngine::new();
355    ///
356    /// let similar = engine.similar_functions("upper").unwrap();
357    ///
358    /// // Other string functions
359    /// println!("Same category:");
360    /// for f in &similar.same_category {
361    ///     println!("  - {}", f.name);
362    /// }
363    /// ```
364    pub fn similar_functions(&self, name: &str) -> Option<SimilarFunctionsResult> {
365        let info = self.registry.get_function(name)?;
366        let all_functions: Vec<_> = self.registry.functions().collect();
367
368        // Same category. Sorted by name before truncating so the result is
369        // deterministic (the registry iterates in HashMap order, so an
370        // unsorted `take(5)` returned an arbitrary, run-varying subset).
371        let mut same_category_fns: Vec<&FunctionInfo> = all_functions
372            .iter()
373            .copied()
374            .filter(|f| f.category == info.category && f.name != info.name)
375            .collect();
376        same_category_fns.sort_by_key(|f| f.name);
377        let same_category: Vec<FunctionDetail> = same_category_fns
378            .into_iter()
379            .take(5)
380            .map(FunctionDetail::from)
381            .collect();
382
383        // Similar signature (same arity), likewise name-sorted for determinism.
384        let this_arity = count_params(info.signature);
385        let mut similar_signature_fns: Vec<&FunctionInfo> = all_functions
386            .iter()
387            .copied()
388            .filter(|f| {
389                f.name != info.name
390                    && f.category != info.category
391                    && count_params(f.signature) == this_arity
392            })
393            .collect();
394        similar_signature_fns.sort_by_key(|f| f.name);
395        let similar_signature: Vec<FunctionDetail> = similar_signature_fns
396            .into_iter()
397            .take(5)
398            .map(FunctionDetail::from)
399            .collect();
400
401        // Related concepts (description keyword overlap)
402        let keywords = extract_keywords(info.description);
403        let mut concept_scores: Vec<(&FunctionInfo, usize)> = all_functions
404            .iter()
405            .filter(|f| f.name != info.name)
406            .map(|f| {
407                let f_keywords = extract_keywords(f.description);
408                let overlap = keywords.iter().filter(|k| f_keywords.contains(*k)).count();
409                (*f, overlap)
410            })
411            .filter(|(_, score)| *score > 0)
412            .collect();
413
414        // Highest overlap first, breaking ties by name so the result is stable
415        // across runs (the underlying iteration order is not).
416        concept_scores.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name.cmp(b.0.name)));
417
418        let related_concepts: Vec<FunctionDetail> = concept_scores
419            .into_iter()
420            .take(5)
421            .map(|(f, _)| FunctionDetail::from(f))
422            .collect();
423
424        Some(SimilarFunctionsResult {
425            same_category,
426            similar_signature,
427            related_concepts,
428        })
429    }
430}
431
432// =============================================================================
433// Helper functions
434// =============================================================================
435
436/// Context for calculating match scores
437struct MatchContext<'a> {
438    name: &'a str,
439    aliases: &'a [String],
440    category: &'a str,
441    description: &'a str,
442    signature: &'a str,
443}
444
445/// Calculate match score and type for a function
446fn calculate_match_score(
447    query: &str,
448    expanded_terms: &[String],
449    ctx: &MatchContext,
450) -> (i32, String) {
451    // Exact name match
452    if ctx.name == query {
453        return (1000, "exact_name".to_string());
454    }
455
456    // Alias match
457    if ctx.aliases.iter().any(|a| a == query) {
458        return (900, "alias".to_string());
459    }
460
461    // Name starts with query
462    if ctx.name.starts_with(query) {
463        return (800, "name_prefix".to_string());
464    }
465
466    // Name contains query
467    if ctx.name.contains(query) {
468        return (700, "name_contains".to_string());
469    }
470
471    // Category match
472    if ctx.category == query {
473        return (600, "category".to_string());
474    }
475
476    // Check expanded terms in description/signature
477    let mut desc_score = 0;
478    let mut matched_terms = Vec::new();
479
480    for term in expanded_terms {
481        if ctx.description.contains(term) || ctx.signature.contains(term) {
482            desc_score += 100;
483            matched_terms.push(term.clone());
484        }
485    }
486
487    if desc_score > 0 {
488        return (
489            desc_score,
490            format!("description ({})", matched_terms.join(", ")),
491        );
492    }
493
494    // Fuzzy name match using Jaro-Winkler
495    let similarity = jaro_winkler(query, ctx.name);
496    if similarity > 0.8 {
497        return ((similarity * 500.0) as i32, "fuzzy_name".to_string());
498    }
499
500    // Check synonyms
501    if let Some(synonyms) = lookup_synonyms(query) {
502        for syn in synonyms {
503            if ctx.name.contains(syn) || ctx.description.contains(syn) {
504                return (300, format!("synonym ({})", syn));
505            }
506        }
507    }
508
509    (0, String::new())
510}
511
512/// Parse category string to Category enum
513pub(crate) fn parse_category(name: &str) -> Option<Category> {
514    let input = name.to_lowercase();
515    Category::all()
516        .iter()
517        .find(|cat| cat.name() == input)
518        .copied()
519}
520
521/// Count parameters in a function signature, ignoring commas inside brackets.
522///
523/// Handles signatures like `array, array[[string, string]] -> array` correctly
524/// by tracking bracket depth and only counting top-level commas before `->`.
525fn count_params(signature: &str) -> usize {
526    // Only look at the input side (before "->")
527    let input = match signature.find("->") {
528        Some(pos) => &signature[..pos],
529        None => signature,
530    };
531    let input = input.trim();
532    if input.is_empty() {
533        return 0;
534    }
535
536    let mut params = 1;
537    let mut depth = 0;
538    for ch in input.chars() {
539        match ch {
540            '[' | '(' => depth += 1,
541            ']' | ')' => depth -= 1,
542            ',' if depth == 0 => params += 1,
543            _ => {}
544        }
545    }
546    params
547}
548
549/// Extract keywords from a description for related concept matching
550fn extract_keywords(description: &str) -> Vec<&str> {
551    let stopwords = [
552        "a",
553        "an",
554        "the",
555        "is",
556        "are",
557        "was",
558        "were",
559        "be",
560        "been",
561        "being",
562        "have",
563        "has",
564        "had",
565        "do",
566        "does",
567        "did",
568        "will",
569        "would",
570        "could",
571        "should",
572        "may",
573        "might",
574        "must",
575        "shall",
576        "can",
577        "to",
578        "of",
579        "in",
580        "for",
581        "on",
582        "with",
583        "at",
584        "by",
585        "from",
586        "or",
587        "and",
588        "as",
589        "if",
590        "that",
591        "which",
592        "this",
593        "these",
594        "those",
595        "it",
596        "its",
597        "such",
598        "when",
599        "where",
600        "how",
601        "all",
602        "each",
603        "every",
604        "both",
605        "few",
606        "more",
607        "most",
608        "other",
609        "some",
610        "any",
611        "no",
612        "not",
613        "only",
614        "same",
615        "than",
616        "very",
617        "just",
618        "also",
619        "into",
620        "over",
621        "after",
622        "before",
623        "between",
624        "under",
625        "again",
626        "further",
627        "then",
628        "once",
629        "here",
630        "there",
631        "why",
632        "because",
633        "while",
634        "although",
635        "though",
636        "unless",
637        "until",
638        "whether",
639        "returns",
640        "return",
641        "value",
642        "values",
643        "given",
644        "input",
645        "output",
646        "function",
647        "functions",
648        "used",
649        "using",
650        "use",
651    ];
652
653    description
654        .split(|c: char| !c.is_alphanumeric())
655        .filter(|w| w.len() > 2 && !stopwords.contains(&w.to_lowercase().as_str()))
656        .collect()
657}
658
659#[cfg(test)]
660mod tests {
661    use crate::JpxEngine;
662
663    #[test]
664    fn test_categories() {
665        let engine = JpxEngine::new();
666        let cats = engine.categories();
667        assert!(!cats.is_empty());
668        assert!(cats.iter().any(|c| c == "String"));
669    }
670
671    #[test]
672    fn test_functions() {
673        let engine = JpxEngine::new();
674
675        // All functions
676        let all = engine.functions(None);
677        assert!(!all.is_empty());
678
679        // Filtered by category
680        let string_funcs = engine.functions(Some("String"));
681        assert!(!string_funcs.is_empty());
682        assert!(string_funcs.iter().all(|f| f.category == "String"));
683    }
684
685    #[test]
686    fn test_describe_function() {
687        let engine = JpxEngine::new();
688
689        let info = engine.describe_function("upper").unwrap();
690        assert_eq!(info.name, "upper");
691        assert_eq!(info.category, "String");
692
693        let missing = engine.describe_function("nonexistent");
694        assert!(missing.is_none());
695    }
696
697    #[test]
698    fn test_describe_functions_batch() {
699        let engine = JpxEngine::new();
700
701        let results = engine.describe_functions(&["upper", "nonexistent", "lower"]);
702        assert_eq!(results.len(), 3);
703
704        // Order is preserved and each name is echoed back.
705        assert_eq!(results[0].0, "upper");
706        assert_eq!(results[0].1.as_ref().unwrap().name, "upper");
707
708        // Missing names yield None rather than failing the whole batch.
709        assert_eq!(results[1].0, "nonexistent");
710        assert!(results[1].1.is_none());
711
712        assert_eq!(results[2].0, "lower");
713        assert_eq!(results[2].1.as_ref().unwrap().name, "lower");
714
715        // Empty input yields an empty result.
716        assert!(engine.describe_functions(&[]).is_empty());
717    }
718
719    #[test]
720    fn test_search_functions() {
721        let engine = JpxEngine::new();
722
723        let results = engine.search_functions("string", 10);
724        assert!(!results.is_empty());
725    }
726
727    #[test]
728    fn test_similar_functions() {
729        let engine = JpxEngine::new();
730
731        let result = engine.similar_functions("upper").unwrap();
732        // Should have functions in same category
733        assert!(!result.same_category.is_empty());
734    }
735
736    // =========================================================================
737    // Categories tests
738    // =========================================================================
739
740    #[test]
741    fn test_categories_contains_common() {
742        let engine = JpxEngine::new();
743        let cats = engine.categories();
744        for expected in &["Math", "Array", "Object", "Utility"] {
745            assert!(
746                cats.iter().any(|c| c == expected),
747                "Expected categories to contain {:?}",
748                expected
749            );
750        }
751    }
752
753    #[test]
754    fn test_categories_no_duplicates() {
755        let engine = JpxEngine::new();
756        let cats = engine.categories();
757        let mut seen = std::collections::HashSet::new();
758        for cat in &cats {
759            assert!(
760                seen.insert(cat.clone()),
761                "Duplicate category found: {:?}",
762                cat
763            );
764        }
765    }
766
767    // =========================================================================
768    // Functions tests
769    // =========================================================================
770
771    #[test]
772    fn test_functions_all() {
773        let engine = JpxEngine::new();
774        let all = engine.functions(None);
775        assert!(
776            !all.is_empty(),
777            "functions(None) should return a non-empty list"
778        );
779        // There should be a substantial number of functions (490+)
780        assert!(
781            all.len() > 100,
782            "Expected at least 100 functions, got {}",
783            all.len()
784        );
785    }
786
787    #[test]
788    fn test_functions_invalid_category() {
789        let engine = JpxEngine::new();
790        let invalid = engine.functions(Some("NonexistentCategory"));
791        assert!(
792            invalid.is_empty(),
793            "Invalid category should return empty list, got {} functions",
794            invalid.len()
795        );
796    }
797
798    #[test]
799    fn test_functions_multi_match_category() {
800        let engine = JpxEngine::new();
801        let results = engine.functions(Some("multi-match"));
802        assert!(
803            !results.is_empty(),
804            "multi-match category should return functions"
805        );
806        let all = engine.functions(None);
807        assert!(
808            results.len() < all.len(),
809            "multi-match should return a subset, not all {} functions",
810            all.len()
811        );
812    }
813
814    #[test]
815    fn test_functions_case_insensitive() {
816        let engine = JpxEngine::new();
817        let lower = engine.functions(Some("string"));
818        let upper = engine.functions(Some("String"));
819        assert!(
820            !lower.is_empty(),
821            "functions(Some(\"string\")) should return results"
822        );
823        assert_eq!(
824            lower.len(),
825            upper.len(),
826            "Case-insensitive category lookup should return the same number of results"
827        );
828        // Verify same function names in both results
829        let lower_names: Vec<&str> = lower.iter().map(|f| f.name.as_str()).collect();
830        let upper_names: Vec<&str> = upper.iter().map(|f| f.name.as_str()).collect();
831        assert_eq!(lower_names, upper_names);
832    }
833
834    // =========================================================================
835    // describe_function tests
836    // =========================================================================
837
838    #[test]
839    fn test_describe_function_detail_fields() {
840        let engine = JpxEngine::new();
841        let info = engine.describe_function("length").unwrap();
842        assert_eq!(info.name, "length");
843        assert!(!info.category.is_empty(), "category should not be empty");
844        assert!(info.is_standard, "length should be a standard function");
845        assert!(
846            !info.description.is_empty(),
847            "description should not be empty"
848        );
849        assert!(!info.signature.is_empty(), "signature should not be empty");
850    }
851
852    #[test]
853    fn test_describe_function_extension() {
854        let engine = JpxEngine::new();
855        let info = engine.describe_function("upper").unwrap();
856        assert_eq!(info.name, "upper");
857        assert!(!info.is_standard, "upper should not be a standard function");
858    }
859
860    #[test]
861    fn test_describe_function_by_alias() {
862        let engine = JpxEngine::new();
863        // "all_expr" has alias "every"
864        let info = engine.describe_function("all_expr").unwrap();
865        assert!(
866            info.aliases.contains(&"every".to_string()),
867            "all_expr should have alias 'every', aliases: {:?}",
868            info.aliases
869        );
870        // Verify that searching for the alias via search_functions finds it
871        let results = engine.search_functions("every", 5);
872        assert!(
873            results.iter().any(|r| r.function.name == "all_expr"),
874            "Searching for alias 'every' should find 'all_expr'"
875        );
876        let alias_result = results
877            .iter()
878            .find(|r| r.function.name == "all_expr")
879            .unwrap();
880        assert_eq!(
881            alias_result.match_type, "alias",
882            "Match type for alias search should be 'alias'"
883        );
884        assert_eq!(alias_result.score, 900, "Alias match score should be 900");
885    }
886
887    // =========================================================================
888    // search_functions tests
889    // =========================================================================
890
891    #[test]
892    fn test_search_exact_name_match() {
893        let engine = JpxEngine::new();
894        let results = engine.search_functions("upper", 10);
895        assert!(!results.is_empty(), "Should find results for 'upper'");
896        let first = &results[0];
897        assert_eq!(first.function.name, "upper");
898        assert_eq!(first.match_type, "exact_name");
899        assert_eq!(first.score, 1000);
900    }
901
902    #[test]
903    fn test_search_name_prefix_match() {
904        let engine = JpxEngine::new();
905        let results = engine.search_functions("to_", 20);
906        assert!(!results.is_empty(), "Should find results for prefix 'to_'");
907        // All top results should have name_prefix match type
908        let prefix_results: Vec<_> = results
909            .iter()
910            .filter(|r| r.match_type == "name_prefix")
911            .collect();
912        assert!(
913            !prefix_results.is_empty(),
914            "Should have at least one name_prefix match"
915        );
916        for r in &prefix_results {
917            assert_eq!(r.score, 800, "name_prefix score should be 800");
918            assert!(
919                r.function.name.starts_with("to_"),
920                "Function '{}' should start with 'to_'",
921                r.function.name
922            );
923        }
924    }
925
926    #[test]
927    fn test_search_name_contains_match() {
928        let engine = JpxEngine::new();
929        // "left" appears in "pad_left" but "pad_left" does not start with "left"
930        let results = engine.search_functions("left", 20);
931        let contains_results: Vec<_> = results
932            .iter()
933            .filter(|r| r.match_type == "name_contains")
934            .collect();
935        assert!(
936            !contains_results.is_empty(),
937            "Should have at least one name_contains match for 'left'"
938        );
939        for r in &contains_results {
940            assert_eq!(r.score, 700, "name_contains score should be 700");
941            assert!(
942                r.function.name.contains("left"),
943                "Function '{}' should contain 'left'",
944                r.function.name
945            );
946        }
947    }
948
949    #[test]
950    fn test_search_description_match() {
951        let engine = JpxEngine::new();
952        // "converts" is a word likely found in descriptions but not as a function name
953        let results = engine.search_functions("converts", 10);
954        if !results.is_empty() {
955            // If we got description matches, verify the match type
956            let desc_matches: Vec<_> = results
957                .iter()
958                .filter(|r| r.match_type.starts_with("description"))
959                .collect();
960            assert!(
961                !desc_matches.is_empty(),
962                "Matches for 'converts' should include description matches"
963            );
964            for r in &desc_matches {
965                assert!(
966                    r.score >= 100,
967                    "Description match score should be at least 100"
968                );
969            }
970        }
971    }
972
973    #[test]
974    fn test_search_case_insensitive() {
975        let engine = JpxEngine::new();
976        let upper_results = engine.search_functions("UPPER", 10);
977        let lower_results = engine.search_functions("upper", 10);
978        // Both should find the "upper" function
979        assert!(
980            upper_results.iter().any(|r| r.function.name == "upper"),
981            "Searching for 'UPPER' should find 'upper'"
982        );
983        assert!(
984            lower_results.iter().any(|r| r.function.name == "upper"),
985            "Searching for 'upper' should find 'upper'"
986        );
987    }
988
989    #[test]
990    fn test_search_respects_limit() {
991        let engine = JpxEngine::new();
992        let results = engine.search_functions("string", 3);
993        assert!(
994            results.len() <= 3,
995            "Results should be limited to 3, got {}",
996            results.len()
997        );
998    }
999
1000    #[test]
1001    fn test_search_results_ordered_by_score() {
1002        let engine = JpxEngine::new();
1003        let results = engine.search_functions("string", 20);
1004        for window in results.windows(2) {
1005            assert!(
1006                window[0].score >= window[1].score,
1007                "Results should be sorted by score descending: {} (score {}) came before {} (score {})",
1008                window[0].function.name,
1009                window[0].score,
1010                window[1].function.name,
1011                window[1].score,
1012            );
1013        }
1014    }
1015
1016    #[test]
1017    fn test_search_empty_query() {
1018        let engine = JpxEngine::new();
1019        // Empty string search should not panic
1020        let results = engine.search_functions("", 10);
1021        // Empty query may or may not return results, but it must not panic
1022        let _ = results;
1023    }
1024
1025    #[test]
1026    fn test_search_no_results() {
1027        let engine = JpxEngine::new();
1028        let results = engine.search_functions("xyzqwerty123", 10);
1029        assert!(
1030            results.is_empty(),
1031            "Should find no results for nonsense query, got {}",
1032            results.len()
1033        );
1034    }
1035
1036    // =========================================================================
1037    // similar_functions tests
1038    // =========================================================================
1039
1040    #[test]
1041    fn test_similar_functions_nonexistent() {
1042        let engine = JpxEngine::new();
1043        let result = engine.similar_functions("nonexistent");
1044        assert!(
1045            result.is_none(),
1046            "similar_functions for nonexistent function should return None"
1047        );
1048    }
1049
1050    #[test]
1051    fn test_similar_same_category_populated() {
1052        let engine = JpxEngine::new();
1053        let result = engine.similar_functions("upper").unwrap();
1054        assert!(
1055            !result.same_category.is_empty(),
1056            "same_category should be populated for 'upper'"
1057        );
1058        // All same_category results should be String functions (same as upper)
1059        for f in &result.same_category {
1060            assert_eq!(
1061                f.category, "String",
1062                "same_category function '{}' should be in String category, got '{}'",
1063                f.name, f.category
1064            );
1065        }
1066        // "upper" itself should not appear in the same_category list
1067        assert!(
1068            !result.same_category.iter().any(|f| f.name == "upper"),
1069            "same_category should not contain the function itself"
1070        );
1071    }
1072
1073    #[test]
1074    fn test_similar_functions_deterministic_and_ordered() {
1075        let engine = JpxEngine::new();
1076        let a = engine.similar_functions("upper").unwrap();
1077        let b = engine.similar_functions("upper").unwrap();
1078
1079        let names = |r: &super::SimilarFunctionsResult| {
1080            (
1081                r.same_category
1082                    .iter()
1083                    .map(|f| f.name.clone())
1084                    .collect::<Vec<_>>(),
1085                r.similar_signature
1086                    .iter()
1087                    .map(|f| f.name.clone())
1088                    .collect::<Vec<_>>(),
1089                r.related_concepts
1090                    .iter()
1091                    .map(|f| f.name.clone())
1092                    .collect::<Vec<_>>(),
1093            )
1094        };
1095        // Repeated calls must return identical results (no HashMap-order flakiness).
1096        assert_eq!(names(&a), names(&b));
1097
1098        // same_category and similar_signature are name-sorted.
1099        let cat: Vec<_> = a.same_category.iter().map(|f| &f.name).collect();
1100        let mut cat_sorted = cat.clone();
1101        cat_sorted.sort();
1102        assert_eq!(cat, cat_sorted, "same_category should be name-sorted");
1103
1104        let sig: Vec<_> = a.similar_signature.iter().map(|f| &f.name).collect();
1105        let mut sig_sorted = sig.clone();
1106        sig_sorted.sort();
1107        assert_eq!(sig, sig_sorted, "similar_signature should be name-sorted");
1108    }
1109
1110    #[test]
1111    fn test_count_params_simple() {
1112        assert_eq!(super::count_params("string -> string"), 1);
1113        assert_eq!(super::count_params("string, string -> string"), 2);
1114        assert_eq!(super::count_params("string, number, string -> string"), 3);
1115    }
1116
1117    #[test]
1118    fn test_count_params_brackets() {
1119        // order_by: array, array[[string, string]] -> array
1120        assert_eq!(
1121            super::count_params("array, array[[string, string]] -> array"),
1122            2
1123        );
1124        // nested brackets
1125        assert_eq!(
1126            super::count_params("array[object[string, number]] -> array"),
1127            1
1128        );
1129    }
1130
1131    #[test]
1132    fn test_count_params_optional_and_variadic() {
1133        assert_eq!(super::count_params("string, string? -> string"), 2);
1134        assert_eq!(super::count_params("string, ...any -> array"), 2);
1135    }
1136
1137    #[test]
1138    fn test_count_params_empty() {
1139        assert_eq!(super::count_params("-> string"), 0);
1140    }
1141
1142    #[test]
1143    fn test_similar_signature_uses_correct_arity() {
1144        let engine = super::super::JpxEngine::new();
1145        // order_by has 2 params; similar_signature should match other 2-param functions
1146        let result = engine.similar_functions("order_by").unwrap();
1147        for f in &result.similar_signature {
1148            let arity = super::count_params(&f.signature);
1149            assert_eq!(
1150                arity, 2,
1151                "similar_signature match '{}' (sig: '{}') should have arity 2, got {}",
1152                f.name, f.signature, arity
1153            );
1154        }
1155    }
1156
1157    #[test]
1158    fn test_similar_functions_serde() {
1159        let engine = JpxEngine::new();
1160        let result = engine.similar_functions("upper").unwrap();
1161        // Should serialize to JSON without error
1162        let json = serde_json::to_string(&result)
1163            .expect("SimilarFunctionsResult should serialize to JSON");
1164        assert!(!json.is_empty(), "Serialized JSON should not be empty");
1165        // Should deserialize back
1166        let deserialized: super::SimilarFunctionsResult = serde_json::from_str(&json)
1167            .expect("SimilarFunctionsResult should deserialize from JSON");
1168        assert_eq!(
1169            result.same_category.len(),
1170            deserialized.same_category.len(),
1171            "Round-trip should preserve same_category length"
1172        );
1173    }
1174}