use crate::clip::Clip;
#[derive(Debug, Clone, Default)]
pub struct FuzzyMatcher;
impl FuzzyMatcher {
#[must_use]
pub fn new() -> Self {
Self
}
#[must_use]
pub fn score(pattern: &str, text: &str) -> f32 {
let p = pattern.to_lowercase();
let t = text.to_lowercase();
let p_len = p.chars().count();
let t_len = t.chars().count();
if p_len == 0 && t_len == 0 {
return 1.0;
}
let max_len = p_len.max(t_len);
let dist = levenshtein(&p, &t);
1.0 - (dist as f32 / max_len as f32)
}
#[must_use]
pub fn match_clips<'a>(
clips: &'a [Clip],
pattern: &str,
min_score: f32,
) -> Vec<(f32, &'a Clip)> {
let mut results: Vec<(f32, &Clip)> = clips
.iter()
.filter_map(|clip| {
let best = clip_best_score(clip, pattern);
if best >= min_score {
Some((best, clip))
} else {
None
}
})
.collect();
results.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
results
}
#[must_use]
pub fn is_match(pattern: &str, text: &str, min_score: f32) -> bool {
Self::score(pattern, text) >= min_score
}
}
fn clip_best_score(clip: &Clip, pattern: &str) -> f32 {
let mut best = FuzzyMatcher::score(pattern, &clip.name);
for kw in &clip.keywords {
let s = FuzzyMatcher::score(pattern, kw);
if s > best {
best = s;
}
}
if let Some(desc) = &clip.description {
let s = FuzzyMatcher::score(pattern, desc);
if s > best {
best = s;
}
}
best
}
fn levenshtein(a: &str, b: &str) -> usize {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let m = a_chars.len();
let n = b_chars.len();
if m == 0 {
return n;
}
if n == 0 {
return m;
}
let (row_src, col_src) = if m <= n {
(&a_chars[..], &b_chars[..])
} else {
(&b_chars[..], &a_chars[..])
};
let row_len = row_src.len();
let col_len = col_src.len();
let mut prev: Vec<usize> = (0..=row_len).collect();
let mut curr = vec![0usize; row_len + 1];
for i in 1..=col_len {
curr[0] = i;
for j in 1..=row_len {
let cost = if col_src[i - 1] == row_src[j - 1] {
0
} else {
1
};
curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[row_len]
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_levenshtein_identical() {
assert_eq!(levenshtein("kitten", "kitten"), 0);
}
#[test]
fn test_levenshtein_empty_a() {
assert_eq!(levenshtein("", "abc"), 3);
}
#[test]
fn test_levenshtein_empty_b() {
assert_eq!(levenshtein("abc", ""), 3);
}
#[test]
fn test_levenshtein_classic() {
assert_eq!(levenshtein("kitten", "sitting"), 3);
}
#[test]
fn test_levenshtein_single_insertion() {
assert_eq!(levenshtein("abc", "abcd"), 1);
}
#[test]
fn test_levenshtein_single_deletion() {
assert_eq!(levenshtein("abcd", "abc"), 1);
}
#[test]
fn test_score_exact_match() {
let s = FuzzyMatcher::score("interview", "interview");
assert!((s - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_score_case_insensitive() {
let s = FuzzyMatcher::score("Interview", "interview");
assert!((s - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_score_completely_different() {
let s = FuzzyMatcher::score("aaa", "zzz");
assert!((s - 0.0).abs() < f32::EPSILON);
}
#[test]
fn test_score_empty_both() {
let s = FuzzyMatcher::score("", "");
assert!((s - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_score_partial_match() {
let s = FuzzyMatcher::score("interv", "interview");
assert!(s > 0.5 && s < 1.0);
}
#[test]
fn test_is_match_true() {
assert!(FuzzyMatcher::is_match("inteview", "interview", 0.7));
}
#[test]
fn test_is_match_false() {
assert!(!FuzzyMatcher::is_match("xyz", "interview", 0.9));
}
fn make_clip(name: &str, keywords: &[&str]) -> Clip {
let mut c = Clip::new(PathBuf::from(format!("/test/{name}.mov")));
c.set_name(name);
for kw in keywords {
c.add_keyword(*kw);
}
c
}
#[test]
fn test_match_clips_by_name() {
let clips = vec![
make_clip("Interview John", &[]),
make_clip("B-Roll Outdoor", &[]),
];
let results = FuzzyMatcher::match_clips(&clips, "interview", 0.5);
assert_eq!(results.len(), 1);
assert_eq!(results[0].1.name, "Interview John");
}
#[test]
fn test_match_clips_by_keyword() {
let clips = vec![
make_clip("Shot A", &["outdoor", "sunny"]),
make_clip("Shot B", &["indoor", "dark"]),
];
let results = FuzzyMatcher::match_clips(&clips, "outdoor", 0.8);
assert_eq!(results.len(), 1);
assert_eq!(results[0].1.name, "Shot A");
}
#[test]
fn test_match_clips_sorted_descending() {
let clips = vec![
make_clip("cat", &[]), make_clip("elephant", &[]), make_clip("bat", &[]), ];
let results = FuzzyMatcher::match_clips(&clips, "bat", 0.0);
assert_eq!(results.len(), 3);
assert!(results[0].0 >= results[1].0);
assert!(results[1].0 >= results[2].0);
}
#[test]
fn test_match_clips_min_score_filter() {
let clips = vec![
make_clip("hello", &[]),
make_clip("world", &[]),
make_clip("help", &[]),
];
let high = FuzzyMatcher::match_clips(&clips, "hello", 0.8);
assert_eq!(high.len(), 1);
}
#[test]
fn test_match_clips_empty_list() {
let results = FuzzyMatcher::match_clips(&[], "interview", 0.5);
assert!(results.is_empty());
}
#[test]
fn test_match_clips_empty_pattern_matches_all_at_zero_threshold() {
let clips = vec![make_clip("A", &[]), make_clip("B", &[])];
let results = FuzzyMatcher::match_clips(&clips, "", 0.0);
assert_eq!(results.len(), 2);
}
}