use std::borrow::Cow;
use std::fmt;
use log::{debug, info, trace};
use serde::Serialize;
use crate::data::{LicenseType, MatchData, NoData, TextData};
use crate::store::{Match, Store};
#[derive(Serialize, Clone)]
pub struct IdentifiedLicense<'a, D> {
pub name: &'a str,
pub kind: LicenseType,
pub data: &'a MatchData<D>,
}
impl<D> fmt::Debug for IdentifiedLicense<'_, D> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("IdentifiedLicense")
.field("name", &self.name)
.field("kind", &self.kind)
.finish()
}
}
#[derive(Serialize, Debug)]
pub struct ScanResult<'a, D> {
pub score: f32,
pub license: Option<IdentifiedLicense<'a, D>>,
pub containing: Vec<ContainedResult<'a, D>>,
}
#[derive(Serialize, Debug, Clone)]
pub struct ContainedResult<'a, D> {
pub score: f32,
pub license: IdentifiedLicense<'a, D>,
pub line_range: (usize, usize),
}
#[derive(Debug)]
pub struct ScanStrategy<'a, D> {
store: &'a Store<D>,
confidence_threshold: f32,
shallow_limit: f32,
max_passes: u16,
step_size: usize,
}
#[derive(Clone, Copy, Debug)]
pub enum ScanMode {
Elimination {
optimize: bool,
},
TopDown,
}
impl Default for ScanMode {
fn default() -> Self {
ScanMode::Elimination { optimize: false }
}
}
impl<'a, D> ScanStrategy<'a, D> {
#[must_use]
pub const fn new(store: &'a Store<D>) -> ScanStrategy<'a, D> {
Self {
store,
confidence_threshold: 0.9,
shallow_limit: 0.99,
max_passes: 10,
step_size: 5,
}
}
#[must_use]
pub const fn confidence_threshold(mut self, confidence_threshold: f32) -> Self {
self.confidence_threshold = confidence_threshold;
self
}
#[must_use]
pub const fn shallow_limit(mut self, shallow_limit: f32) -> Self {
self.shallow_limit = shallow_limit;
self
}
#[must_use]
pub const fn max_passes(mut self, max_passes: u16) -> Self {
self.max_passes = max_passes;
self
}
#[must_use]
pub const fn step_size(mut self, step_size: usize) -> Self {
self.step_size = step_size;
self
}
}
impl ScanStrategy<'_, TextData> {
#[must_use]
pub fn scan(&self, text: &MatchData<TextData>, mode: ScanMode) -> ScanResult<'_, TextData> {
match mode {
ScanMode::Elimination { optimize } => self.scan_elimination(text, optimize),
ScanMode::TopDown => self.scan_topdown(text),
}
}
fn scan_elimination(&self, text: &MatchData<TextData>, optimize: bool) -> ScanResult<'_, TextData> {
let mut analysis = self.store.analyze(text);
let score = analysis.score;
let mut license = None;
let mut containing = Vec::new();
debug!("Elimination top-level analysis: {analysis:?}");
if analysis.score > self.confidence_threshold {
license = Some(IdentifiedLicense {
name: analysis.name,
kind: analysis.license_type,
data: analysis.data,
});
if analysis.score > self.shallow_limit {
return ScanResult {
score,
license,
containing,
};
}
}
if optimize {
let mut current_text: Cow<'_, MatchData<TextData>> = Cow::Borrowed(text);
for _n in 0..self.max_passes {
let (optimized, optimized_score) = current_text.optimize_bounds(analysis.data);
if optimized_score < self.confidence_threshold {
break;
}
info!(
"Optimized to {} lines ({}, {})",
optimized_score,
optimized.lines_view().0,
optimized.lines_view().1
);
containing.push(ContainedResult {
score: optimized_score,
license: IdentifiedLicense {
name: analysis.name,
kind: analysis.license_type,
data: analysis.data,
},
line_range: optimized.lines_view(),
});
current_text = Cow::Owned(optimized.white_out());
analysis = self.store.analyze(¤t_text);
}
}
ScanResult {
score,
license,
containing,
}
}
fn scan_topdown(&self, text: &MatchData<TextData>) -> ScanResult<'_, TextData> {
let (_, text_end) = text.lines_view();
let mut containing = Vec::new();
let mut current_start = 0usize;
while current_start < text_end {
let result = self.topdown_find_contained_license(text, current_start);
let Some(contained) = result else { break };
current_start = contained.line_range.1 + 1;
containing.push(contained);
}
ScanResult {
score: 0.0,
license: None,
containing,
}
}
fn topdown_find_contained_license(
&self,
text: &MatchData<TextData>,
starting_at: usize,
) -> Option<ContainedResult<'_, TextData>> {
let (_, text_end) = text.lines_view();
let mut found: (usize, usize, Option<Match<'_, TextData>>) = (0, 0, None);
trace!("topdown_find_contained_license starting at line {starting_at}");
let mut hit_threshold = false;
'start: for start in (starting_at..text_end).step_by(self.step_size) {
for end in (start..=text_end).step_by(self.step_size) {
let view = text.with_view(start, end);
let analysis = self.store.analyze(&view);
if !hit_threshold && analysis.score >= self.confidence_threshold {
hit_threshold = true;
trace!("hit_threshold at ({}, {}) with score {}", start, end, analysis.score);
}
if hit_threshold {
if analysis.score < self.confidence_threshold {
trace!(
"exiting threshold at ({}, {}) with score {}",
start,
end,
analysis.score
);
break 'start;
}
found = (start, end, Some(analysis));
}
}
}
let matched = found.2?;
let check = matched.data;
let view = text.with_view(found.0, found.1);
let (optimized, optimized_score) = view.optimize_bounds(check);
trace!(
"optimized {} {} at ({:?})",
optimized_score,
matched.name,
optimized.lines_view()
);
if optimized_score < self.confidence_threshold {
return None;
}
Some(ContainedResult {
score: optimized_score,
license: IdentifiedLicense {
name: matched.name,
kind: matched.license_type,
data: matched.data,
},
line_range: optimized.lines_view(),
})
}
}
impl ScanStrategy<'_, NoData> {
#[must_use]
pub fn scan(&self, text: &MatchData<NoData>) -> ScanResult<'_, NoData> {
self.scan_elimination(text)
}
fn scan_elimination(&self, text: &MatchData<NoData>) -> ScanResult<'_, NoData> {
let analysis = self.store.analyze(text);
let score = analysis.score;
let mut license = None;
let containing = Vec::new();
debug!("Elimination top-level analysis: {analysis:?}");
if analysis.score > self.confidence_threshold {
license = Some(IdentifiedLicense {
name: analysis.name,
kind: analysis.license_type,
data: analysis.data,
});
if analysis.score > self.shallow_limit {
return ScanResult {
score,
license,
containing,
};
}
}
ScanResult {
score,
license,
containing,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::TextData;
#[test]
fn can_construct() {
let store: Store<TextData> = Store::new();
let _ = ScanStrategy::new(&store);
let _ = ScanStrategy::new(&store).confidence_threshold(0.5);
let _ = ScanStrategy::new(&store).shallow_limit(0.99).max_passes(100);
}
#[test]
fn shallow_scan() {
let store = create_dummy_store();
let test_data = MatchData::new("lorem ipsum\naaaaa bbbbb\nccccc\nhello");
let strategy = ScanStrategy::new(&store).confidence_threshold(0.5).shallow_limit(0.0);
let result = strategy.scan(&test_data, ScanMode::default());
assert!(result.score > 0.5, "score must meet threshold; was {}", result.score);
assert_eq!(result.license.expect("result has a license").name, "license-1");
let strategy = ScanStrategy::new(&store).confidence_threshold(0.8).shallow_limit(0.0);
let result = strategy.scan(&test_data, ScanMode::default());
assert!(result.license.is_none(), "result license is None");
}
#[test]
fn single_optimize() {
let store = create_dummy_store();
let test_data =
MatchData::new("lorem\nipsum abc def ghi jkl\n1234 5678 1234\n0000\n1010101010\n\n8888 9999\nwhatsit hello\narst neio qwfp colemak is the best keyboard layout");
let strategy = ScanStrategy::new(&store).confidence_threshold(0.5).shallow_limit(1.0);
let result = strategy.scan(&test_data, ScanMode::Elimination { optimize: true });
assert!(result.license.is_none(), "result license is None");
assert_eq!(result.containing.len(), 1);
let contained = &result.containing[0];
assert_eq!(contained.license.name, "license-2");
assert!(contained.score > 0.5, "contained score is greater than threshold");
}
#[test]
fn find_multiple_licenses_elimination() {
let store = create_dummy_store();
let test_data =
MatchData::new("lorem\nipsum abc def ghi jkl\n1234 5678 1234\n0000\n1010101010\n\n8888 9999\nwhatsit hello\narst neio qwfp colemak is the best keyboard layout\naaaaa\nbbbbb\nccccc");
let strategy = ScanStrategy::new(&store).confidence_threshold(0.5).shallow_limit(1.0);
let result = strategy.scan(&test_data, ScanMode::Elimination { optimize: true });
assert!(result.license.is_none(), "result license is None");
assert_eq!(2, result.containing.len());
let mut found1 = 0;
let mut found2 = 0;
for contained in &result.containing {
match contained.license.name {
"license-1" => {
assert!(contained.score > 0.5, "license-1 score meets threshold");
found1 += 1;
},
"license-2" => {
assert!(contained.score > 0.5, "license-2 score meets threshold");
found2 += 1;
},
_ => {
panic!("somehow got an unknown license name");
},
}
}
assert!(found1 == 1 && found2 == 1, "found both licenses exactly once");
}
#[test]
fn find_multiple_licenses_topdown() {
env_logger::init();
let store = create_dummy_store();
let test_data =
MatchData::new("lorem\nipsum abc def ghi jkl\n1234 5678 1234\n0000\n1010101010\n\n8888 9999\nwhatsit hello\narst neio qwfp colemak is the best keyboard layout\naaaaa\nbbbbb\nccccc");
let strategy = ScanStrategy::new(&store).confidence_threshold(0.5).step_size(1);
let result = strategy.scan(&test_data, ScanMode::TopDown);
assert!(result.license.is_none(), "result license is None");
println!("{result:?}");
assert_eq!(2, result.containing.len());
let mut found1 = 0;
let mut found2 = 0;
for contained in &result.containing {
match contained.license.name {
"license-1" => {
assert!(contained.score > 0.5, "license-1 score meets threshold");
found1 += 1;
},
"license-2" => {
assert!(contained.score > 0.5, "license-2 score meets threshold");
found2 += 1;
},
_ => {
panic!("somehow got an unknown license name");
},
}
}
assert!(found1 == 1 && found2 == 1, "found both licenses exactly once");
}
fn create_dummy_store() -> Store<TextData> {
let mut store = Store::new();
store.add_license("license-1".into(), "aaaaa\nbbbbb\nccccc".into());
store.add_license(
"license-2".into(),
"1234 5678 1234\n0000\n1010101010\n\n8888 9999".into(),
);
store
}
}