1use crate::JpxEngine;
8use jpx_core::registry::{Category, FunctionInfo, expand_search_terms, lookup_synonyms};
9use serde::{Deserialize, Serialize};
10use strsim::jaro_winkler;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct FunctionDetail {
32 pub name: String,
34 pub category: String,
36 pub description: String,
38 pub signature: String,
40 pub example: String,
42 pub is_standard: bool,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub jep: Option<String>,
47 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct SearchResult {
85 pub function: FunctionDetail,
87 pub match_type: String,
89 pub score: i32,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct SimilarFunctionsResult {
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub same_category: Vec<FunctionDetail>,
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
123 pub similar_signature: Vec<FunctionDetail>,
124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub related_concepts: Vec<FunctionDetail>,
127}
128
129impl JpxEngine {
134 pub fn categories(&self) -> Vec<String> {
151 Category::all().iter().map(|c| format!("{:?}", c)).collect()
152 }
153
154 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 pub fn describe_function(&self, name: &str) -> Option<FunctionDetail> {
220 self.registry.get_function(name).map(FunctionDetail::from)
221 }
222
223 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 pub fn search_functions(&self, query: &str, limit: usize) -> Vec<SearchResult> {
282 let query_lower = query.to_lowercase();
283
284 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 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 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 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 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 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 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 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
432struct MatchContext<'a> {
438 name: &'a str,
439 aliases: &'a [String],
440 category: &'a str,
441 description: &'a str,
442 signature: &'a str,
443}
444
445fn calculate_match_score(
447 query: &str,
448 expanded_terms: &[String],
449 ctx: &MatchContext,
450) -> (i32, String) {
451 if ctx.name == query {
453 return (1000, "exact_name".to_string());
454 }
455
456 if ctx.aliases.iter().any(|a| a == query) {
458 return (900, "alias".to_string());
459 }
460
461 if ctx.name.starts_with(query) {
463 return (800, "name_prefix".to_string());
464 }
465
466 if ctx.name.contains(query) {
468 return (700, "name_contains".to_string());
469 }
470
471 if ctx.category == query {
473 return (600, "category".to_string());
474 }
475
476 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 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 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
512pub(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
521fn count_params(signature: &str) -> usize {
526 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
549fn 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 let all = engine.functions(None);
677 assert!(!all.is_empty());
678
679 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 assert_eq!(results[0].0, "upper");
706 assert_eq!(results[0].1.as_ref().unwrap().name, "upper");
707
708 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 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 assert!(!result.same_category.is_empty());
734 }
735
736 #[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 #[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 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 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 #[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 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 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 #[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 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 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 let results = engine.search_functions("converts", 10);
954 if !results.is_empty() {
955 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 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 let results = engine.search_functions("", 10);
1021 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 #[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 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 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 assert_eq!(names(&a), names(&b));
1097
1098 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 assert_eq!(
1121 super::count_params("array, array[[string, string]] -> array"),
1122 2
1123 );
1124 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 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 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 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}