#[allow(clippy::wildcard_imports)]
use super::*;
pub struct Corpus {
pub issues: Vec<Issue>,
texts: std::cell::OnceCell<Vec<String>>,
}
impl Corpus {
pub fn build(store: &Store) -> Result<Corpus> {
Self::build_with(store, false)
}
pub fn build_with(store: &Store, with_archived: bool) -> Result<Corpus> {
let issues = if with_archived {
store.list_all()?
} else {
store.list()?
};
Ok(Self::from_issues(issues))
}
pub fn from_issues(issues: Vec<Issue>) -> Corpus {
Corpus {
issues,
texts: std::cell::OnceCell::new(),
}
}
pub fn texts(&self) -> &[String] {
self.texts.get_or_init(|| {
self.issues
.iter()
.map(|issue| issue.to_file_contents())
.collect()
})
}
#[cfg(test)]
pub fn with_texts(issues: Vec<Issue>, texts: Vec<String>) -> Corpus {
Corpus {
issues,
texts: std::cell::OnceCell::from(texts),
}
}
#[cfg(test)]
pub fn texts_initialized(&self) -> bool {
self.texts.get().is_some()
}
pub fn by_number(&self, n: u32) -> Option<usize> {
self.issues.iter().position(|issue| issue.number == n)
}
pub fn apply(&mut self, store: &Store, delta: &FsDelta) -> Result<()> {
if !delta
.changed
.iter()
.any(|p| crate::storage::is_issue_rel_path(p))
&& !delta
.removed
.iter()
.any(|p| crate::storage::is_issue_rel_path(p))
{
return Ok(());
}
let mut updates: Vec<Issue> = Vec::new();
for path in &delta.changed {
if !crate::storage::is_issue_rel_path(path) {
continue;
}
match store.read_issue_at(path) {
Ok(issue) => updates.push(issue),
Err(TixError::NotFound(_) | TixError::Io(_)) => {}
Err(e) => return Err(e),
}
}
let dropped: HashSet<&Path> = delta
.changed
.iter()
.chain(delta.removed.iter())
.filter(|path| crate::storage::is_issue_rel_path(path))
.map(PathBuf::as_path)
.collect();
let mut combined: Vec<Issue> = Vec::with_capacity(self.issues.len() + updates.len());
let issues = std::mem::take(&mut self.issues);
for issue in issues {
if !dropped.contains(Path::new(&issue.filename)) {
combined.push(issue);
}
}
combined.extend(updates);
combined.sort_by(|a, b| a.filename.cmp(&b.filename));
self.issues = combined;
self.texts = std::cell::OnceCell::new();
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchHit {
pub issue: Issue,
pub matched_lines: Vec<String>,
}
pub fn search_substring(corpus: &Corpus, query: &str) -> Vec<SearchHit> {
if query.is_empty() {
return Vec::new();
}
let needle = query.to_lowercase();
let mut hits: Vec<SearchHit> = Vec::new();
for (issue, text) in corpus.issues.iter().zip(corpus.texts().iter()) {
let hay = text.to_lowercase();
if !hay.contains(&needle) {
continue;
}
let matched_lines: Vec<String> = text
.lines()
.filter(|line| line.to_lowercase().contains(&needle))
.map(str::to_string)
.collect();
hits.push(SearchHit {
issue: issue.clone(),
matched_lines,
});
}
hits.sort_by_key(|hit| hit.issue.number);
hits
}
pub fn fuzzy_score(query: &str, haystack: &str) -> Option<u32> {
let q = query.to_lowercase();
let h = haystack.to_lowercase();
if q.is_empty() {
return Some(0);
}
if q == h {
return Some(EXACT_MATCH_SCORE);
}
let positions = greedy_subsequence_positions(&q, &h)?;
let mut score = 0u32;
let mut prev: Option<usize> = None;
for pos in &positions {
score += 100;
score += (1000 - (*pos).min(1000)) as u32;
if prev.is_some_and(|p| *pos == p + 1) {
score += RUN_BONUS;
}
prev = Some(*pos);
}
if positions.first() == Some(&0) {
score += PREFIX_BONUS;
}
Some(score)
}
pub(super) const RUN_BONUS: u32 = 150;
pub(super) const PREFIX_BONUS: u32 = 500;
pub(super) const EXACT_MATCH_SCORE: u32 = 50_000;
pub(super) fn greedy_subsequence_positions(query: &str, haystack: &str) -> Option<Vec<usize>> {
let q: Vec<char> = query.chars().collect();
if q.is_empty() {
return Some(Vec::new());
}
let h: Vec<char> = haystack.chars().collect();
let mut positions = Vec::with_capacity(q.len());
let mut hi = 0usize;
for qc in &q {
let found = h[hi..]
.iter()
.position(|hc| *hc == *qc)
.map(|off| off + hi)?;
positions.push(found);
hi = found + 1;
}
Some(positions)
}