1use crate::license_detection::automaton::Automaton;
10use regex::Regex;
11use sha1::{Digest, Sha1};
12use std::sync::LazyLock;
13
14use crate::license_detection::index::LicenseIndex;
15use crate::license_detection::index::dictionary::{TokenId, TokenKind};
16use crate::license_detection::models::position_span::PositionSpan;
17use crate::license_detection::models::{LicenseMatch, MatchCoordinates, MatcherKind, RuleId};
18use crate::license_detection::position_set::PositionSet;
19use crate::license_detection::query::Query;
20use crate::license_detection::tokenize::STOPWORDS;
21use crate::models::LineNumber;
22use crate::models::MatchScore;
23
24pub const MATCH_UNKNOWN: MatcherKind = MatcherKind::Unknown;
25
26const UNKNOWN_NGRAM_LENGTH: usize = 6;
29
30const MIN_NGRAM_MATCHES: usize = 3;
34
35const MIN_REGION_LENGTH: usize = 5;
36
37static QUERY_PATTERN: LazyLock<Regex> =
38 LazyLock::new(|| Regex::new(r"[^_\W]+\+?[^_\W]*").expect("Invalid regex pattern"));
39static MATCHED_TEXT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
40 Regex::new(r"(?P<token>[^_\W]+\+?[^_\W]*)|(?P<punct>[_\W\s\+]+[_\W\s]?)")
41 .expect("Invalid matched text regex pattern")
42});
43
44#[derive(Clone)]
45struct MatchedTextToken {
46 value: String,
47 line_num: usize,
48 pos: Option<usize>,
49 is_text: bool,
50 is_known: bool,
51 is_matched: bool,
52}
53
54pub fn unknown_match(
55 index: &LicenseIndex,
56 query: &Query,
57 known_matches: &[LicenseMatch],
58) -> Vec<LicenseMatch> {
59 let mut unknown_matches = Vec::new();
60
61 if query.tokens.is_empty() {
62 return unknown_matches;
63 }
64
65 let query_len = query.tokens.len();
66
67 let covered_positions = compute_covered_positions(query, known_matches);
68
69 let unmatched_regions = find_unmatched_regions(query_len, &covered_positions);
70
71 let automaton = &index.unknown_automaton;
72
73 for region in unmatched_regions {
74 let start = region.0;
75 let end = region.1;
76
77 let region_length = end - start;
78 if region_length < MIN_REGION_LENGTH {
79 continue;
80 }
81
82 let matched_ngrams = get_matched_ngrams(&query.tokens, start, end, automaton);
83
84 if matched_ngrams.len() < MIN_NGRAM_MATCHES {
85 continue;
86 }
87
88 let qspan = compute_qspan_union(&matched_ngrams);
89
90 if qspan.is_empty() {
91 continue;
92 }
93
94 let qspan_length = qspan.len();
95
96 #[cfg(debug_assertions)]
98 {
99 eprintln!("\n=== UNKNOWN MATCH DEBUG ===");
100 eprintln!("Region: {}-{} ({} tokens)", start, end, region_length);
101 eprintln!("matched_ngrams: {} matches", matched_ngrams.len());
102 eprintln!("qspan: {:?}", qspan);
103 eprintln!(
104 "qspan_length: {} (threshold: {})",
105 qspan_length,
106 UNKNOWN_NGRAM_LENGTH * 4
107 );
108 }
109
110 if qspan_length < UNKNOWN_NGRAM_LENGTH * 4 {
114 continue;
115 }
116
117 let hispan = compute_hispan_from_qspan(&query.tokens, &qspan, index);
118
119 #[cfg(debug_assertions)]
120 {
121 eprintln!("hispan: {} (threshold: 5)", hispan);
122 }
123
124 if hispan < 5 {
125 continue;
126 }
127
128 if let Some(match_result) = create_unknown_match_from_qspan(query, &qspan) {
129 unknown_matches.push(match_result);
130 }
131 }
132
133 unknown_matches
134}
135
136fn compute_covered_positions(_query: &Query, known_matches: &[LicenseMatch]) -> PositionSet {
137 let mut covered = PositionSet::new();
138 for m in known_matches {
139 covered.extend_from_span(m.query_span());
140 }
141 covered
142}
143
144fn find_unmatched_regions(
145 query_len: usize,
146 covered_positions: &PositionSet,
147) -> Vec<(usize, usize)> {
148 let mut regions = Vec::new();
149
150 if query_len == 0 {
151 return regions;
152 }
153
154 let mut region_start = None;
155
156 for pos in 0..query_len {
157 if !covered_positions.contains(pos) {
158 if region_start.is_none() {
159 region_start = Some(pos);
160 }
161 } else if let Some(start) = region_start {
162 regions.push((start, pos));
163 region_start = None;
164 }
165 }
166
167 if let Some(start) = region_start {
168 regions.push((start, query_len));
169 }
170
171 regions
172}
173
174fn get_matched_ngrams(
175 tokens: &[TokenId],
176 start: usize,
177 end: usize,
178 automaton: &Automaton,
179) -> Vec<(usize, usize)> {
180 if start >= end || end > tokens.len() {
181 return Vec::new();
182 }
183
184 let region_tokens = &tokens[start..end];
185
186 let region_bytes: Vec<u8> = region_tokens
187 .iter()
188 .flat_map(|tid| tid.to_le_bytes())
189 .collect();
190
191 let offset = UNKNOWN_NGRAM_LENGTH;
192 let mut matches = Vec::new();
193
194 for m in automaton.find_overlapping_iter(®ion_bytes) {
195 let local_qend = m.end / 2;
196 let qend = start + local_qend;
197 let qstart = qend.saturating_sub(offset);
198 matches.push((qstart, qend));
199 }
200
201 matches
202}
203
204fn compute_qspan_union(positions: &[(usize, usize)]) -> PositionSet {
205 if positions.is_empty() {
206 return PositionSet::new();
207 }
208
209 let mut sorted: Vec<_> = positions.to_vec();
210 sorted.sort_by_key(|p| p.0);
211
212 let mut merged: Vec<(usize, usize)> = Vec::new();
213 let mut current = sorted[0];
214
215 for (start, end) in sorted.into_iter().skip(1) {
216 if start <= current.1 {
217 current.1 = current.1.max(end);
218 } else {
219 merged.push(current);
220 current = (start, end);
221 }
222 }
223 merged.push(current);
224
225 let mut result = PositionSet::new();
226 for (start, end) in merged {
227 result.extend_from_span(&PositionSpan::range(start, end));
228 }
229 result
230}
231
232fn compute_hispan_from_qspan(
233 tokens: &[TokenId],
234 qspan: &PositionSet,
235 index: &LicenseIndex,
236) -> usize {
237 qspan
238 .iter()
239 .filter(|&pos| {
240 tokens
241 .get(pos)
242 .is_some_and(|&tid| index.dictionary.token_kind(tid) == TokenKind::Legalese)
243 })
244 .count()
245}
246
247fn create_unknown_match_from_qspan(query: &Query, qspan: &PositionSet) -> Option<LicenseMatch> {
248 if qspan.is_empty() {
249 return None;
250 }
251
252 let match_len = qspan.len();
253
254 let start = qspan.min_pos();
255 let end = qspan.max_pos() + 1;
256
257 let start_line = query
258 .line_by_pos
259 .get(start)
260 .copied()
261 .and_then(LineNumber::new)
262 .unwrap_or(LineNumber::ONE);
263 let end_line = query
264 .line_by_pos
265 .get(end.saturating_sub(1))
266 .copied()
267 .and_then(LineNumber::new)
268 .unwrap_or(start_line);
269
270 let qspan_positions: Vec<usize> = qspan.iter().collect();
271 let synthetic_rule_text =
272 build_unknown_rule_text(query, &qspan_positions, start_line, end_line);
273 let rule_identifier = build_unknown_rule_identifier(&synthetic_rule_text);
274
275 let ngram_count = qspan.len();
276
277 let score = calculate_score(ngram_count, match_len);
278
279 let qspan_span = qspan.to_position_span();
280
281 LicenseMatch {
282 rid: RuleId::NONE,
283 license_expression: "unknown".to_string(),
284 license_expression_spdx: None,
285 from_file: None,
286 start_line,
287 end_line,
288 start_token: start,
289 end_token: end,
290 matcher: MATCH_UNKNOWN,
291 score,
292 matched_length: match_len,
293 rule_length: match_len,
294 match_coverage: 100.0,
295 rule_relevance: 50,
296 rule_identifier,
297 rule_url: String::new(),
298 matched_text: None,
299 referenced_filenames: None,
300 rule_kind: crate::license_detection::models::RuleKind::None,
301 is_from_license: false,
302 rule_start_token: 0,
303 coordinates: MatchCoordinates::query_region(qspan_span),
304 }
305 .into()
306}
307
308fn build_unknown_rule_text(
309 query: &Query,
310 qspan_positions: &[usize],
311 start_line: LineNumber,
312 end_line: LineNumber,
313) -> String {
314 let Some(&start_pos) = qspan_positions.first() else {
315 return String::new();
316 };
317 let Some(&end_pos) = qspan_positions.last() else {
318 return String::new();
319 };
320
321 let matched_positions: PositionSet = qspan_positions.iter().copied().collect();
322 let tokens = tokenize_matched_unknown_text(&query.text, query);
323 let reportable_tokens = collect_reportable_unknown_tokens(
324 tokens,
325 &matched_positions,
326 start_pos,
327 end_pos,
328 start_line.get(),
329 end_line.get(),
330 );
331 let line_endings = collect_line_endings(&query.text);
332
333 render_unknown_rule_tokens(&reportable_tokens, &line_endings)
334}
335
336fn tokenize_matched_unknown_text(text: &str, query: &Query) -> Vec<MatchedTextToken> {
337 let mut tokens = Vec::new();
338 let mut pos = 0usize;
339 for (line_num, line) in (1usize..).zip(text.split_inclusive('\n')) {
340 for capture in MATCHED_TEXT_PATTERN.captures_iter(line) {
341 if let Some(token_match) = capture.name("token") {
342 let token_text = token_match.as_str();
343 let retokenized: Vec<String> = QUERY_PATTERN
344 .find_iter(&token_text.to_lowercase())
345 .map(|m| m.as_str().to_string())
346 .filter(|token| !STOPWORDS.contains(token.as_str()))
347 .collect();
348
349 if retokenized.is_empty() {
350 tokens.push(MatchedTextToken {
351 value: token_text.to_string(),
352 line_num,
353 pos: None,
354 is_text: true,
355 is_known: false,
356 is_matched: false,
357 });
358 } else if retokenized.len() == 1 {
359 let token = &retokenized[0];
360 let is_known = query.index.dictionary.get(token).is_some();
361 let token_pos = if is_known {
362 let current_pos = pos;
363 pos += 1;
364 Some(current_pos)
365 } else {
366 None
367 };
368
369 tokens.push(MatchedTextToken {
370 value: token_text.to_string(),
371 line_num,
372 pos: token_pos,
373 is_text: true,
374 is_known,
375 is_matched: false,
376 });
377 } else {
378 for token in retokenized {
379 let is_known = query.index.dictionary.get(&token).is_some();
380 let token_pos = if is_known {
381 let current_pos = pos;
382 pos += 1;
383 Some(current_pos)
384 } else {
385 None
386 };
387
388 tokens.push(MatchedTextToken {
389 value: token,
390 line_num,
391 pos: token_pos,
392 is_text: true,
393 is_known,
394 is_matched: false,
395 });
396 }
397 }
398 } else if let Some(punct_match) = capture.name("punct") {
399 tokens.push(MatchedTextToken {
400 value: punct_match.as_str().to_string(),
401 line_num,
402 pos: None,
403 is_text: false,
404 is_known: false,
405 is_matched: false,
406 });
407 }
408 }
409 }
410
411 tokens
412}
413
414fn collect_reportable_unknown_tokens(
415 tokens: Vec<MatchedTextToken>,
416 matched_positions: &PositionSet,
417 start_pos: usize,
418 end_pos: usize,
419 start_line: usize,
420 end_line: usize,
421) -> Vec<MatchedTextToken> {
422 let mut reportable = Vec::new();
423 let mut started = false;
424 let mut finished = false;
425 let mut end_real_pos = None;
426 let mut last_real_pos = None;
427
428 for (real_pos, mut token) in tokens.into_iter().enumerate() {
429 if token.line_num < start_line {
430 continue;
431 }
432
433 if token.line_num > end_line {
434 break;
435 }
436
437 let mut is_included = false;
438
439 if token
440 .pos
441 .is_some_and(|pos| token.is_known && matched_positions.contains(pos))
442 {
443 token.is_matched = true;
444 is_included = true;
445 }
446
447 if !started && token.pos == Some(start_pos) {
448 started = true;
449 is_included = true;
450 }
451
452 if started && !finished {
453 is_included = true;
454 }
455
456 if token.pos == Some(end_pos) {
457 finished = true;
458 started = false;
459 end_real_pos = Some(real_pos);
460 }
461
462 if finished && !started && end_real_pos.is_some() && last_real_pos == end_real_pos {
463 end_real_pos = None;
464 if !token.is_text && !token.value.trim().is_empty() {
465 is_included = true;
466 }
467 }
468
469 last_real_pos = Some(real_pos);
470
471 if is_included {
472 reportable.push(token);
473 }
474 }
475
476 reportable
477}
478
479fn collect_line_endings(text: &str) -> Vec<String> {
480 text.split_inclusive('\n')
481 .map(|line| {
482 if line.ends_with("\r\n") {
483 "\r\n".to_string()
484 } else if line.ends_with('\n') {
485 "\n".to_string()
486 } else {
487 String::new()
488 }
489 })
490 .collect()
491}
492
493fn render_unknown_rule_tokens(tokens: &[MatchedTextToken], line_endings: &[String]) -> String {
494 let mut rendered = String::new();
495 let mut previous_line: Option<usize> = None;
496
497 for token in tokens {
498 if let Some(prev_line) = previous_line
499 && token.line_num > prev_line
500 {
501 for line in prev_line..token.line_num {
502 if let Some(line_ending) = line_endings.get(line.saturating_sub(1)) {
503 rendered.push_str(line_ending.as_str());
504 }
505 }
506 }
507
508 let token_value = if token.is_text {
509 token.value.as_str()
510 } else {
511 token
512 .value
513 .strip_suffix("\r\n")
514 .or_else(|| token.value.strip_suffix('\n'))
515 .unwrap_or(token.value.as_str())
516 };
517
518 if token.is_text && !STOPWORDS.contains(token.value.to_lowercase().as_str()) {
519 if token.is_matched {
520 rendered.push_str(token_value);
521 } else {
522 rendered.push('.');
523 }
524 } else {
525 rendered.push_str(token_value);
526 }
527
528 previous_line = Some(token.line_num);
529 }
530
531 rendered
532}
533
534fn build_unknown_rule_identifier(rule_text: &str) -> String {
535 let content = format!("None{}", python_str_repr(rule_text));
536 let mut hasher = Sha1::new();
537 hasher.update(content.as_bytes());
538 let digest = hasher.finalize();
539
540 format!("license-detection-unknown-{}", hex::encode(digest))
541}
542
543fn python_str_repr(text: &str) -> String {
544 let use_double_quotes = text.contains('\'') && !text.contains('"');
545 let quote = if use_double_quotes { '"' } else { '\'' };
546 let mut escaped = String::with_capacity(text.len());
547
548 for ch in text.chars() {
549 match ch {
550 '\\' => escaped.push_str("\\\\"),
551 '\n' => escaped.push_str("\\n"),
552 '\r' => escaped.push_str("\\r"),
553 '\t' => escaped.push_str("\\t"),
554 '\'' if !use_double_quotes => escaped.push_str("\\'"),
555 '"' if use_double_quotes => escaped.push_str("\\\""),
556 _ => escaped.push(ch),
557 }
558 }
559
560 format!("{quote}{escaped}{quote}")
561}
562
563fn calculate_score(ngram_count: usize, match_len: usize) -> MatchScore {
564 if match_len == 0 {
565 return MatchScore::default();
566 }
567
568 let density = ngram_count as f64 / match_len as f64;
569
570 MatchScore::from_percentage(density.min(1.0) * 100.0)
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use crate::license_detection::index::LicenseIndex;
577 use crate::license_detection::index::dictionary::{TokenId, tid};
578 use crate::license_detection::query::Query;
579
580 fn tids(values: &[u16]) -> Vec<TokenId> {
581 values.iter().copied().map(tid).collect()
582 }
583
584 #[test]
585 fn test_unknown_match_empty_query() {
586 let index = LicenseIndex::with_legalese_count(10);
587 let query = Query::from_extracted_text("", &index, false).expect("Failed to create query");
588 let known_matches = vec![];
589
590 let matches = unknown_match(&index, &query, &known_matches);
591
592 assert!(matches.is_empty());
593 }
594
595 #[test]
596 fn test_find_unmatched_regions_no_coverage() {
597 let query_len = 10;
598 let covered_positions = PositionSet::new();
599
600 let regions = find_unmatched_regions(query_len, &covered_positions);
601
602 assert_eq!(regions, vec![(0, 10)]);
603 }
604
605 #[test]
606 fn test_find_unmatched_regions_full_coverage() {
607 let query_len = 10;
608 let covered_positions: PositionSet = (0..10).collect();
609
610 let regions = find_unmatched_regions(query_len, &covered_positions);
611
612 assert!(regions.is_empty());
613 }
614
615 #[test]
616 fn test_find_unmatched_regions_partial_coverage() {
617 let query_len = 20;
618 let covered_positions: PositionSet = [0, 1, 2, 12, 13, 14, 15, 16, 17, 18, 19]
619 .iter()
620 .copied()
621 .collect();
622
623 let regions = find_unmatched_regions(query_len, &covered_positions);
624
625 assert_eq!(regions.len(), 1);
626 assert_eq!(regions[0], (3, 12));
627 }
628
629 #[test]
630 fn test_find_unmatched_regions_trailing_unmatched() {
631 let query_len = 20;
632 let covered_positions: PositionSet = [0, 1, 2, 3, 4, 5].iter().copied().collect();
633
634 let regions = find_unmatched_regions(query_len, &covered_positions);
635
636 assert_eq!(regions.len(), 1);
637 assert_eq!(regions[0], (6, 20));
638 }
639
640 #[test]
641 fn test_compute_qspan_union_empty() {
642 let positions: Vec<(usize, usize)> = Vec::new();
643 let merged = compute_qspan_union(&positions);
644 assert!(merged.is_empty());
645 }
646
647 #[test]
648 fn test_compute_qspan_union_single() {
649 let positions = vec![(5, 11)];
650 let merged = compute_qspan_union(&positions);
651 assert_eq!(merged.len(), 6);
652 assert!(merged.contains(5));
653 assert!(merged.contains(10));
654 assert!(!merged.contains(4));
655 assert!(!merged.contains(11));
656 }
657
658 #[test]
659 fn test_compute_qspan_union_overlapping() {
660 let positions = vec![(5, 11), (8, 14), (20, 26)];
661 let merged = compute_qspan_union(&positions);
662 assert_eq!(merged.len(), 15);
663 assert!(merged.contains(5));
664 assert!(merged.contains(13));
665 assert!(!merged.contains(14));
666 assert!(merged.contains(20));
667 assert!(merged.contains(25));
668 assert!(!merged.contains(26));
669 }
670
671 #[test]
672 fn test_compute_qspan_union_adjacent() {
673 let positions = vec![(5, 11), (11, 17)];
674 let merged = compute_qspan_union(&positions);
675 assert_eq!(merged.len(), 12);
676 assert!(merged.contains(5));
677 assert!(merged.contains(16));
678 assert!(!merged.contains(4));
679 assert!(!merged.contains(17));
680 }
681
682 #[test]
683 fn test_compute_qspan_union_unsorted() {
684 let positions = vec![(20, 26), (5, 11), (8, 14)];
685 let merged = compute_qspan_union(&positions);
686 assert_eq!(merged.len(), 15);
687 assert!(merged.contains(5));
688 assert!(merged.contains(13));
689 assert!(merged.contains(20));
690 assert!(merged.contains(25));
691 }
692
693 #[test]
694 fn test_compute_hispan_from_qspan() {
695 let mut index = LicenseIndex::with_legalese_count(0);
696 let legalese_entries: Vec<(String, u16)> =
697 (0u16..15).map(|i| (format!("legalese-{i}"), i)).collect();
698 index.dictionary =
699 crate::license_detection::index::dictionary::TokenDictionary::new_with_legalese_pairs(
700 &legalese_entries
701 .iter()
702 .map(|(token, id)| (token.as_str(), *id))
703 .collect::<Vec<_>>(),
704 );
705
706 let mut tokens: Vec<TokenId> = (0..15)
707 .map(|i| {
708 index
709 .dictionary
710 .get_token_id(&format!("legalese-{i}"))
711 .unwrap()
712 })
713 .collect();
714 for i in 15..30 {
715 tokens.push(index.dictionary.get_or_assign(&format!("regular-{i}")));
716 }
717 let qspan: PositionSet = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 24]
718 .iter()
719 .copied()
720 .collect();
721 let hispan = compute_hispan_from_qspan(&tokens, &qspan, &index);
722 assert_eq!(hispan, 10);
723 }
724
725 #[test]
726 fn test_get_matched_ngrams_empty_automaton() {
727 use crate::license_detection::automaton::AutomatonBuilder;
728
729 let tokens = tids(&[1, 2, 3, 4, 5, 6, 7, 8]);
730 let automaton = AutomatonBuilder::new().build();
731
732 let matches = get_matched_ngrams(&tokens, 0, 8, &automaton);
733
734 assert!(matches.is_empty());
735 }
736
737 #[test]
738 fn test_get_matched_ngrams_with_matches() {
739 use crate::license_detection::automaton::AutomatonBuilder;
740
741 let tokens: Vec<TokenId> = (0..30).map(tid).collect();
742 let ngram: Vec<u8> = vec![0, 0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0];
743
744 let mut builder = AutomatonBuilder::new();
745 builder.add_pattern(&ngram);
746 let automaton = builder.build();
747
748 let matches = get_matched_ngrams(&tokens, 0, 30, &automaton);
749
750 assert!(!matches.is_empty(), "Should find ngram matches");
751
752 for (qstart, qend) in &matches {
753 assert_eq!(*qend - *qstart, UNKNOWN_NGRAM_LENGTH);
754 }
755 }
756
757 #[test]
758 fn test_get_matched_ngrams_keeps_overlapping_matches() {
759 use crate::license_detection::automaton::AutomatonBuilder;
760
761 let tokens = tids(&[1, 2, 3, 1, 2, 3, 1, 2, 3]);
762 let overlapping_ngram: Vec<u8> = tokens[..UNKNOWN_NGRAM_LENGTH]
763 .iter()
764 .flat_map(|tid| tid.to_le_bytes())
765 .collect();
766
767 let mut builder = AutomatonBuilder::new();
768 builder.add_pattern(&overlapping_ngram);
769 let automaton = builder.build();
770
771 let matches = get_matched_ngrams(&tokens, 0, tokens.len(), &automaton);
772
773 assert_eq!(matches, vec![(0, 6), (3, 9)]);
774 }
775
776 #[test]
777 fn test_calculate_score() {
778 let score1 = calculate_score(5, 10);
779 let score2 = calculate_score(10, 10);
780 let score3 = calculate_score(0, 10);
781
782 assert!(score2 > score1);
783 assert!(score2 <= MatchScore::MAX);
784 assert_eq!(score3, MatchScore::default());
785 }
786
787 #[test]
788 fn test_find_unmatched_regions_leading_unmatched() {
789 let query_len = 20;
790 let covered_positions: PositionSet = [10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
791 .iter()
792 .copied()
793 .collect();
794
795 let regions = find_unmatched_regions(query_len, &covered_positions);
796
797 assert_eq!(regions.len(), 1);
798 assert_eq!(regions[0], (0, 10));
799 }
800
801 #[test]
802 fn test_find_unmatched_regions_middle_gap() {
803 let query_len = 30;
804 let covered_positions: PositionSet =
805 [0, 1, 2, 3, 4, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29]
806 .iter()
807 .copied()
808 .collect();
809
810 let regions = find_unmatched_regions(query_len, &covered_positions);
811
812 assert_eq!(regions.len(), 1);
813 assert_eq!(regions[0], (5, 20));
814 }
815
816 #[test]
817 fn test_compute_covered_positions_gapped_qspan() {
818 let index = LicenseIndex::with_legalese_count(10);
819 let query = Query::from_extracted_text("some license text here", &index, false)
820 .expect("Failed to create query");
821
822 let known_matches = vec![LicenseMatch {
823 rid: RuleId::NONE,
824 license_expression: "test".to_string(),
825 license_expression_spdx: Some("TEST".to_string()),
826 from_file: None,
827 start_line: LineNumber::ONE,
828 end_line: LineNumber::ONE,
829 start_token: 0,
830 end_token: 10,
831 matcher: MatcherKind::Aho,
832 score: MatchScore::MAX,
833 matched_length: 6,
834 rule_length: 6,
835 match_coverage: 100.0,
836 rule_relevance: 100,
837 rule_identifier: "test-rule".to_string(),
838 rule_url: String::new(),
839 matched_text: Some("matched text".to_string()),
840 referenced_filenames: None,
841 rule_kind: crate::license_detection::models::RuleKind::None,
842 is_from_license: false,
843 rule_start_token: 0,
844 coordinates: MatchCoordinates::query_region(PositionSpan::from_positions(vec![
845 0, 1, 2, 7, 8, 9,
846 ])),
847 }];
848
849 let covered = compute_covered_positions(&query, &known_matches);
850
851 assert!(covered.contains(0), "Should contain position 0");
852 assert!(covered.contains(2), "Should contain position 2");
853 assert!(covered.contains(7), "Should contain position 7");
854 assert!(covered.contains(9), "Should contain position 9");
855 assert!(!covered.contains(3), "Should NOT contain position 3 (gap)");
856 assert!(!covered.contains(5), "Should NOT contain position 5 (gap)");
857 assert!(
858 !covered.contains(10),
859 "Should NOT contain position 10 (outside)"
860 );
861 }
862
863 #[test]
864 fn test_compute_covered_positions_fallback_contiguous() {
865 let index = LicenseIndex::with_legalese_count(10);
866 let query = Query::from_extracted_text("some license text here", &index, false)
867 .expect("Failed to create query");
868
869 let known_matches = vec![LicenseMatch {
870 rid: RuleId::NONE,
871 license_expression: "test".to_string(),
872 license_expression_spdx: Some("TEST".to_string()),
873 from_file: None,
874 start_line: LineNumber::ONE,
875 end_line: LineNumber::ONE,
876 start_token: 5,
877 end_token: 10,
878 matcher: MatcherKind::Aho,
879 score: MatchScore::MAX,
880 matched_length: 5,
881 rule_length: 5,
882 match_coverage: 100.0,
883 rule_relevance: 100,
884 rule_identifier: "test-rule".to_string(),
885 rule_url: String::new(),
886 matched_text: Some("matched text".to_string()),
887 referenced_filenames: None,
888 rule_kind: crate::license_detection::models::RuleKind::None,
889 is_from_license: false,
890 rule_start_token: 0,
891 coordinates: MatchCoordinates::query_region(PositionSpan::range(5, 10)),
892 }];
893
894 let covered = compute_covered_positions(&query, &known_matches);
895
896 assert!(covered.contains(5), "Should contain position 5");
897 assert!(covered.contains(7), "Should contain position 7");
898 assert!(covered.contains(9), "Should contain position 9");
899 assert!(
900 !covered.contains(4),
901 "Should NOT contain position 4 (before)"
902 );
903 assert!(
904 !covered.contains(10),
905 "Should NOT contain position 10 (after)"
906 );
907 }
908
909 #[test]
910 fn test_compute_covered_positions_qspan_creates_extra_unmatched_region() {
911 let index = LicenseIndex::with_legalese_count(10);
912 let query = Query::from_extracted_text("some license text here", &index, false)
913 .expect("Failed to create query");
914
915 let known_matches = vec![LicenseMatch {
916 rid: RuleId::NONE,
917 license_expression: "test".to_string(),
918 license_expression_spdx: Some("TEST".to_string()),
919 from_file: None,
920 start_line: LineNumber::ONE,
921 end_line: LineNumber::ONE,
922 start_token: 0,
923 end_token: 15,
924 matcher: MatcherKind::Aho,
925 score: MatchScore::MAX,
926 matched_length: 8,
927 rule_length: 8,
928 match_coverage: 100.0,
929 rule_relevance: 100,
930 rule_identifier: "test-rule".to_string(),
931 rule_url: String::new(),
932 matched_text: Some("matched text".to_string()),
933 referenced_filenames: None,
934 rule_kind: crate::license_detection::models::RuleKind::None,
935 is_from_license: false,
936 rule_start_token: 0,
937 coordinates: MatchCoordinates::query_region(PositionSpan::from_positions(vec![
938 0, 1, 2, 3, 11, 12, 13, 14,
939 ])),
940 }];
941
942 let covered = compute_covered_positions(&query, &known_matches);
943 let regions = find_unmatched_regions(20, &covered);
944
945 assert!(
946 regions.contains(&(4, 11)),
947 "Should have unmatched region 4-11 (the gap in qspan_positions), got: {:?}",
948 regions
949 );
950 assert!(
951 regions.contains(&(15, 20)),
952 "Should have trailing unmatched region 15-20, got: {:?}",
953 regions
954 );
955
956 let contiguous_covered: PositionSet = (0..15).collect();
957 let contiguous_regions = find_unmatched_regions(20, &contiguous_covered);
958 assert_eq!(
959 contiguous_regions,
960 vec![(15, 20)],
961 "Contiguous coverage would collapse the gap, producing only trailing region"
962 );
963 }
964
965 #[test]
966 fn test_create_unknown_match_from_qspan_valid() {
967 use crate::license_detection::test_utils::create_mock_query_with_tokens;
968
969 let index = LicenseIndex::with_legalese_count(10);
970
971 let tokens: Vec<u16> = (0..30).collect();
972 let query = create_mock_query_with_tokens(&tokens, &index);
973
974 let qspan: PositionSet = (0..30).collect();
975
976 let match_result = create_unknown_match_from_qspan(&query, &qspan);
977
978 assert!(
979 match_result.is_some(),
980 "Should create unknown match for sufficient length"
981 );
982
983 let m = match_result.unwrap();
984 assert_eq!(m.license_expression, "unknown");
985 assert_eq!(m.matcher, MATCH_UNKNOWN);
986 assert!(!m.coordinates.query_span().is_empty());
987 }
988
989 #[test]
990 fn test_unknown_match_with_known_matches() {
991 let index = LicenseIndex::with_legalese_count(10);
992 let text = "some text that is license related and should be detected";
993 let query =
994 Query::from_extracted_text(text, &index, false).expect("Failed to create query");
995
996 let known_matches = vec![LicenseMatch {
997 rid: RuleId::NONE,
998 license_expression: "mit".to_string(),
999 license_expression_spdx: Some("MIT".to_string()),
1000 from_file: None,
1001 start_line: LineNumber::ONE,
1002 end_line: LineNumber::ONE,
1003 start_token: 0,
1004 end_token: 5,
1005 matcher: MatcherKind::Aho,
1006 score: MatchScore::MAX,
1007 matched_length: 5,
1008 rule_length: 5,
1009 match_coverage: 100.0,
1010 rule_relevance: 100,
1011 rule_identifier: "test-rule".to_string(),
1012 rule_url: String::new(),
1013 matched_text: Some("some text".to_string()),
1014 referenced_filenames: None,
1015 rule_kind: crate::license_detection::models::RuleKind::None,
1016 is_from_license: false,
1017 rule_start_token: 0,
1018 coordinates: MatchCoordinates::query_region(PositionSpan::range(0, 5)),
1019 }];
1020
1021 let matches = unknown_match(&index, &query, &known_matches);
1022
1023 assert!(
1024 matches.is_empty() || matches[0].start_line > LineNumber::ONE,
1025 "Should not re-detect known regions"
1026 );
1027 }
1028
1029 #[test]
1030 fn test_calculate_score_edge_cases() {
1031 let score_zero_length = calculate_score(10, 0);
1032 assert_eq!(
1033 score_zero_length,
1034 MatchScore::default(),
1035 "Zero length should have zero score"
1036 );
1037
1038 let score_zero_ngrams = calculate_score(0, 100);
1039 assert_eq!(
1040 score_zero_ngrams,
1041 MatchScore::default(),
1042 "Zero ngrams should have zero score"
1043 );
1044
1045 let score_high_density = calculate_score(100, 50);
1046 assert_eq!(
1047 score_high_density,
1048 MatchScore::MAX,
1049 "High density should be capped at 100.0"
1050 );
1051 }
1052
1053 #[test]
1054 fn test_get_matched_ngrams_out_of_bounds() {
1055 use crate::license_detection::automaton::AutomatonBuilder;
1056
1057 let tokens = tids(&[1, 2, 3]);
1058 let automaton = AutomatonBuilder::new().build();
1059
1060 let matches = get_matched_ngrams(&tokens, 5, 10, &automaton);
1061 assert!(matches.is_empty(), "Out of bounds should return empty");
1062
1063 let matches = get_matched_ngrams(&tokens, 2, 1, &automaton);
1064 assert!(matches.is_empty(), "Invalid range should return empty");
1065 }
1066
1067 fn unknown_index_with_windows(
1072 token_count: usize,
1073 legalese_count: u16,
1074 window_starts: &[usize],
1075 ) -> (LicenseIndex, Vec<u16>) {
1076 use crate::license_detection::automaton::AutomatonBuilder;
1077
1078 let legalese_entries: Vec<(String, u16)> = (0u16..legalese_count)
1079 .map(|i| (format!("legalese-{i}"), i))
1080 .collect();
1081 let mut index = LicenseIndex::with_legalese_count(0);
1082 index.dictionary =
1083 crate::license_detection::index::dictionary::TokenDictionary::new_with_legalese_pairs(
1084 &legalese_entries
1085 .iter()
1086 .map(|(token, id)| (token.as_str(), *id))
1087 .collect::<Vec<_>>(),
1088 );
1089
1090 let token_ids: Vec<TokenId> = (0..token_count as u16).map(TokenId::new).collect();
1091
1092 let mut builder = AutomatonBuilder::new();
1093 for &start in window_starts {
1094 let window = &token_ids[start..start + UNKNOWN_NGRAM_LENGTH];
1095 let pattern: Vec<u8> = window.iter().flat_map(|tid| tid.to_le_bytes()).collect();
1096 builder.add_pattern(&pattern);
1097 }
1098 index.unknown_automaton = builder.build();
1099
1100 let tokens: Vec<u16> = (0..token_count as u16).collect();
1101 (index, tokens)
1102 }
1103
1104 #[test]
1108 fn test_unknown_match_pins_min_qspan_at_24() {
1109 use crate::license_detection::test_utils::create_mock_query_with_tokens;
1110
1111 let (index, tokens) = unknown_index_with_windows(30, 30, &[0, 6, 12]);
1113 let query = create_mock_query_with_tokens(&tokens, &index);
1114 assert!(unknown_match(&index, &query, &[]).is_empty());
1115
1116 let (index, tokens) = unknown_index_with_windows(30, 30, &[0, 6, 12, 18]);
1118 let query = create_mock_query_with_tokens(&tokens, &index);
1119 assert_eq!(unknown_match(&index, &query, &[]).len(), 1);
1120 }
1121
1122 #[test]
1126 fn test_unknown_match_pins_min_hispan_at_5() {
1127 use crate::license_detection::test_utils::create_mock_query_with_tokens;
1128
1129 let (index, tokens) = unknown_index_with_windows(30, 4, &[0, 6, 12, 18]);
1131 let query = create_mock_query_with_tokens(&tokens, &index);
1132 assert!(unknown_match(&index, &query, &[]).is_empty());
1133
1134 let (index, tokens) = unknown_index_with_windows(30, 5, &[0, 6, 12, 18]);
1136 let query = create_mock_query_with_tokens(&tokens, &index);
1137 assert_eq!(unknown_match(&index, &query, &[]).len(), 1);
1138 }
1139
1140 #[test]
1144 fn test_unknown_match_pins_min_region_length_early_out() {
1145 use crate::license_detection::test_utils::create_mock_query_with_tokens;
1146
1147 let (index, tokens) = unknown_index_with_windows(30, 30, &[0, 6, 12, 18]);
1148 let query = create_mock_query_with_tokens(&tokens, &index);
1149
1150 let known = vec![LicenseMatch {
1153 matcher: MatcherKind::Aho,
1154 coordinates: MatchCoordinates::query_region(PositionSpan::range(4, 30)),
1155 ..Default::default()
1156 }];
1157 assert!(unknown_match(&index, &query, &known).is_empty());
1158 }
1159
1160 #[test]
1165 fn test_unknown_match_pins_min_ngram_matches_early_out() {
1166 use crate::license_detection::test_utils::create_mock_query_with_tokens;
1167
1168 let (index, tokens) = unknown_index_with_windows(30, 30, &[0, 6]);
1169 let query = create_mock_query_with_tokens(&tokens, &index);
1170 assert!(unknown_match(&index, &query, &[]).is_empty());
1171 }
1172}