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