use crate::domain::bookmark::Bookmark;
use crate::domain::tag::Tag;
use std::collections::HashSet;
use std::marker::PhantomData;
pub trait Specification<T>: std::fmt::Debug {
fn is_satisfied_by(&self, entity: &T) -> bool;
}
impl<T> Specification<T> for Box<dyn Specification<T>> {
fn is_satisfied_by(&self, entity: &T) -> bool {
self.as_ref().is_satisfied_by(entity) }
}
#[derive(Debug)]
pub struct AndSpecification<T, A, B>
where
T: std::fmt::Debug,
A: Specification<T>,
B: Specification<T>,
{
spec_a: A,
spec_b: B,
_marker: PhantomData<T>,
}
impl<T, A, B> AndSpecification<T, A, B>
where
T: std::fmt::Debug,
A: Specification<T>,
B: Specification<T>,
{
pub fn new(spec_a: A, spec_b: B) -> Self {
Self {
spec_a,
spec_b,
_marker: PhantomData,
}
}
}
impl<T, A, B> Specification<T> for AndSpecification<T, A, B>
where
T: std::fmt::Debug,
A: Specification<T>,
B: Specification<T>,
{
fn is_satisfied_by(&self, entity: &T) -> bool {
self.spec_a.is_satisfied_by(entity) && self.spec_b.is_satisfied_by(entity)
}
}
#[derive(Debug)]
pub struct OrSpecification<T, A, B>
where
T: std::fmt::Debug,
A: Specification<T>,
B: Specification<T>,
{
spec_a: A,
spec_b: B,
_marker: PhantomData<T>,
}
impl<T, A, B> OrSpecification<T, A, B>
where
T: std::fmt::Debug,
A: Specification<T>,
B: Specification<T>,
{
pub fn new(spec_a: A, spec_b: B) -> Self {
Self {
spec_a,
spec_b,
_marker: PhantomData,
}
}
}
impl<T, A, B> Specification<T> for OrSpecification<T, A, B>
where
T: std::fmt::Debug,
A: Specification<T>,
B: Specification<T>,
{
fn is_satisfied_by(&self, entity: &T) -> bool {
self.spec_a.is_satisfied_by(entity) || self.spec_b.is_satisfied_by(entity)
}
}
#[derive(Debug)]
pub struct NotSpecification<T, S>
where
T: std::fmt::Debug,
S: Specification<T>,
{
spec: S,
_marker: PhantomData<T>,
}
impl<T, S> NotSpecification<T, S>
where
T: std::fmt::Debug,
S: Specification<T>,
{
pub fn new(spec: S) -> Self {
Self {
spec,
_marker: PhantomData,
}
}
}
impl<T, S> Specification<T> for NotSpecification<T, S>
where
T: std::fmt::Debug,
S: Specification<T>,
{
fn is_satisfied_by(&self, entity: &T) -> bool {
!self.spec.is_satisfied_by(entity)
}
}
#[derive(Debug)]
pub struct AllTagsSpecification {
tags: HashSet<Tag>,
}
impl AllTagsSpecification {
pub fn new(tags: HashSet<Tag>) -> Self {
Self { tags }
}
}
impl Specification<Bookmark> for AllTagsSpecification {
fn is_satisfied_by(&self, bookmark: &Bookmark) -> bool {
bookmark.matches_all_tags(&self.tags)
}
}
#[derive(Debug)]
pub struct AnyTagSpecification {
tags: HashSet<Tag>,
}
impl AnyTagSpecification {
pub fn new(tags: HashSet<Tag>) -> Self {
Self { tags }
}
}
impl Specification<Bookmark> for AnyTagSpecification {
fn is_satisfied_by(&self, bookmark: &Bookmark) -> bool {
bookmark.matches_any_tag(&self.tags)
}
}
#[derive(Debug)]
pub struct ExactTagsSpecification {
tags: HashSet<Tag>,
}
impl ExactTagsSpecification {
pub fn new(tags: HashSet<Tag>) -> Self {
Self { tags }
}
}
impl Specification<Bookmark> for ExactTagsSpecification {
fn is_satisfied_by(&self, bookmark: &Bookmark) -> bool {
bookmark.matches_exact_tags(&self.tags)
}
}
#[derive(Debug)]
pub struct TextSearchSpecification {
query: String,
}
impl TextSearchSpecification {
pub fn new(query: String) -> Self {
Self { query }
}
}
impl Specification<Bookmark> for TextSearchSpecification {
fn is_satisfied_by(&self, bookmark: &Bookmark) -> bool {
if self.query.is_empty() {
return true;
}
let query = self.query.to_lowercase();
let content = format!(
"{} {} {}",
bookmark.title.to_lowercase(),
bookmark.description.to_lowercase(),
bookmark
.tags
.iter()
.map(|t| t.value().to_lowercase())
.collect::<Vec<_>>()
.join(" ")
);
content.contains(&query)
}
}
pub trait SpecificationExt<T: std::fmt::Debug>: Specification<T> {
fn and<S: Specification<T>>(self, other: S) -> AndSpecification<T, Self, S>
where
Self: Sized,
{
AndSpecification::new(self, other)
}
fn or<S: Specification<T>>(self, other: S) -> OrSpecification<T, Self, S>
where
Self: Sized,
{
OrSpecification::new(self, other)
}
fn not(self) -> NotSpecification<T, Self>
where
Self: Sized,
{
NotSpecification::new(self)
}
}
impl<T, S> SpecificationExt<T> for S
where
S: Specification<T>,
T: std::fmt::Debug,
{
}
#[derive(Debug)]
pub struct BookmarkQuery {
pub specification: Option<Box<dyn Specification<Bookmark>>>,
pub sort_by_date: Option<SortDirection>,
pub limit: Option<usize>,
pub offset: Option<usize>,
pub text_query: Option<String>,
pub tags_exact: Option<HashSet<Tag>>,
pub tags_all: Option<HashSet<Tag>>,
pub tags_all_not: Option<HashSet<Tag>>,
pub tags_any: Option<HashSet<Tag>>,
pub tags_any_not: Option<HashSet<Tag>>,
pub tags_prefix: Option<HashSet<Tag>>,
}
impl BookmarkQuery {
pub fn new() -> Self {
Self {
specification: None,
sort_by_date: None,
limit: None,
offset: None,
text_query: None,
tags_exact: None,
tags_all: None,
tags_all_not: None,
tags_any: None,
tags_any_not: None,
tags_prefix: None,
}
}
pub fn with_text_query(mut self, query: Option<&str>) -> Self {
self.text_query = query.map(|s| s.to_string());
self
}
pub fn with_tags_exact(mut self, tags: Option<&HashSet<Tag>>) -> Self {
self.tags_exact = tags.cloned();
self
}
pub fn with_tags_all(mut self, tags: Option<&HashSet<Tag>>) -> Self {
self.tags_all = tags.cloned();
self
}
pub fn with_tags_all_not(mut self, tags: Option<&HashSet<Tag>>) -> Self {
self.tags_all_not = tags.cloned();
self
}
pub fn with_tags_any(mut self, tags: Option<&HashSet<Tag>>) -> Self {
self.tags_any = tags.cloned();
self
}
pub fn with_tags_any_not(mut self, tags: Option<&HashSet<Tag>>) -> Self {
self.tags_any_not = tags.cloned();
self
}
pub fn with_tags_prefix(mut self, tags: Option<&HashSet<Tag>>) -> Self {
self.tags_prefix = tags.cloned();
self
}
pub fn with_specification<S>(mut self, spec: S) -> Self
where
S: Specification<Bookmark> + 'static,
{
self.specification = Some(Box::new(spec));
self
}
pub fn with_specification_boxed(mut self, spec: Box<dyn Specification<Bookmark>>) -> Self {
self.specification = Some(spec);
self
}
pub fn with_sort_by_date(mut self, direction: SortDirection) -> Self {
self.sort_by_date = Some(direction);
self
}
pub fn with_limit(mut self, limit: Option<usize>) -> Self {
self.limit = limit;
self
}
pub fn with_offset(mut self, offset: usize) -> Self {
self.offset = Some(offset);
self
}
pub fn matches(&self, bookmark: &Bookmark) -> bool {
match &self.specification {
Some(spec) => spec.is_satisfied_by(bookmark),
None => true,
}
}
pub fn apply_non_text_filters(&self, bookmarks: &[Bookmark]) -> Vec<Bookmark> {
let mut filtered = bookmarks.to_vec();
if let Some(spec) = &self.specification {
filtered.retain(|bookmark| spec.is_satisfied_by(bookmark));
}
if let Some(tags) = &self.tags_exact {
if !tags.is_empty() {
filtered.retain(|bookmark| bookmark.matches_exact_tags(tags));
}
}
if let Some(tags) = &self.tags_all {
if !tags.is_empty() {
filtered.retain(|bookmark| bookmark.matches_all_tags(tags));
}
}
if let Some(tags) = &self.tags_all_not {
if !tags.is_empty() {
filtered.retain(|bookmark| !bookmark.matches_all_tags(tags));
}
}
if let Some(tags) = &self.tags_any {
if !tags.is_empty() {
filtered.retain(|bookmark| bookmark.matches_any_tag(tags));
}
}
if let Some(tags) = &self.tags_any_not {
if !tags.is_empty() {
filtered.retain(|bookmark| !bookmark.matches_any_tag(tags));
}
}
if let Some(prefixes) = &self.tags_prefix {
if !prefixes.is_empty() {
filtered.retain(|bookmark| {
prefixes.iter().any(|prefix| {
let prefix_str = prefix.value();
bookmark
.tags
.iter()
.any(|tag| tag.value().starts_with(prefix_str))
})
});
}
}
if let Some(direction) = &self.sort_by_date {
match direction {
SortDirection::Ascending => {
filtered.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
}
SortDirection::Descending => {
filtered.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
}
}
} else {
filtered.sort_by(|a, b| a.id.unwrap_or(0).cmp(&b.id.unwrap_or(0)));
}
if let Some(offset) = self.offset {
if offset < filtered.len() {
filtered = filtered.into_iter().skip(offset).collect();
} else {
filtered.clear();
}
}
if let Some(limit) = self.limit {
filtered.truncate(limit);
}
filtered
}
}
impl Default for BookmarkQuery {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDirection {
Ascending,
Descending,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::testing::init_test_env;
#[test]
fn given_multiple_specifications_when_and_then_combines_with_logical_and() {
let _ = init_test_env();
let spec1 = TextSearchSpecification::new("rust".to_string());
let spec2 = TextSearchSpecification::new("programming".to_string());
let and_spec = spec1.and(spec2);
let tags = HashSet::new();
let matching_bookmark = Bookmark::new(
"https://example.com",
"Rust Programming",
"Learn Rust programming",
tags.clone(),
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
let partial_bookmark = Bookmark::new(
"https://example.com",
"Rust",
"Learn Rust",
tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
assert!(and_spec.is_satisfied_by(&matching_bookmark));
assert!(!and_spec.is_satisfied_by(&partial_bookmark));
}
#[test]
fn given_multiple_specifications_when_or_then_combines_with_logical_or() {
let _ = init_test_env();
let spec1 = TextSearchSpecification::new("rust".to_string());
let spec2 = TextSearchSpecification::new("python".to_string());
let or_spec = spec1.or(spec2);
let tags = HashSet::new();
let rust_bookmark = Bookmark::new(
"https://example.com",
"Rust",
"Learn Rust",
tags.clone(),
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
let python_bookmark = Bookmark::new(
"https://example.com",
"Python",
"Learn Python",
tags.clone(),
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
let other_bookmark = Bookmark::new(
"https://example.com",
"JavaScript",
"Learn JavaScript",
tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
assert!(or_spec.is_satisfied_by(&rust_bookmark));
assert!(or_spec.is_satisfied_by(&python_bookmark));
assert!(!or_spec.is_satisfied_by(&other_bookmark));
}
#[test]
fn given_specification_when_not_then_inverts_logic() {
let _ = init_test_env();
let spec = TextSearchSpecification::new("rust".to_string());
let not_spec = spec.not();
let tags = HashSet::new();
let rust_bookmark = Bookmark::new(
"https://example.com",
"Rust",
"Learn Rust",
tags.clone(),
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
let other_bookmark = Bookmark::new(
"https://example.com",
"Python",
"Learn Python",
tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
assert!(!not_spec.is_satisfied_by(&rust_bookmark));
assert!(not_spec.is_satisfied_by(&other_bookmark));
}
#[test]
fn given_complex_nested_specifications_when_evaluate_then_applies_correct_logic() {
let _ = init_test_env();
let rust_tag = Tag::new("rust").unwrap();
let programming_tag = Tag::new("programming").unwrap();
let mut rust_tags = HashSet::new();
rust_tags.insert(rust_tag.clone());
let mut programming_tags = HashSet::new();
programming_tags.insert(programming_tag.clone());
let has_rust_tag = AllTagsSpecification::new(rust_tags);
let has_programming_tag = AllTagsSpecification::new(programming_tags);
let about_web = TextSearchSpecification::new("web".to_string());
let complex_spec = has_rust_tag.or(has_programming_tag).and(about_web);
let mut rust_web_tags = HashSet::new();
rust_web_tags.insert(rust_tag.clone());
let rust_web_bookmark = Bookmark::new(
"https://example.com",
"Rust Web",
"Web development with Rust",
rust_web_tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
let mut programming_web_tags = HashSet::new();
programming_web_tags.insert(programming_tag.clone());
let programming_web_bookmark = Bookmark::new(
"https://example.com",
"Web Programming",
"Web development programming",
programming_web_tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
let mut rust_tags = HashSet::new();
rust_tags.insert(rust_tag.clone());
let rust_bookmark = Bookmark::new(
"https://example.com",
"Rust",
"Learn Rust",
rust_tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
assert!(complex_spec.is_satisfied_by(&rust_web_bookmark));
assert!(complex_spec.is_satisfied_by(&programming_web_bookmark));
assert!(!complex_spec.is_satisfied_by(&rust_bookmark));
}
#[test]
fn given_query_builder_when_build_then_creates_valid_query() {
let _ = init_test_env();
let tags = HashSet::new();
let bookmark = Bookmark::new(
"https://example.com",
"Rust Programming",
"Learn Rust programming",
tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
let query = BookmarkQuery::new()
.with_specification(TextSearchSpecification::new("rust".to_string()))
.with_sort_by_date(SortDirection::Descending)
.with_limit(Some(10));
assert!(query.matches(&bookmark));
assert_eq!(query.sort_by_date, Some(SortDirection::Descending));
assert_eq!(query.limit, Some(10));
}
#[test]
fn given_non_text_filters_when_apply_then_filters_bookmarks() {
let _ = init_test_env();
let embedder = &crate::infrastructure::embeddings::DummyEmbedding;
let now = chrono::Utc::now();
let one_day_ago = now - chrono::Duration::days(1);
let two_days_ago = now - chrono::Duration::days(2);
let mut tags1 = HashSet::new();
tags1.insert(Tag::new("rust").unwrap());
tags1.insert(Tag::new("programming").unwrap());
let mut bookmark1 = Bookmark::new(
"https://example.com/rust",
"Rust Programming",
"Learn Rust programming",
tags1,
embedder,
)
.unwrap();
bookmark1.id = Some(1);
bookmark1.updated_at = now;
let mut tags2 = HashSet::new();
tags2.insert(Tag::new("python").unwrap());
tags2.insert(Tag::new("programming").unwrap());
tags2.insert(Tag::new("web").unwrap());
let mut bookmark2 = Bookmark::new(
"https://example.com/python",
"Python Web Development",
"Learn Python web development",
tags2,
embedder,
)
.unwrap();
bookmark2.id = Some(2);
bookmark2.updated_at = one_day_ago;
let mut tags3 = HashSet::new();
tags3.insert(Tag::new("java").unwrap());
tags3.insert(Tag::new("enterprise").unwrap());
let mut bookmark3 = Bookmark::new(
"https://example.com/java",
"Java Enterprise",
"Enterprise Java development",
tags3,
embedder,
)
.unwrap();
bookmark3.id = Some(3);
bookmark3.updated_at = two_days_ago;
let bookmarks = vec![bookmark1.clone(), bookmark2.clone(), bookmark3.clone()];
let mut exact_tags = HashSet::new();
exact_tags.insert(Tag::new("rust").unwrap());
exact_tags.insert(Tag::new("programming").unwrap());
let query1 = BookmarkQuery::new().with_tags_exact(Some(&exact_tags));
let results1 = query1.apply_non_text_filters(&bookmarks);
assert_eq!(results1.len(), 1);
assert_eq!(results1[0].id, Some(1));
let mut all_tags = HashSet::new();
all_tags.insert(Tag::new("programming").unwrap());
let query2 = BookmarkQuery::new().with_tags_all(Some(&all_tags));
let results2 = query2.apply_non_text_filters(&bookmarks);
assert_eq!(results2.len(), 2);
assert!(results2.iter().any(|b| b.id == Some(1)));
assert!(results2.iter().any(|b| b.id == Some(2)));
let mut any_tags = HashSet::new();
any_tags.insert(Tag::new("enterprise").unwrap());
any_tags.insert(Tag::new("rust").unwrap());
let query3 = BookmarkQuery::new().with_tags_any(Some(&any_tags));
let results3 = query3.apply_non_text_filters(&bookmarks);
assert_eq!(results3.len(), 2);
assert!(results3.iter().any(|b| b.id == Some(1)));
assert!(results3.iter().any(|b| b.id == Some(3)));
let mut all_not_tags = HashSet::new();
all_not_tags.insert(Tag::new("programming").unwrap());
let query4 = BookmarkQuery::new().with_tags_all_not(Some(&all_not_tags));
let results4 = query4.apply_non_text_filters(&bookmarks);
assert_eq!(results4.len(), 1);
assert_eq!(results4[0].id, Some(3));
let mut any_not_tags = HashSet::new();
any_not_tags.insert(Tag::new("web").unwrap());
any_not_tags.insert(Tag::new("enterprise").unwrap());
let query5 = BookmarkQuery::new().with_tags_any_not(Some(&any_not_tags));
let results5 = query5.apply_non_text_filters(&bookmarks);
assert_eq!(results5.len(), 1);
assert_eq!(results5[0].id, Some(1));
let mut prefix_tags = HashSet::new();
prefix_tags.insert(Tag::new("pro").unwrap());
let query6 = BookmarkQuery::new().with_tags_prefix(Some(&prefix_tags));
let results6 = query6.apply_non_text_filters(&bookmarks);
assert_eq!(results6.len(), 2);
assert!(results6.iter().any(|b| b.id == Some(1)));
assert!(results6.iter().any(|b| b.id == Some(2)));
let query7 = BookmarkQuery::new().with_sort_by_date(SortDirection::Ascending);
let results7 = query7.apply_non_text_filters(&bookmarks);
assert_eq!(results7.len(), 3);
assert_eq!(results7[0].id, Some(3)); assert_eq!(results7[1].id, Some(2));
assert_eq!(results7[2].id, Some(1));
let query8 = BookmarkQuery::new().with_sort_by_date(SortDirection::Descending);
let results8 = query8.apply_non_text_filters(&bookmarks);
assert_eq!(results8.len(), 3);
assert_eq!(results8[0].id, Some(1)); assert_eq!(results8[1].id, Some(2));
assert_eq!(results8[2].id, Some(3));
let query9 = BookmarkQuery::new().with_limit(Some(2));
let results9 = query9.apply_non_text_filters(&bookmarks);
assert_eq!(results9.len(), 2);
let query10 = BookmarkQuery::new().with_offset(1);
let results10 = query10.apply_non_text_filters(&bookmarks);
assert_eq!(results10.len(), 2);
assert!(results10.iter().any(|b| b.id == Some(2)));
assert!(results10.iter().any(|b| b.id == Some(3)));
let combined_query = BookmarkQuery::new()
.with_tags_all(Some(&all_tags))
.with_tags_any_not(Some(&any_not_tags))
.with_sort_by_date(SortDirection::Descending)
.with_limit(Some(1));
let combined_results = combined_query.apply_non_text_filters(&bookmarks);
assert_eq!(combined_results.len(), 1);
assert_eq!(combined_results[0].id, Some(1));
let empty_tags = HashSet::new();
let query12 = BookmarkQuery::new().with_tags_all(Some(&empty_tags));
let results12 = query12.apply_non_text_filters(&bookmarks);
assert_eq!(
results12.len(),
3,
"Empty tag set should not filter anything"
);
let spec = TextSearchSpecification::new("rust".to_string());
let query13 = BookmarkQuery::new().with_specification(spec);
let results13 = query13.apply_non_text_filters(&bookmarks);
assert_eq!(results13.len(), 1);
assert_eq!(results13[0].id, Some(1));
}
#[test]
fn given_empty_bookmarks_when_apply_non_text_filters_then_returns_empty() {
let _ = init_test_env();
let bookmarks: Vec<Bookmark> = Vec::new();
let mut tags = HashSet::new();
tags.insert(Tag::new("test").unwrap());
let query = BookmarkQuery::new()
.with_tags_all(Some(&tags))
.with_sort_by_date(SortDirection::Descending)
.with_limit(Some(10));
let results = query.apply_non_text_filters(&bookmarks);
assert!(
results.is_empty(),
"Filtering empty collection should return empty results"
);
}
#[test]
fn given_boxed_specification_when_evaluate_then_works_correctly() {
let _ = init_test_env();
let tags = HashSet::new();
let embedder = &crate::infrastructure::embeddings::DummyEmbedding;
let bookmark = Bookmark::new(
"https://example.com",
"Test Bookmark",
"This is a test",
tags,
embedder,
)
.unwrap();
let spec = TextSearchSpecification::new("test".to_string());
let boxed_spec: Box<dyn Specification<Bookmark>> = Box::new(spec);
let query = BookmarkQuery::new().with_specification_boxed(boxed_spec);
assert!(query.matches(&bookmark), "Boxed specification should match");
let non_matching_spec = TextSearchSpecification::new("nonexistent".to_string());
let boxed_non_matching: Box<dyn Specification<Bookmark>> = Box::new(non_matching_spec);
let non_matching_query = BookmarkQuery::new().with_specification_boxed(boxed_non_matching);
assert!(
!non_matching_query.matches(&bookmark),
"Non-matching boxed specification should not match"
);
}
}