use super::{ListFilters, SqliteStorage};
use crate::error::{BeadsError, Result};
use crate::model::{Comment, Issue};
use regex::{Regex, RegexBuilder};
use std::collections::{HashMap, HashSet};
const SEARCH_BATCH_SIZE: usize = 256;
impl SqliteStorage {
pub(crate) fn search_unicode_issues_unpaginated(
&self,
query: &str,
filters: &ListFilters,
) -> Result<Vec<Issue>> {
let candidates = self.unicode_search_candidates(query, filters)?;
hydrate_matches(&candidates, |ids| self.get_issues_by_ids(ids))
}
pub(crate) fn search_unicode_issues_default_page(
&self,
query: &str,
filters: &ListFilters,
) -> Result<Vec<Issue>> {
if filters.reverse || !matches!(filters.sort.as_deref(), None | Some("priority")) {
return Err(BeadsError::Validation {
field: "sort".to_string(),
reason: "paged Unicode discovery requires default priority ordering".to_string(),
});
}
let mut candidate_filters = filters.clone();
candidate_filters.sort = None;
let candidates = self.unicode_search_candidates_in_window(
query,
&candidate_filters,
filters.offset.unwrap_or(0),
filters.limit.unwrap_or(0),
)?;
hydrate_matches(&candidates, |ids| self.get_issues_by_ids(ids))
}
pub(crate) fn count_unicode_search_matches_unpaginated(
&self,
query: &str,
filters: &ListFilters,
) -> Result<usize> {
Ok(self.unicode_search_candidates(query, filters)?.len())
}
fn unicode_search_candidates(&self, query: &str, filters: &ListFilters) -> Result<Vec<Issue>> {
self.unicode_search_candidates_in_window(query, filters, 0, 0)
}
fn unicode_search_candidates_in_window(
&self,
query: &str,
filters: &ListFilters,
offset: usize,
limit: usize,
) -> Result<Vec<Issue>> {
let query = query.trim();
if query.is_empty() {
return Err(BeadsError::Validation {
field: "query".to_string(),
reason: "search query cannot be empty".to_string(),
});
}
let matcher = RegexBuilder::new(®ex::escape(query))
.case_insensitive(true)
.build()
.map_err(|error| BeadsError::Validation {
field: "query".to_string(),
reason: format!("cannot compile Unicode search query: {error}"),
})?;
let mut filters = filters.clone();
filters.limit = None;
filters.offset = None;
let candidates = self.list_lint_issues_for_command_output(&filters)?;
select_matching_window(candidates, &matcher, offset, limit, |ids| {
self.get_comments_for_issues(ids)
})
}
}
fn select_matching_window(
candidates: Vec<Issue>,
matcher: &Regex,
mut offset: usize,
limit: usize,
mut read_comments: impl FnMut(&[String]) -> Result<HashMap<String, Vec<Comment>>>,
) -> Result<Vec<Issue>> {
let mut candidates = candidates.into_iter();
let mut selected = Vec::new();
loop {
let batch = candidates
.by_ref()
.take(SEARCH_BATCH_SIZE)
.collect::<Vec<_>>();
if batch.is_empty() {
return Ok(selected);
}
let mut matched_ids = HashSet::new();
let mut comment_ids = Vec::new();
for issue in &batch {
if unicode_issue_fields_match(issue, matcher) {
matched_ids.insert(issue.id.clone());
} else {
comment_ids.push(issue.id.clone());
}
}
if !comment_ids.is_empty() {
for (id, comments) in read_comments(&comment_ids)? {
if !comment_ids.contains(&id) {
return Err(BeadsError::Internal {
message: "Unicode search returned comments for an unexpected issue"
.to_string(),
});
}
if comments
.iter()
.any(|comment| matcher.is_match(&comment.body))
{
matched_ids.insert(id);
}
}
}
for issue in batch {
if !matched_ids.contains(&issue.id) {
continue;
}
if offset > 0 {
offset -= 1;
continue;
}
selected.push(issue);
if limit > 0 && selected.len() == limit {
return Ok(selected);
}
}
}
}
pub fn unicode_issue_fields_match(issue: &Issue, matcher: &Regex) -> bool {
matcher.is_match(&issue.id)
|| matcher.is_match(&issue.title)
|| issue
.description
.as_deref()
.is_some_and(|description| matcher.is_match(description))
}
fn hydrate_matches(
candidates: &[Issue],
mut read: impl FnMut(&[String]) -> Result<Vec<Issue>>,
) -> Result<Vec<Issue>> {
let mut result = Vec::with_capacity(candidates.len());
for batch in candidates.chunks(SEARCH_BATCH_SIZE) {
let ids = batch
.iter()
.map(|issue| issue.id.clone())
.collect::<Vec<_>>();
let mut hydrated = HashMap::with_capacity(batch.len());
for issue in read(&ids)? {
if !ids.contains(&issue.id) {
return Err(BeadsError::Internal {
message: "Unicode search hydration returned an unexpected issue".to_string(),
});
}
if hydrated.insert(issue.id.clone(), issue).is_some() {
return Err(BeadsError::Internal {
message: "Unicode search hydration returned a duplicate issue".to_string(),
});
}
}
for candidate in batch {
let issue =
hydrated
.remove(&candidate.id)
.ok_or_else(|| BeadsError::IssueNotFound {
id: candidate.id.clone(),
})?;
if issue.title != candidate.title
|| issue.description != candidate.description
|| issue.status != candidate.status
|| issue.issue_type != candidate.issue_type
|| issue.created_at != candidate.created_at
|| issue.updated_at != candidate.updated_at
{
return Err(BeadsError::Internal {
message: format!(
"Issue {} changed during Unicode search; retry the search",
candidate.id
),
});
}
result.push(issue);
}
}
Ok(result)
}
#[cfg(test)]
#[path = "search_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "search_page_tests.rs"]
mod page_tests;