mrapids 0.1.31

Your OpenAPI, but executable
Documentation
// Fuzzy Matcher - Levenshtein-based fuzzy matching for typo recovery
// Uses length-based distance thresholds to prevent false positives

use strsim::levenshtein;

/// Result of a single fuzzy match between two tokens
#[derive(Debug, Clone)]
pub struct FuzzyHit {
    pub query_token: String,
    pub matched_token: String,
    pub distance: usize,
}

/// Check if two tokens are a fuzzy match within length-based threshold.
///
/// Thresholds:
///   token length <= 4:  max distance 1
///   token length 5-9:   max distance 2
///   token length >= 10:  max distance 3
pub fn is_fuzzy_match(query_token: &str, candidate_token: &str) -> bool {
    let distance = levenshtein(query_token, candidate_token);

    if distance == 0 {
        return true; // exact match
    }

    // Use the shorter token's length for threshold (more conservative)
    let len = query_token.len().min(candidate_token.len());
    let max_distance = match len {
        0..=4 => 1,
        5..=9 => 2,
        _ => 3,
    };

    distance <= max_distance
}

/// Compute fuzzy match ratio (0.0-1.0) between two token sets.
///
/// For each query token, finds the best matching candidate token.
/// Returns the ratio of matched query tokens and the match details.
pub fn fuzzy_match_ratio(
    query_tokens: &[String],
    candidate_tokens: &[String],
) -> (f64, Vec<FuzzyHit>) {
    if query_tokens.is_empty() {
        return (0.0, Vec::new());
    }

    let mut hits = Vec::new();
    let mut matched_count = 0;

    for qt in query_tokens {
        let mut best_distance = usize::MAX;
        let mut best_candidate = None;

        for ct in candidate_tokens {
            let distance = levenshtein(qt, ct);
            if distance < best_distance {
                best_distance = distance;
                best_candidate = Some(ct.clone());
            }
        }

        if let Some(candidate) = best_candidate {
            if is_fuzzy_match(qt, &candidate) {
                matched_count += 1;
                hits.push(FuzzyHit {
                    query_token: qt.clone(),
                    matched_token: candidate,
                    distance: best_distance,
                });
            }
        }
    }

    let ratio = matched_count as f64 / query_tokens.len() as f64;
    (ratio, hits)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_threshold_short_token() {
        // "fin" vs "find" — len 3, distance 1 → match
        assert!(is_fuzzy_match("fin", "find"));
    }

    #[test]
    fn test_threshold_medium_token() {
        // "stauts" vs "status" — len 6, distance 2 → match
        assert!(is_fuzzy_match("stauts", "status"));
    }

    #[test]
    fn test_threshold_long_token() {
        // "subscrption" vs "subscription" — distance 1 → match
        assert!(is_fuzzy_match("subscrption", "subscription"));
    }

    #[test]
    fn test_threshold_exceeded() {
        // "abc" vs "xyz" — len 3, distance 3 → no match
        assert!(!is_fuzzy_match("abc", "xyz"));
    }

    #[test]
    fn test_fuzzy_ratio() {
        let query = vec!["fin".to_string(), "pets".to_string()];
        let candidate = vec![
            "find".to_string(),
            "pets".to_string(),
            "by".to_string(),
            "status".to_string(),
        ];
        let (ratio, hits) = fuzzy_match_ratio(&query, &candidate);
        assert!(ratio > 0.0);
        assert_eq!(hits.len(), 2); // "fin"→"find" and "pets"→"pets"
    }

    #[test]
    fn test_exact_match_distance_zero() {
        assert!(is_fuzzy_match("status", "status"));
        let query = vec!["status".to_string()];
        let candidate = vec!["status".to_string()];
        let (ratio, hits) = fuzzy_match_ratio(&query, &candidate);
        assert_eq!(ratio, 1.0);
        assert_eq!(hits[0].distance, 0);
    }

    #[test]
    fn test_empty_query_tokens() {
        let (ratio, hits) = fuzzy_match_ratio(&[], &["test".to_string()]);
        assert_eq!(ratio, 0.0);
        assert!(hits.is_empty());
    }

    #[test]
    fn test_no_match_completely_different() {
        // "hello" vs "world" — distance 4, len 5, threshold 2 → no match
        assert!(!is_fuzzy_match("hello", "world"));
    }
}