Skip to main content

mixtape_tools/edit/
edit_block.rs

1use crate::filesystem::validate_path;
2use crate::prelude::*;
3use std::path::PathBuf;
4use strsim::normalized_levenshtein;
5
6/// Input for editing a block of text in a file
7#[derive(Debug, Deserialize, JsonSchema)]
8pub struct EditBlockInput {
9    /// Path to the file to edit
10    pub file_path: PathBuf,
11
12    /// Text to search for and replace
13    pub old_string: String,
14
15    /// Replacement text
16    pub new_string: String,
17
18    /// Expected number of replacements (default: 1)
19    #[serde(default = "default_replacements")]
20    pub expected_replacements: usize,
21
22    /// Enable fuzzy matching if exact match fails (default: true)
23    #[serde(default = "default_fuzzy")]
24    pub enable_fuzzy: bool,
25
26    /// Minimum similarity threshold for fuzzy matching (0.0-1.0, default: 0.7)
27    #[serde(default = "default_threshold")]
28    pub fuzzy_threshold: f32,
29}
30
31fn default_replacements() -> usize {
32    1
33}
34
35fn default_fuzzy() -> bool {
36    true
37}
38
39fn default_threshold() -> f32 {
40    0.7
41}
42
43/// Result of a fuzzy match
44#[derive(Debug)]
45struct FuzzyMatch {
46    start: usize,
47    end: usize,
48    similarity: f64,
49    matched_text: String,
50}
51
52/// Tool for surgical code editing with exact and fuzzy string replacement
53pub struct EditBlockTool {
54    base_path: PathBuf,
55}
56
57impl Default for EditBlockTool {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl EditBlockTool {
64    /// Create a new EditBlockTool using the current working directory as the base path
65    pub fn new() -> Self {
66        Self {
67            base_path: std::env::current_dir().expect("Failed to get current working directory"),
68        }
69    }
70
71    /// Create an EditBlockTool with a custom base directory
72    pub fn with_base_path(base_path: PathBuf) -> Self {
73        Self { base_path }
74    }
75
76    /// Find the best fuzzy match for a pattern in text
77    fn find_fuzzy_match(text: &str, pattern: &str, threshold: f32) -> Option<FuzzyMatch> {
78        let pattern_len = pattern.len();
79        if pattern_len == 0 || pattern_len > text.len() {
80            return None;
81        }
82
83        let mut best_match: Option<FuzzyMatch> = None;
84        let mut best_similarity = threshold as f64;
85
86        // Slide a window across the text
87        for start in 0..=(text.len() - pattern_len) {
88            let end = (start + pattern_len).min(text.len());
89            let window = &text[start..end];
90
91            let similarity = normalized_levenshtein(pattern, window);
92
93            if similarity > best_similarity {
94                best_similarity = similarity;
95                best_match = Some(FuzzyMatch {
96                    start,
97                    end,
98                    similarity,
99                    matched_text: window.to_string(),
100                });
101            }
102        }
103
104        // Also try with slightly larger and smaller windows
105        for window_size in [
106            pattern_len.saturating_sub(pattern_len / 10),
107            pattern_len + pattern_len / 10,
108        ] {
109            if window_size == 0 || window_size > text.len() {
110                continue;
111            }
112
113            for start in 0..=(text.len() - window_size) {
114                let end = (start + window_size).min(text.len());
115                let window = &text[start..end];
116
117                let similarity = normalized_levenshtein(pattern, window);
118
119                if similarity > best_similarity {
120                    best_similarity = similarity;
121                    best_match = Some(FuzzyMatch {
122                        start,
123                        end,
124                        similarity,
125                        matched_text: window.to_string(),
126                    });
127                }
128            }
129        }
130
131        best_match
132    }
133
134    /// Preserve the line ending style of the file
135    fn detect_line_ending(content: &str) -> &str {
136        if content.contains("\r\n") {
137            "\r\n"
138        } else {
139            "\n"
140        }
141    }
142}
143
144impl Tool for EditBlockTool {
145    type Input = EditBlockInput;
146
147    fn name(&self) -> &str {
148        "edit_block"
149    }
150
151    fn description(&self) -> &str {
152        "Edit a file by replacing text. Supports exact matching with fallback to fuzzy matching. Preserves file line endings."
153    }
154
155    async fn execute(&self, input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
156        let path = validate_path(&self.base_path, &input.file_path)
157            .map_err(|e| ToolError::from(e.to_string()))?;
158
159        // Read the file
160        let content = tokio::fs::read_to_string(&path)
161            .await
162            .map_err(|e| ToolError::from(format!("Failed to read file: {}", e)))?;
163
164        let line_ending = Self::detect_line_ending(&content);
165
166        // Try exact replacement first
167        let replacement_count = content.matches(&input.old_string).count();
168
169        let (new_content, actual_replacements, method) = if replacement_count > 0 {
170            // Exact match found
171            let new_content = content.replace(&input.old_string, &input.new_string);
172            (new_content, replacement_count, "exact".to_string())
173        } else if input.enable_fuzzy {
174            // Try fuzzy matching
175            match Self::find_fuzzy_match(&content, &input.old_string, input.fuzzy_threshold) {
176                Some(fuzzy_match) => {
177                    let new_content = format!(
178                        "{}{}{}",
179                        &content[..fuzzy_match.start],
180                        &input.new_string,
181                        &content[fuzzy_match.end..]
182                    );
183
184                    let info = format!(
185                        "fuzzy (similarity: {:.1}%)\nMatched text:\n{}",
186                        fuzzy_match.similarity * 100.0,
187                        fuzzy_match.matched_text
188                    );
189
190                    (new_content, 1, info)
191                }
192                None => {
193                    return Err(format!(
194                        "No match found for the specified text (tried exact and fuzzy matching with threshold {:.1}%)",
195                        input.fuzzy_threshold * 100.0
196                    ).into());
197                }
198            }
199        } else {
200            return Err("No exact match found and fuzzy matching is disabled".into());
201        };
202
203        // Validate replacement count
204        if actual_replacements != input.expected_replacements {
205            return Err(format!(
206                "Expected {} replacement(s) but found {}",
207                input.expected_replacements, actual_replacements
208            )
209            .into());
210        }
211
212        // Normalize line endings if needed
213        // First normalize to LF, then convert to target line ending to avoid double-CR
214        let final_content = if line_ending == "\r\n" {
215            // First convert any existing CRLF to LF to avoid doubling
216            let normalized = new_content.replace("\r\n", "\n");
217            // Then convert all LF to CRLF
218            normalized.replace('\n', "\r\n")
219        } else {
220            new_content
221        };
222
223        // Write the file
224        tokio::fs::write(&path, final_content.as_bytes())
225            .await
226            .map_err(|e| ToolError::from(format!("Failed to write file: {}", e)))?;
227
228        // Calculate line changes
229        let old_lines = input.old_string.lines().count();
230        let new_lines = input.new_string.lines().count();
231        let line_diff = new_lines as i64 - old_lines as i64;
232
233        let line_change = if line_diff > 0 {
234            format!("(\x1b[32m+{} lines\x1b[0m)", line_diff)
235        } else if line_diff < 0 {
236            format!("(\x1b[31m{} lines\x1b[0m)", line_diff)
237        } else {
238            "(no change in line count)".to_string()
239        };
240
241        let content = format!(
242            "Successfully edited {} using {} matching\n{} replacement(s) {}",
243            input.file_path.display(),
244            method,
245            actual_replacements,
246            line_change
247        );
248
249        Ok(content.into())
250    }
251
252    fn format_input_plain(&self, params: &serde_json::Value) -> String {
253        let file_path = params
254            .get("file_path")
255            .and_then(|v| v.as_str())
256            .unwrap_or("?");
257        let old_string = params
258            .get("old_string")
259            .and_then(|v| v.as_str())
260            .unwrap_or("");
261        let new_string = params
262            .get("new_string")
263            .and_then(|v| v.as_str())
264            .unwrap_or("");
265
266        let mut output = format!("edit_block: {}\n", file_path);
267        output.push_str("--- old\n");
268        for line in old_string.lines() {
269            output.push_str(&format!("- {}\n", line));
270        }
271        output.push_str("+++ new\n");
272        for line in new_string.lines() {
273            output.push_str(&format!("+ {}\n", line));
274        }
275        output
276    }
277
278    fn format_input_ansi(&self, params: &serde_json::Value) -> String {
279        let file_path = params
280            .get("file_path")
281            .and_then(|v| v.as_str())
282            .unwrap_or("?");
283        let old_string = params
284            .get("old_string")
285            .and_then(|v| v.as_str())
286            .unwrap_or("");
287        let new_string = params
288            .get("new_string")
289            .and_then(|v| v.as_str())
290            .unwrap_or("");
291
292        let mut output = format!("\x1b[1medit_block:\x1b[0m {}\n", file_path);
293        output.push_str("\x1b[31m--- old\x1b[0m\n");
294        for line in old_string.lines() {
295            output.push_str(&format!("\x1b[31m- {}\x1b[0m\n", line));
296        }
297        output.push_str("\x1b[32m+++ new\x1b[0m\n");
298        for line in new_string.lines() {
299            output.push_str(&format!("\x1b[32m+ {}\x1b[0m\n", line));
300        }
301        output
302    }
303
304    fn format_input_markdown(&self, params: &serde_json::Value) -> String {
305        let file_path = params
306            .get("file_path")
307            .and_then(|v| v.as_str())
308            .unwrap_or("?");
309        let old_string = params
310            .get("old_string")
311            .and_then(|v| v.as_str())
312            .unwrap_or("");
313        let new_string = params
314            .get("new_string")
315            .and_then(|v| v.as_str())
316            .unwrap_or("");
317
318        let mut output = format!("**edit_block:** `{}`\n\n```diff\n", file_path);
319        for line in old_string.lines() {
320            output.push_str(&format!("- {}\n", line));
321        }
322        for line in new_string.lines() {
323            output.push_str(&format!("+ {}\n", line));
324        }
325        output.push_str("```\n");
326        output
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use std::fs;
334    use tempfile::TempDir;
335
336    // ===== Metadata Tests =====
337
338    #[test]
339    fn test_tool_metadata() {
340        let tool: EditBlockTool = Default::default();
341        assert_eq!(tool.name(), "edit_block");
342        assert!(!tool.description().is_empty());
343
344        let tool2 = EditBlockTool::new();
345        assert_eq!(tool2.name(), "edit_block");
346    }
347
348    #[test]
349    fn test_format_methods() {
350        let tool = EditBlockTool::new();
351        let params =
352            serde_json::json!({"file_path": "test.txt", "old_string": "old", "new_string": "new"});
353
354        assert!(!tool.format_input_plain(&params).is_empty());
355        assert!(!tool.format_input_ansi(&params).is_empty());
356        assert!(!tool.format_input_markdown(&params).is_empty());
357
358        let result = ToolResult::from("Edited file");
359        assert!(!tool.format_output_plain(&result).is_empty());
360        assert!(!tool.format_output_ansi(&result).is_empty());
361        assert!(!tool.format_output_markdown(&result).is_empty());
362    }
363
364    #[test]
365    fn test_default_values() {
366        // Deserialize without optional fields to trigger defaults
367        let input: EditBlockInput = serde_json::from_value(serde_json::json!({
368            "file_path": "test.txt",
369            "old_string": "old",
370            "new_string": "new"
371        }))
372        .unwrap();
373
374        assert_eq!(input.expected_replacements, 1);
375        assert!(input.enable_fuzzy);
376        assert!((input.fuzzy_threshold - 0.7).abs() < 0.001);
377    }
378
379    #[tokio::test]
380    async fn test_edit_block_exact() {
381        let temp_dir = TempDir::new().unwrap();
382        let file_path = temp_dir.path().join("test.txt");
383        fs::write(&file_path, "Hello, World!\nThis is a test.").unwrap();
384
385        let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
386        let input = EditBlockInput {
387            file_path: PathBuf::from("test.txt"),
388            old_string: "World".to_string(),
389            new_string: "Rust".to_string(),
390            expected_replacements: 1,
391            enable_fuzzy: false,
392            fuzzy_threshold: 0.7,
393        };
394
395        let result = tool.execute(input).await.unwrap();
396        assert!(result.as_text().contains("exact matching"));
397
398        let content = fs::read_to_string(&file_path).unwrap();
399        assert_eq!(content, "Hello, Rust!\nThis is a test.");
400    }
401
402    #[tokio::test]
403    async fn test_edit_block_fuzzy() {
404        let temp_dir = TempDir::new().unwrap();
405        let file_path = temp_dir.path().join("test.txt");
406        fs::write(&file_path, "Hello, World!\nThis is a test.").unwrap();
407
408        let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
409        let input = EditBlockInput {
410            file_path: PathBuf::from("test.txt"),
411            old_string: "Wrld".to_string(), // Typo - should match "World" via fuzzy
412            new_string: "Rust".to_string(),
413            expected_replacements: 1,
414            enable_fuzzy: true,
415            fuzzy_threshold: 0.7,
416        };
417
418        let result = tool.execute(input).await.unwrap();
419        assert!(result.as_text().contains("fuzzy"));
420    }
421
422    #[tokio::test]
423    async fn test_edit_block_preserves_line_endings() {
424        let temp_dir = TempDir::new().unwrap();
425        let file_path = temp_dir.path().join("test.txt");
426        fs::write(&file_path, "Line1\r\nLine2\r\n").unwrap();
427
428        let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
429        let input = EditBlockInput {
430            file_path: PathBuf::from("test.txt"),
431            old_string: "Line1".to_string(),
432            new_string: "First".to_string(),
433            expected_replacements: 1,
434            enable_fuzzy: false,
435            fuzzy_threshold: 0.7,
436        };
437
438        tool.execute(input).await.unwrap();
439
440        let content = fs::read_to_string(&file_path).unwrap();
441        assert!(content.contains("\r\n"));
442    }
443
444    // ===== Comprehensive Line Ending Tests =====
445
446    #[tokio::test]
447    async fn test_edit_block_lf_only() {
448        let temp_dir = TempDir::new().unwrap();
449        let file_path = temp_dir.path().join("lf.txt");
450
451        let original = "Line 1\nLine 2\nLine 3\n";
452        fs::write(&file_path, original).unwrap();
453
454        let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
455        let input = EditBlockInput {
456            file_path: PathBuf::from("lf.txt"),
457            old_string: "Line 2".to_string(),
458            new_string: "Modified Line 2".to_string(),
459            expected_replacements: 1,
460            enable_fuzzy: false,
461            fuzzy_threshold: 0.7,
462        };
463
464        tool.execute(input).await.unwrap();
465
466        let bytes = fs::read(&file_path).unwrap();
467        let content = String::from_utf8(bytes).unwrap();
468        assert!(content.contains("Modified Line 2"));
469        assert!(content.contains("\n"));
470        assert!(!content.contains("\r\n"));
471    }
472
473    #[tokio::test]
474    async fn test_edit_block_crlf_only() {
475        let temp_dir = TempDir::new().unwrap();
476        let file_path = temp_dir.path().join("crlf.txt");
477
478        let original = "Line 1\r\nLine 2\r\nLine 3\r\n";
479        fs::write(&file_path, original).unwrap();
480
481        let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
482        let input = EditBlockInput {
483            file_path: PathBuf::from("crlf.txt"),
484            old_string: "Line 2".to_string(),
485            new_string: "Modified Line 2".to_string(),
486            expected_replacements: 1,
487            enable_fuzzy: false,
488            fuzzy_threshold: 0.7,
489        };
490
491        tool.execute(input).await.unwrap();
492
493        let bytes = fs::read(&file_path).unwrap();
494        let content = String::from_utf8(bytes).unwrap();
495        assert!(content.contains("Modified Line 2"));
496        assert!(content.contains("\r\n"));
497        // Count CRLF sequences
498        let crlf_count = content.matches("\r\n").count();
499        assert!(crlf_count >= 2); // Should still have CRLF endings
500    }
501
502    #[tokio::test]
503    async fn test_edit_block_mixed_line_endings() {
504        let temp_dir = TempDir::new().unwrap();
505        let file_path = temp_dir.path().join("mixed.txt");
506
507        let original = "Line 1\nLine 2\r\nLine 3\rLine 4";
508        fs::write(&file_path, original).unwrap();
509
510        let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
511        let input = EditBlockInput {
512            file_path: PathBuf::from("mixed.txt"),
513            old_string: "Line 2".to_string(),
514            new_string: "Modified Line 2".to_string(),
515            expected_replacements: 1,
516            enable_fuzzy: false,
517            fuzzy_threshold: 0.7,
518        };
519
520        tool.execute(input).await.unwrap();
521
522        let bytes = fs::read(&file_path).unwrap();
523        let content = String::from_utf8(bytes).unwrap();
524        assert!(content.contains("Modified Line 2"));
525        // Mixed endings should be preserved
526        assert!(content.contains("\n") || content.contains("\r"));
527    }
528
529    #[tokio::test]
530    async fn test_edit_block_empty_file() {
531        let temp_dir = TempDir::new().unwrap();
532        let file_path = temp_dir.path().join("empty.txt");
533        fs::write(&file_path, "").unwrap();
534
535        let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
536        let input = EditBlockInput {
537            file_path: PathBuf::from("empty.txt"),
538            old_string: "nonexistent".to_string(),
539            new_string: "something".to_string(),
540            expected_replacements: 1,
541            enable_fuzzy: false,
542            fuzzy_threshold: 0.7,
543        };
544
545        let result = tool.execute(input).await;
546        // Should fail gracefully on empty file
547        assert!(result.is_err() || result.unwrap().as_text().contains("not found"));
548    }
549
550    #[tokio::test]
551    async fn test_edit_block_utf8_content() {
552        let temp_dir = TempDir::new().unwrap();
553        let file_path = temp_dir.path().join("utf8.txt");
554
555        let original = "Hello 世界\nÜmläüts äöü\n🎵 Music\n";
556        fs::write(&file_path, original).unwrap();
557
558        let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
559        let input = EditBlockInput {
560            file_path: PathBuf::from("utf8.txt"),
561            old_string: "Ümläüts äöü".to_string(),
562            new_string: "Modified äöü".to_string(),
563            expected_replacements: 1,
564            enable_fuzzy: false,
565            fuzzy_threshold: 0.7,
566        };
567
568        tool.execute(input).await.unwrap();
569
570        let content = fs::read_to_string(&file_path).unwrap();
571        assert!(content.contains("Modified äöü"));
572        assert!(content.contains("世界"));
573        assert!(content.contains("🎵"));
574    }
575
576    #[tokio::test]
577    async fn test_edit_block_crlf_replacement_with_crlf_in_new_string() {
578        // BUG TEST: When the original file has CRLF endings, and the new_string
579        // also contains CRLF, the CRLF preservation logic should NOT double the CR.
580        let temp_dir = TempDir::new().unwrap();
581        let file_path = temp_dir.path().join("crlf_replace.txt");
582
583        // Original file with CRLF line endings
584        let original = "Line 1\r\nLine 2\r\nLine 3\r\n";
585        fs::write(&file_path, original).unwrap();
586
587        let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
588        let input = EditBlockInput {
589            file_path: PathBuf::from("crlf_replace.txt"),
590            old_string: "Line 2".to_string(),
591            // new_string explicitly contains CRLF - this should be preserved as-is
592            new_string: "New Line 2\r\nExtra Line".to_string(),
593            expected_replacements: 1,
594            enable_fuzzy: false,
595            fuzzy_threshold: 0.7,
596        };
597
598        tool.execute(input).await.unwrap();
599
600        let bytes = fs::read(&file_path).unwrap();
601        let content = String::from_utf8(bytes).unwrap();
602
603        // The content should have proper CRLF, not doubled \r\r\n
604        assert!(
605            !content.contains("\r\r\n"),
606            "Bug: CRLF was doubled to \\r\\r\\n! Content bytes: {:?}",
607            content.as_bytes()
608        );
609
610        // Verify the replacement happened correctly
611        assert!(content.contains("New Line 2\r\nExtra Line"));
612    }
613
614    #[tokio::test]
615    async fn test_edit_block_multiple_occurrences() {
616        let temp_dir = TempDir::new().unwrap();
617        let file_path = temp_dir.path().join("multi.txt");
618
619        let original = "Item A\nItem A\nItem B\nItem A\n";
620        fs::write(&file_path, original).unwrap();
621
622        let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
623
624        // Replace all occurrences (3 total)
625        let input = EditBlockInput {
626            file_path: PathBuf::from("multi.txt"),
627            old_string: "Item A".to_string(),
628            new_string: "Item X".to_string(),
629            expected_replacements: 3, // All 3 occurrences
630            enable_fuzzy: false,
631            fuzzy_threshold: 0.7,
632        };
633
634        tool.execute(input).await.unwrap();
635
636        let content = fs::read_to_string(&file_path).unwrap();
637        // Should have replaced all occurrences
638        let x_count = content.matches("Item X").count();
639        let a_count = content.matches("Item A").count();
640        assert_eq!(x_count, 3);
641        assert_eq!(a_count, 0);
642    }
643
644    // ===== find_fuzzy_match Unit Tests =====
645
646    #[test]
647    fn test_fuzzy_match_empty_pattern() {
648        let result = EditBlockTool::find_fuzzy_match("some text", "", 0.5);
649        assert!(result.is_none(), "Empty pattern should return None");
650    }
651
652    #[test]
653    fn test_fuzzy_match_pattern_longer_than_text() {
654        let result =
655            EditBlockTool::find_fuzzy_match("short", "this pattern is much longer than text", 0.5);
656        assert!(
657            result.is_none(),
658            "Pattern longer than text should return None"
659        );
660    }
661
662    #[test]
663    fn test_fuzzy_match_exact_match() {
664        let result = EditBlockTool::find_fuzzy_match("hello world", "world", 0.5);
665        assert!(result.is_some());
666        let m = result.unwrap();
667        assert_eq!(m.matched_text, "world");
668        assert!(
669            (m.similarity - 1.0).abs() < 0.001,
670            "Exact match should have similarity 1.0"
671        );
672    }
673
674    #[test]
675    fn test_fuzzy_match_finds_similar() {
676        // "wrld" is similar to "world"
677        let result = EditBlockTool::find_fuzzy_match("hello world goodbye", "wrld", 0.5);
678        assert!(result.is_some());
679        let m = result.unwrap();
680        assert!(m.similarity > 0.5);
681    }
682
683    #[test]
684    fn test_fuzzy_match_below_threshold() {
685        // Very high threshold, nothing should match
686        let result = EditBlockTool::find_fuzzy_match("hello world", "xyz", 0.99);
687        assert!(result.is_none(), "Nothing should match with high threshold");
688    }
689
690    #[test]
691    fn test_fuzzy_match_variable_window_skip_large() {
692        // Trigger: window_size > text.len() causes continue
693        // Pattern of 10 chars on 10 char text: +10% = 11 > 10, should skip that window
694        let result = EditBlockTool::find_fuzzy_match("abcdefghij", "abcdefghij", 0.5);
695        assert!(result.is_some()); // Should still find match via exact window
696    }
697
698    #[test]
699    fn test_fuzzy_match_smaller_window() {
700        // Test -10% window size finding a match
701        // Pattern "ABCDEFGHIJ" (10 chars), -10% window = 9 chars
702        // Text has "ABCDEFGHI" (9 chars) which the smaller window will evaluate
703        let result = EditBlockTool::find_fuzzy_match("xxxABCDEFGHIxxx", "ABCDEFGHIJ", 0.5);
704        assert!(result.is_some());
705        // The variable window logic is exercised
706    }
707
708    #[test]
709    fn test_fuzzy_match_continue_branch() {
710        // Trigger the continue branch: window_size > text.len()
711        // Pattern 100 chars, +10% = 110 chars, but text is only 105 chars
712        let long_pattern = "a".repeat(100);
713        let text = "a".repeat(105); // Match exists but +10% window can't be used
714
715        let result = EditBlockTool::find_fuzzy_match(&text, &long_pattern, 0.5);
716        // This exercises the continue branch for +10% window (110 > 105)
717        assert!(result.is_some()); // Still finds match via exact or -10% window
718    }
719}