1use crate::text_search;
23use crate::types::VerifyResult;
24
25const FALLBACK_THRESHOLD: f64 = 0.6;
27
28pub struct Verifier;
29
30impl Verifier {
31 pub fn verify(original: &str, compressed: &str) -> VerifyResult {
37 let mut passed = Vec::new();
38 let mut failed = Vec::new();
39
40 let retention = if original.is_empty() {
42 1.0
43 } else {
44 compressed.len() as f64 / original.len() as f64
45 };
46 if retention >= 0.10 {
47 passed.push("min_retention".to_string());
48 } else {
49 failed.push((
50 "min_retention".to_string(),
51 format!("output is only {:.1}% of input length", retention * 100.0),
52 ));
53 }
54
55 let error_lines: Vec<&str> = original
57 .lines()
58 .filter(|l| {
59 let lower = l.to_lowercase();
60 lower.contains("error:") || lower.contains("warning:") || lower.contains("fatal:")
61 || lower.contains("panic:") || lower.contains("exception:")
62 })
63 .collect();
64 if error_lines.is_empty() {
65 passed.push("error_lines".to_string());
66 } else {
67 let trimmed: Vec<&str> = error_lines.iter().map(|l| l.trim()).collect();
68 let missing = text_search::present(compressed, &trimmed)
69 .iter()
70 .filter(|&&p| !p)
71 .count();
72 if missing == 0 {
73 passed.push("error_lines".to_string());
74 } else {
75 failed.push((
76 "error_lines".to_string(),
77 format!("{missing} error/warning line(s) missing from output"),
78 ));
79 }
80 }
81
82 let path_lines: Vec<&str> = original
84 .lines()
85 .filter(|l| {
86 (l.contains('/') || l.contains('\\'))
87 && l.chars().any(|c| c == '.')
88 && l.len() < 200 })
90 .take(20) .collect();
92 if path_lines.is_empty() {
93 passed.push("file_paths".to_string());
94 } else {
95 let missing_paths = path_lines
96 .iter()
97 .filter(|&&line| {
98 let token = line.split_whitespace()
100 .find(|t| t.contains('/') || t.contains('\\'))
101 .unwrap_or("");
102 !token.is_empty() && !compressed.contains(token)
103 })
104 .count();
105 if missing_paths == 0 {
106 passed.push("file_paths".to_string());
107 } else {
108 failed.push((
109 "file_paths".to_string(),
110 format!("{missing_paths} file path(s) missing from output"),
111 ));
112 }
113 }
114
115 let orig_trimmed = original.trim();
119 if orig_trimmed.starts_with('{') || orig_trimmed.starts_with('[') {
120 if let Ok(orig_val) = serde_json::from_str::<serde_json::Value>(orig_trimmed) {
121 let orig_keys = collect_top_level_keys(&orig_val);
122 if orig_keys.is_empty() {
123 passed.push("json_keys".to_string());
124 } else {
125 let present: usize = orig_keys
126 .iter()
127 .filter(|&&k| compressed.contains(k))
128 .count();
129 let retention_ratio = present as f64 / orig_keys.len() as f64;
130 if retention_ratio >= 0.5 {
132 passed.push("json_keys".to_string());
133 } else {
134 let missing: Vec<&str> = orig_keys
135 .iter()
136 .filter(|&&k| !compressed.contains(k))
137 .copied()
138 .collect();
139 failed.push((
140 "json_keys".to_string(),
141 format!("only {:.0}% of JSON keys retained; missing: {:?}",
142 retention_ratio * 100.0,
143 &missing[..missing.len().min(5)]),
144 ));
145 }
146 }
147 } else {
148 passed.push("json_keys".to_string()); }
150 } else {
151 passed.push("json_keys".to_string()); }
153
154 let hunk_headers: Vec<&str> = original
156 .lines()
157 .filter(|l| l.starts_with("@@"))
158 .collect();
159 if hunk_headers.is_empty() {
160 passed.push("diff_hunks".to_string());
161 } else {
162 let missing_hunks = text_search::present(compressed, &hunk_headers)
163 .iter()
164 .filter(|&&p| !p)
165 .count();
166 if missing_hunks == 0 {
167 passed.push("diff_hunks".to_string());
168 } else {
169 failed.push((
170 "diff_hunks".to_string(),
171 format!("{missing_hunks} diff hunk header(s) missing"),
172 ));
173 }
174 }
175
176 let numbers: Vec<&str> = original
178 .split(|c: char| !c.is_ascii_digit() && c != '.' && c != '-')
179 .filter(|s| !s.is_empty() && s.len() >= 2 && s.parse::<f64>().is_ok())
180 .take(10)
181 .collect();
182 if numbers.is_empty() {
183 passed.push("numeric_values".to_string());
184 } else {
185 let missing_nums = numbers
186 .iter()
187 .filter(|&&n| !compressed.contains(n))
188 .count();
189 if missing_nums == 0 {
190 passed.push("numeric_values".to_string());
191 } else {
192 failed.push((
193 "numeric_values".to_string(),
194 format!("{missing_nums} numeric value(s) missing from output"),
195 ));
196 }
197 }
198
199 let preservation_tokens = extract_preservation_tokens(original);
211 if preservation_tokens.is_empty() {
212 passed.push("preservation".to_string());
213 } else {
214 let tokens: Vec<&str> = preservation_tokens.iter().map(String::as_str).collect();
215 let hits = text_search::present(compressed, &tokens);
216 let present = hits.iter().filter(|&&p| p).count();
217 let total = preservation_tokens.len();
218 let ratio = present as f64 / total as f64;
219 if ratio >= 0.85 {
220 passed.push("preservation".to_string());
221 } else {
222 let missing: Vec<&str> = tokens
223 .iter()
224 .zip(&hits)
225 .filter(|(_, &p)| !p)
226 .take(5)
227 .map(|(&t, _)| t)
228 .collect();
229 failed.push((
230 "preservation".to_string(),
231 format!(
232 "only {}/{} preservation tokens retained ({:.0}%); missing: {:?}",
233 present, total, ratio * 100.0, missing,
234 ),
235 ));
236 }
237 }
238
239 let total = passed.len() + failed.len();
249 let mut confidence = if total == 0 {
250 1.0
251 } else {
252 passed.len() as f64 / total as f64
253 };
254 let preservation_failed = failed.iter().any(|(k, _)| k == "preservation");
255 if preservation_failed {
256 confidence = confidence.min(0.5);
257 }
258
259 let fallback_triggered = confidence < FALLBACK_THRESHOLD;
260
261 VerifyResult {
262 passed: failed.is_empty(),
263 confidence,
264 checks_passed: passed,
265 checks_failed: failed,
266 fallback_triggered,
267 }
268 }
269
270 pub fn should_fallback(result: &VerifyResult) -> bool {
272 result.fallback_triggered
273 }
274}
275
276fn collect_top_level_keys(value: &serde_json::Value) -> Vec<&str> {
277 match value {
278 serde_json::Value::Object(map) => map.keys().map(|k| k.as_str()).collect(),
279 _ => vec![],
280 }
281}
282
283const MAX_SCAN_BYTES: usize = 1024 * 1024;
303const MAX_TOKENS: usize = 500;
304
305fn extract_preservation_tokens(input: &str) -> Vec<String> {
308 let scan = crate::text_boundary::truncate_str(input, MAX_SCAN_BYTES);
309 let bytes = scan.as_bytes();
310 let mut tokens: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
311
312 let mut i = 0;
313 while i < bytes.len() && tokens.len() < MAX_TOKENS {
314 let b = bytes[i];
315
316 if b == b'`' {
318 if let Some(end) = find_closing(bytes, i + 1, b'`') {
319 let slice = &scan[i + 1..end];
320 if !slice.is_empty()
323 && slice.len() <= 200
324 && is_identifier_or_path_content(slice)
325 {
326 tokens.insert(slice.to_string());
327 }
328 i = end + 1;
329 continue;
330 }
331 }
332
333 if b == b'$' && i + 1 < bytes.len() {
335 let next = bytes[i + 1];
336 if next == b'{' {
337 if let Some(end) = find_closing(bytes, i + 2, b'}') {
338 let slice = &scan[i + 2..end];
339 if is_env_name(slice) {
340 tokens.insert(format!("${{{}}}", slice));
341 }
342 i = end + 1;
343 continue;
344 }
345 } else if next.is_ascii_uppercase() || next == b'_' {
346 let start = i + 1;
348 let mut j = start;
349 while j < bytes.len()
350 && (bytes[j].is_ascii_uppercase()
351 || bytes[j] == b'_'
352 || bytes[j].is_ascii_digit())
353 {
354 j += 1;
355 }
356 if j > start {
357 tokens.insert(format!("${}", &scan[start..j]));
358 i = j;
359 continue;
360 }
361 }
362 }
363
364 if is_url_start(bytes, i) {
366 let end = scan_url_end(bytes, i);
367 if end > i + 8 {
368 tokens.insert(scan[i..end].to_string());
370 i = end;
371 continue;
372 }
373 }
374
375 if is_path_start(bytes, i) {
379 let end = scan_path_end(bytes, i);
380 if end > i {
381 let slice = &scan[i..end];
382 if slice.contains('/') && is_plausible_path(slice) {
384 tokens.insert(slice.to_string());
385 i = end;
386 continue;
387 }
388 }
389 }
390
391 if b.is_ascii_digit() || (b == b'v' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()) {
393 let end = scan_version_end(bytes, i);
394 if end > i {
395 let slice = &scan[i..end];
396 if is_version(slice) {
397 tokens.insert(slice.to_string());
398 i = end;
399 continue;
400 }
401 }
402 }
403
404 i += 1;
405 }
406
407 tokens.into_iter().collect()
408}
409
410fn find_closing(bytes: &[u8], start: usize, target: u8) -> Option<usize> {
411 let end = (start + 256).min(bytes.len());
414 bytes[start..end].iter().position(|&b| b == target).map(|off| start + off)
415}
416
417fn is_identifier_or_path_content(s: &str) -> bool {
418 let space_count = s.bytes().filter(|&b| b == b' ').count();
423 if space_count > 3 {
424 return false;
425 }
426 s.bytes().any(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'/' || b == b':')
428}
429
430fn is_env_name(s: &str) -> bool {
431 !s.is_empty()
432 && s.len() <= 64
433 && s.bytes()
434 .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
435}
436
437fn is_url_start(bytes: &[u8], i: usize) -> bool {
438 const PREFIXES: &[&[u8]] = &[
439 b"https://", b"http://", b"git://", b"ssh://", b"ftp://",
440 b"file://", b"git@", b"ws://", b"wss://",
441 ];
442 PREFIXES.iter().any(|p| bytes[i..].starts_with(p))
443}
444
445fn scan_url_end(bytes: &[u8], start: usize) -> usize {
446 let cap = (start + 2048).min(bytes.len());
449 let mut i = start;
450 while i < cap {
451 let b = bytes[i];
452 if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'
453 || b == b'"' || b == b'\'' || b == b'<' || b == b'>'
454 || b == b'`'
455 {
456 break;
457 }
458 i += 1;
459 }
460 while i > start {
462 let last = bytes[i - 1];
463 if last == b'.' || last == b',' || last == b';' || last == b':'
464 || last == b')' || last == b']' || last == b'!' || last == b'?'
465 {
466 i -= 1;
467 } else {
468 break;
469 }
470 }
471 i
472}
473
474fn is_path_start(bytes: &[u8], i: usize) -> bool {
475 if i >= bytes.len() {
476 return false;
477 }
478 if i > 0 && bytes[i - 1].is_ascii_alphanumeric() {
480 return false;
481 }
482 let b = bytes[i];
483 b == b'/' || b == b'.' || b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
484}
485
486fn scan_path_end(bytes: &[u8], start: usize) -> usize {
487 let cap = (start + 512).min(bytes.len());
490 let mut i = start;
491 while i < cap {
492 let b = bytes[i];
493 if b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/' {
494 i += 1;
495 } else {
496 break;
497 }
498 }
499 while i > start && bytes[i - 1] == b'.' {
501 i -= 1;
502 }
503 i
504}
505
506fn is_plausible_path(s: &str) -> bool {
507 let bytes = s.as_bytes();
508 let slash = match bytes.iter().position(|&b| b == b'/') {
509 Some(p) => p,
510 None => return false,
511 };
512 let has_before = slash > 0;
516 let has_after = slash + 1 < bytes.len();
517 if !has_before && !has_after {
518 return false;
519 }
520 bytes.iter().any(|b| b.is_ascii_alphabetic())
521}
522
523fn scan_version_end(bytes: &[u8], start: usize) -> usize {
524 let cap = (start + 64).min(bytes.len());
525 let mut i = start;
526 while i < cap {
527 let b = bytes[i];
528 if b.is_ascii_alphanumeric() || b == b'.' || b == b'-' {
529 i += 1;
530 } else {
531 break;
532 }
533 }
534 i
535}
536
537fn is_version(s: &str) -> bool {
538 let trimmed = s.strip_prefix('v').unwrap_or(s);
541 let dots = trimmed.bytes().filter(|&b| b == b'.').count();
542 if dots < 2 {
543 return false;
544 }
545 let first_segment: String = trimmed.chars().take_while(|c| c.is_ascii_digit()).collect();
547 if first_segment.is_empty() {
548 return false;
549 }
550 let after_first: &str = &trimmed[first_segment.len() + 1..];
552 after_first.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)
553}
554
555#[cfg(test)]
560mod tests {
561 use super::*;
562
563 #[test]
564 fn verify_identical_passes_all() {
565 let text = "error: something went wrong\nfile: src/main.rs\n";
566 let result = Verifier::verify(text, text);
567 assert!(result.passed);
568 assert!((result.confidence - 1.0).abs() < f64::EPSILON);
569 assert!(!result.fallback_triggered);
570 }
571
572 #[test]
573 fn verify_empty_input_passes() {
574 let result = Verifier::verify("", "");
575 assert!(result.passed);
576 }
577
578 #[test]
579 fn verify_detects_missing_error_line() {
580 let original = "error: connection refused\nsome other content here\n";
581 let compressed = "some other content here\n"; let result = Verifier::verify(original, compressed);
583 assert!(!result.passed);
584 assert!(result.checks_failed.iter().any(|(k, _)| k == "error_lines"));
585 }
586
587 #[test]
588 fn verify_detects_over_compression() {
589 let original = "error: critical failure at line 42\n@@ -1,5 +1,5 @@\n/path/to/file.rs\nvalue: 12345\n".repeat(20);
591 let compressed = "x"; let result = Verifier::verify(&original, compressed);
593 assert!(!result.passed);
594 assert!(result.checks_failed.iter().any(|(k, _)| k == "min_retention"));
595 assert!(result.fallback_triggered, "should trigger fallback: confidence={:.2}", result.confidence);
596 }
597
598 #[test]
599 fn verify_json_keys_preserved() {
600 let original = r#"{"id":1,"name":"Alice","status":"active"}"#;
601 let compressed = r#"TOON:{id:1,name:"Alice",status:"active"}"#;
602 let result = Verifier::verify(original, compressed);
603 assert!(result.checks_passed.contains(&"json_keys".to_string()));
604 }
605
606 #[test]
607 fn verify_detects_missing_json_keys() {
608 let original = r#"{"id":1,"name":"Alice","status":"active","role":"admin","email":"a@b.com","created":"2024-01-01"}"#;
609 let compressed = r#"TOON:{id:1}"#; let result = Verifier::verify(original, compressed);
611 assert!(result.checks_failed.iter().any(|(k, _)| k == "json_keys"),
612 "should fail json_keys when <50% of keys retained");
613 }
614
615 #[test]
616 fn verify_diff_hunks_preserved() {
617 let original = "@@ -1,5 +1,5 @@\n-old\n+new\n context\n";
618 let compressed = "@@ -1,5 +1,5 @@\n-old\n+new\n";
619 let result = Verifier::verify(original, compressed);
620 assert!(result.checks_passed.contains(&"diff_hunks".to_string()));
621 }
622
623 #[test]
624 fn verify_detects_missing_diff_hunks() {
625 let original = "@@ -1,5 +1,5 @@\n-old\n+new\n";
626 let compressed = "-old\n+new\n"; let result = Verifier::verify(original, compressed);
628 assert!(result.checks_failed.iter().any(|(k, _)| k == "diff_hunks"));
629 }
630
631 #[test]
632 fn fallback_threshold_triggers_correctly() {
633 let original = "error: critical failure\n@@ -1,5 +1,5 @@\n/path/to/file.rs:42\n";
635 let compressed = "x"; let result = Verifier::verify(original, compressed);
637 assert!(result.fallback_triggered, "should trigger fallback on low confidence");
638 }
639
640 #[test]
643 fn verify_cargo_test_output_preserved() {
644 let original = "running 47 tests\ntest engine::tests::test_compress ... ok\ntest pipeline::tests::compress_json ... ok\ntest result: ok. 47 passed; 0 failed; 0 ignored; finished in 2.34s\n";
645 let compressed = "47 tests\ntest result: ok. 47 passed; 0 failed; finished in 2.34s\n";
646 let result = Verifier::verify(original, compressed);
647 assert!(result.confidence >= 0.7, "cargo test output should verify well: {:.2}", result.confidence);
649 }
650
651 #[test]
652 fn verify_rust_compile_error_preserved() {
653 let original = "error[E0308]: mismatched types\n --> src/main.rs:42:5\n |\n42 | let x: i32 = \"hello\";\n | ^^^^^^^ expected `i32`, found `&str`\n\nerror: aborting due to previous error\n";
654 let compressed = "error[E0308]: mismatched types\n --> src/main.rs:42:5\nerror: aborting due to previous error\n";
655 let result = Verifier::verify(original, compressed);
656 assert!(result.checks_passed.contains(&"error_lines".to_string()),
658 "error lines should be preserved");
659 }
660
661 #[test]
662 fn verify_git_log_output() {
663 let original = "commit a1b2c3d4\nAuthor: Ojus Chugh <ojuschugh@gmail.com>\nDate: Sun Apr 12 10:00:00 2026\n\n feat: Add compression engine\n\ncommit b2c3d4e5\nAuthor: Ojus Chugh <ojuschugh@gmail.com>\nDate: Sat Apr 11 15:30:00 2026\n\n fix: Handle edge case\n";
664 let compressed = "commit a1b2c3d4\n feat: Add compression engine\ncommit b2c3d4e5\n fix: Handle edge case\n";
665 let result = Verifier::verify(original, compressed);
666 assert!(result.confidence >= 0.7, "git log should verify well: {:.2}", result.confidence);
667 }
668
669 #[test]
670 fn verify_json_api_with_stripped_nulls() {
671 let original = r#"{"id":1,"name":"Alice","debug_info":null,"trace_id":null,"status":"active"}"#;
673 let compressed = r#"TOON:{id:1,name:"Alice",status:"active"}"#;
674 let result = Verifier::verify(original, compressed);
675 assert!(result.checks_passed.contains(&"json_keys".to_string()),
677 "60% key retention should pass: {:?}", result.checks_failed);
678 }
679
680 #[test]
683 fn extract_detects_absolute_paths() {
684 let tokens = extract_preservation_tokens("see /etc/myapp/config.yml for details");
685 assert!(tokens.contains(&"/etc/myapp/config.yml".to_string()),
686 "absolute path should be extracted: {:?}", tokens);
687 }
688
689 #[test]
690 fn extract_detects_relative_paths() {
691 let tokens = extract_preservation_tokens("edit src/main.rs and tests/util.rs");
692 assert!(tokens.contains(&"src/main.rs".to_string()), "{:?}", tokens);
693 assert!(tokens.contains(&"tests/util.rs".to_string()), "{:?}", tokens);
694 }
695
696 #[test]
697 fn extract_detects_directory_listing_entries() {
698 let input = "drwxr-xr-x user staff 192 Apr 18 packages/\n\
700 drwxr-xr-x user staff 96 Apr 18 configuration/\n";
701 let tokens = extract_preservation_tokens(input);
702 assert!(tokens.iter().any(|t| t.contains("packages")),
704 "should extract packages: {:?}", tokens);
705 assert!(tokens.iter().any(|t| t.contains("configuration")),
706 "should extract configuration: {:?}", tokens);
707 }
708
709 #[test]
710 fn extract_detects_urls() {
711 let input = "clone from https://github.com/example/repository and \
712 read https://docs.example.com/guide.";
713 let tokens = extract_preservation_tokens(input);
714 assert!(tokens.contains(&"https://github.com/example/repository".to_string()),
715 "{:?}", tokens);
716 assert!(tokens.iter().any(|t| t.starts_with("https://docs.example.com")),
717 "{:?}", tokens);
718 }
719
720 #[test]
721 fn extract_detects_backtick_identifiers() {
722 let tokens = extract_preservation_tokens(
723 "use `SqzEngine::new` and `CompressionPipeline::compress`"
724 );
725 assert!(tokens.contains(&"SqzEngine::new".to_string()), "{:?}", tokens);
726 assert!(tokens.contains(&"CompressionPipeline::compress".to_string()), "{:?}", tokens);
727 }
728
729 #[test]
730 fn extract_detects_env_vars() {
731 let tokens = extract_preservation_tokens("set $HOME and ${FOO_BAR} and $PATH");
732 assert!(tokens.contains(&"$HOME".to_string()), "{:?}", tokens);
733 assert!(tokens.contains(&"${FOO_BAR}".to_string()), "{:?}", tokens);
734 assert!(tokens.contains(&"$PATH".to_string()), "{:?}", tokens);
735 }
736
737 #[test]
738 fn extract_detects_version_numbers() {
739 let tokens = extract_preservation_tokens(
740 "upgrade to 1.2.3 from v0.7.0 and pin 2.0.0-beta.1"
741 );
742 assert!(tokens.iter().any(|t| t.starts_with("1.2.3")), "{:?}", tokens);
743 assert!(tokens.iter().any(|t| t.starts_with("v0.7.0")), "{:?}", tokens);
744 }
745
746 #[test]
747 fn extract_ignores_prose() {
748 let tokens = extract_preservation_tokens(
750 "The quick brown fox jumps over the lazy dog. Lorem ipsum dolor sit amet."
751 );
752 assert!(tokens.is_empty(), "prose should yield no preservation tokens: {:?}", tokens);
753 }
754
755 #[test]
756 fn extract_ignores_fractions_in_prose() {
757 let tokens = extract_preservation_tokens("We completed 3/4 of the tasks");
759 assert!(tokens.iter().all(|t| !t.contains("3/4")),
760 "fraction should not be extracted as path: {:?}", tokens);
761 }
762
763 #[test]
764 fn extract_scan_cap_lands_on_a_char_boundary() {
765 let input = format!("{}{}", "x".repeat(MAX_SCAN_BYTES - 1), "日".repeat(50));
766 assert!(Verifier::verify(&input, &input).passed);
767 assert!(extract_preservation_tokens(&input).is_empty());
768 }
769
770 #[test]
771 fn extract_caps_at_max_tokens() {
772 let mut input = String::new();
774 for i in 0..1000 {
775 input.push_str(&format!("file_{}/sub_{}.txt ", i, i));
776 }
777 let tokens = extract_preservation_tokens(&input);
778 assert!(tokens.len() <= MAX_TOKENS, "should cap at {MAX_TOKENS}, got {}", tokens.len());
779 }
780
781 #[test]
784 fn verify_rejects_packages_to_pkgs_rewrite() {
785 let original = "drwxr-xr-x user staff 192 Apr 18 packages/\n\
787 drwxr-xr-x user staff 128 Apr 18 documentation/\n";
788 let compressed = "drwxr-xr-x user staff 192 Apr 18 pkgs/\n\
789 drwxr-xr-x user staff 128 Apr 18 docs/\n";
790 let result = Verifier::verify(original, compressed);
791 assert!(
792 result.checks_failed.iter().any(|(k, _)| k == "preservation"),
793 "should fail preservation when packages→pkgs: {:?}", result.checks_failed
794 );
795 }
796
797 #[test]
798 fn verify_rejects_config_path_rewrite() {
799 let original = "check /etc/myapp/configuration/default.yml for errors";
801 let compressed = "check /etc/myapp/config/default.yml for errors";
802 let result = Verifier::verify(original, compressed);
803 assert!(
804 result.checks_failed.iter().any(|(k, _)| k == "preservation"),
805 "should fail preservation when path segment rewritten: {:?}", result.checks_failed
806 );
807 }
808
809 #[test]
810 fn verify_rejects_github_repo_rewrite() {
811 let original = "origin https://github.com/example/repository (fetch)";
813 let compressed = "origin https://github.com/example/repo (fetch)";
814 let result = Verifier::verify(original, compressed);
815 assert!(
816 result.checks_failed.iter().any(|(k, _)| k == "preservation"),
817 "should fail preservation when URL path rewritten: {:?}", result.checks_failed
818 );
819 }
820
821 #[test]
822 fn verify_rejects_drops_filenames_entirely() {
823 let original = "drwxr-xr-x packages/\n\
825 drwxr-xr-x configuration/\n\
826 drwxr-xr-x documentation/\n\
827 drwxr-xr-x environment/\n";
828 let compressed = "drwxr-xr-x ... [×4, varying: 4 unique values]\n";
829 let result = Verifier::verify(original, compressed);
830 assert!(
831 result.checks_failed.iter().any(|(k, _)| k == "preservation"),
832 "should fail preservation when filenames dropped: {:?}", result.checks_failed
833 );
834 }
835
836 #[test]
837 fn verify_accepts_lossless_dedup_output() {
838 let original = "see /etc/myapp/default.yml and src/main.rs";
840 let compressed = "see /etc/myapp/default.yml and src/main.rs";
841 let result = Verifier::verify(original, compressed);
842 assert!(
843 result.checks_passed.contains(&"preservation".to_string()),
844 "identical content must pass preservation: {:?}", result.checks_failed
845 );
846 }
847
848 #[test]
849 fn verify_accepts_json_null_stripped() {
850 let original = r#"{"path":"/etc/foo.yml","debug":null,"log":"/var/log/app.log"}"#;
852 let compressed = r#"TOON:{path:"/etc/foo.yml",log:"/var/log/app.log"}"#;
853 let result = Verifier::verify(original, compressed);
854 assert!(
855 result.checks_passed.contains(&"preservation".to_string()),
856 "null-stripping must not trip preservation: {:?}", result.checks_failed
857 );
858 }
859
860 #[test]
861 fn verify_accepts_empty_input() {
862 let result = Verifier::verify("", "");
864 assert!(result.checks_passed.contains(&"preservation".to_string()));
865 }
866
867 #[test]
868 fn preservation_failure_triggers_fallback() {
869 let original = "commit: check /etc/myapp/configuration/default.yml\n\
874 file: src/main.rs line 42\n";
875 let compressed = "commit: check /etc/myapp/config/default.yml\n\
876 file: src/main.rs line 42\n";
877 let result = Verifier::verify(original, compressed);
878 assert!(
879 result.checks_failed.iter().any(|(k, _)| k == "preservation"),
880 "preservation should fail"
881 );
882 assert!(
883 result.fallback_triggered,
884 "preservation failure alone must trigger fallback (confidence={:.2})",
885 result.confidence
886 );
887 }
888
889 #[test]
890 fn verify_is_linear_on_error_dense_input() {
891 let input = |bytes: usize| {
892 let mut s = String::new();
893 let mut i = 0;
894 while s.len() < bytes {
895 s.push_str(&format!(
896 "worker {i}: error: request {i} timed out after 30s\n"
897 ));
898 s.push_str(&format!("@@ -{i},7 +{i},8 @@ fn handler_{i}()\n"));
899 i += 1;
900 }
901 s
902 };
903 let quote = |s: &str| s.lines().map(|l| format!("> {l}\n")).collect::<String>();
904 let (small, large) = (input(100 << 10), input(1 << 20));
905 let (small_quoted, large_quoted) = (quote(&small), quote(&large));
906 for compressed in [&large, &large_quoted] {
907 let result = Verifier::verify(&large, compressed);
908 assert!(result.checks_passed.contains(&"error_lines".to_string()));
909 assert!(result.checks_passed.contains(&"diff_hunks".to_string()));
910 }
911 let dropped = large_quoted.replace("request 9999 timed", "request timed");
912 let result = Verifier::verify(&large, &dropped);
913 assert!(result
914 .checks_failed
915 .iter()
916 .any(|(k, m)| k == "error_lines" && m.starts_with("1 ")));
917 let check = |input: &str, quoted: &str| {
918 std::hint::black_box(Verifier::verify(input, input));
919 std::hint::black_box(Verifier::verify(input, quoted));
920 };
921 let ratio = crate::text_search::time_ratio(
922 || check(&small, &small_quoted),
923 || check(&large, &large_quoted),
924 );
925 assert!(ratio < 25.0, "1 MB took {ratio:.1}x as long as 100 KB");
926 }
927}