use crate::domain::model::search::{SearchFilter, SearchHit};
pub trait SearchRepository {
fn search(&self, query: &str) -> anyhow::Result<Vec<SearchHit>>;
}
pub fn search_records(
repo: &dyn SearchRepository,
query: &str,
filter: &SearchFilter,
) -> anyhow::Result<Vec<SearchHit>> {
let hits = repo.search(query)?;
let filtered = hits.into_iter().filter(|h| match &filter.kind {
Some(k) => &h.kind == k,
None => true,
});
let hits: Vec<SearchHit> = match filter.limit {
Some(n) => filtered.take(n).collect(),
None => filtered.collect(),
};
Ok(hits)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::entity_ref::EntityRef;
use crate::domain::model::record_kind::RecordKind;
use crate::domain::model::title::Title;
#[test]
fn returns_all_hits_when_no_filter() {
scenario()
.given_hit(hit("ISSUE-0001", "issue", "Add login"))
.given_hit(hit("ADR-0001", "adr", "Use PostgreSQL"))
.when_search("login", SearchFilter::new())
.then_count(2);
}
#[test]
fn filter_by_kind_returns_only_matching_kind() {
scenario()
.given_hit(hit("ISSUE-0001", "issue", "Auth feature"))
.given_hit(hit("ADR-0001", "adr", "Auth architecture"))
.when_search("Auth", SearchFilter::new().kind("issue"))
.then_ids(&["ISSUE-0001"]);
}
#[test]
fn filter_by_kind_adr_excludes_issues() {
scenario()
.given_hit(hit("ISSUE-0001", "issue", "Auth feature"))
.given_hit(hit("ADR-0001", "adr", "Auth architecture"))
.when_search("Auth", SearchFilter::new().kind("adr"))
.then_ids(&["ADR-0001"]);
}
#[test]
fn limit_caps_results() {
scenario()
.given_hit(hit("ISSUE-0001", "issue", "Auth login"))
.given_hit(hit("ISSUE-0002", "issue", "Auth signup"))
.given_hit(hit("ISSUE-0003", "issue", "Auth logout"))
.when_search("Auth", SearchFilter::new().limit(2))
.then_count(2);
}
#[test]
fn empty_corpus_returns_empty() {
scenario()
.when_search("anything", SearchFilter::new())
.then_empty();
}
#[test]
fn kind_filter_with_no_match_returns_empty() {
scenario()
.given_hit(hit("ISSUE-0001", "issue", "Auth feature"))
.when_search("Auth", SearchFilter::new().kind("adr"))
.then_empty();
}
struct StubSearchRepo {
hits: Vec<SearchHit>,
}
impl StubSearchRepo {
fn with(hits: Vec<SearchHit>) -> Self {
Self { hits }
}
}
impl SearchRepository for StubSearchRepo {
fn search(&self, _query: &str) -> anyhow::Result<Vec<SearchHit>> {
Ok(self.hits.clone())
}
}
fn hit(id: &str, kind: &str, title: &str) -> SearchHit {
SearchHit {
id: EntityRef::new(id).unwrap(),
kind: RecordKind::new(kind).unwrap(),
title: Title::new(title).unwrap(),
excerpt: None,
}
}
struct Scenario {
hits: Vec<SearchHit>,
}
fn scenario() -> Scenario {
Scenario { hits: vec![] }
}
impl Scenario {
fn given_hit(mut self, h: SearchHit) -> Self {
self.hits.push(h);
self
}
fn when_search(self, query: &str, filter: SearchFilter) -> SearchOutcome {
let repo = StubSearchRepo::with(self.hits);
let result = search_records(&repo, query, &filter).unwrap();
SearchOutcome { result }
}
}
struct SearchOutcome {
result: Vec<SearchHit>,
}
impl SearchOutcome {
fn then_ids(self, expected: &[&str]) -> Self {
let actual: Vec<String> = self.result.iter().map(|h| h.id.to_string()).collect();
let expected: Vec<&str> = expected.to_vec();
assert_eq!(
actual, expected,
"expected ids {expected:?}, got {actual:?}"
);
self
}
fn then_empty(self) -> Self {
assert!(
self.result.is_empty(),
"expected empty result, got {} hits",
self.result.len()
);
self
}
fn then_count(self, n: usize) -> Self {
assert_eq!(
self.result.len(),
n,
"expected {n} hits, got {}",
self.result.len()
);
self
}
}
}