use crate::domain::embedding::{serialize_embedding, Embedder};
use crate::domain::error::{DomainError, DomainResult};
use crate::domain::system_tag::SystemTag;
use crate::domain::tag::Tag;
use crate::util::helper::calc_content_hash;
use chrono::{DateTime, Utc};
use derive_builder::Builder;
use std::collections::HashSet;
use std::fmt;
#[derive(Builder, Clone, PartialEq)]
#[builder(setter(into))]
pub struct Bookmark {
pub id: Option<i32>,
pub url: String,
pub title: String,
pub description: String,
pub tags: HashSet<Tag>,
pub access_count: i32,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: DateTime<Utc>,
pub embedding: Option<Vec<u8>>,
pub content_hash: Option<Vec<u8>>,
#[builder(default = "false")]
pub embeddable: bool,
#[builder(default)]
pub file_path: Option<String>,
#[builder(default)]
pub file_mtime: Option<i32>,
#[builder(default)]
pub file_hash: Option<String>,
#[builder(default)]
pub opener: Option<String>,
#[builder(default)]
pub accessed_at: Option<DateTime<Utc>>,
}
impl Bookmark {
pub fn new<S: AsRef<str>>(
url: S,
title: S,
description: S,
tags: HashSet<Tag>,
embedder: &dyn Embedder,
) -> DomainResult<Self> {
let url_str = url.as_ref();
let now = Utc::now();
let mut bookmark = Self {
id: None,
url: url_str.to_string(),
title: title.as_ref().to_string(),
description: description.as_ref().to_string(),
tags,
access_count: 0,
created_at: Some(now),
updated_at: now,
embedding: None,
content_hash: None,
embeddable: false, file_path: None,
file_mtime: None,
file_hash: None,
opener: None,
accessed_at: None,
};
let content = bookmark.get_content_for_embedding();
let embedding_result = embedder
.embed(&content)?
.map(serialize_embedding)
.transpose()?;
if embedding_result.is_some() {
bookmark.embedding = embedding_result;
bookmark.content_hash = Some(calc_content_hash(&content));
}
Ok(bookmark)
}
pub fn from_storage(
id: i32,
url: String,
title: String,
description: String,
tag_string: String,
access_count: i32,
created_at: Option<DateTime<Utc>>,
updated_at: DateTime<Utc>,
embedding: Option<Vec<u8>>,
content_hash: Option<Vec<u8>>,
embeddable: bool,
file_path: Option<String>,
file_mtime: Option<i32>,
file_hash: Option<String>,
opener: Option<String>,
accessed_at: Option<DateTime<Utc>>,
) -> DomainResult<Self> {
let tags = Tag::parse_tags(tag_string)?;
Ok(Self {
id: Some(id),
url,
title,
description,
tags,
access_count,
created_at,
updated_at,
embedding,
content_hash,
embeddable,
file_path,
file_mtime,
file_hash,
opener,
accessed_at,
})
}
pub fn set_embeddable(&mut self, embeddable: bool) {
self.embeddable = embeddable;
self.updated_at = Utc::now();
}
pub fn add_tag(&mut self, tag: Tag) -> DomainResult<()> {
self.tags.insert(tag);
self.updated_at = Utc::now();
Ok(())
}
pub fn remove_tag(&mut self, tag: &Tag) -> DomainResult<()> {
if !self.tags.remove(tag) {
return Err(DomainError::TagOperationFailed(format!(
"Tag '{}' not found on bookmark",
tag
)));
}
self.updated_at = Utc::now();
Ok(())
}
pub fn set_tags(&mut self, tags: HashSet<Tag>) -> DomainResult<()> {
self.tags = tags;
self.updated_at = Utc::now();
Ok(())
}
pub fn record_access(&mut self) {
self.access_count += 1;
self.accessed_at = Some(Utc::now());
}
pub fn update(&mut self, title: String, description: String) {
self.title = title;
self.description = description;
self.updated_at = Utc::now();
}
pub fn formatted_tags(&self) -> String {
Tag::format_tags(&self.tags)
}
pub fn get_content_for_embedding(&self) -> String {
let visible_tags = self.get_visible_tags();
let tags_str = Tag::format_tags(&visible_tags);
format!(
"{}{} -- {}{}",
tags_str, self.title, self.description, tags_str
)
}
fn get_visible_tags(&self) -> HashSet<Tag> {
let visible_tags: HashSet<_> = self
.tags
.iter()
.filter(|tag| !tag.value().starts_with('_') && !tag.value().ends_with('_'))
.cloned()
.collect();
visible_tags
}
pub fn matches_all_tags(&self, tags: &HashSet<Tag>) -> bool {
Tag::contains_all(&self.tags, tags)
}
pub fn matches_any_tag(&self, tags: &HashSet<Tag>) -> bool {
Tag::contains_any(&self.tags, tags)
}
pub fn matches_exact_tags(&self, tags: &HashSet<Tag>) -> bool {
self.tags == *tags
}
pub fn set_id(&mut self, id: i32) {
self.id = Some(id);
}
pub fn has_interpolation(&self) -> bool {
self.url.contains("{{") || self.url.contains("{%")
}
pub fn snippet_content(&self) -> &str {
&self.url
}
pub fn add_system_tag(&mut self, system_tag: SystemTag) -> DomainResult<()> {
self.add_tag(system_tag.to_tag()?)
}
pub fn remove_system_tag(&mut self, system_tag: SystemTag) -> DomainResult<()> {
self.remove_tag(&system_tag.to_tag()?)
}
pub fn get_system_tags(&self) -> HashSet<Tag> {
self.tags
.iter()
.filter(|tag| {
let value = tag.value();
value.starts_with('_') && value.ends_with('_') && value.len() > 2
})
.cloned()
.collect()
}
pub fn get_tags(&self) -> HashSet<Tag> {
self.tags
.iter()
.filter(|tag| {
let value = tag.value();
!(value.starts_with('_') && value.ends_with('_') && value.len() > 2)
})
.cloned()
.collect()
}
pub fn is_system_tag(&self, system_tag: SystemTag) -> bool {
self.tags.iter().any(|tag| tag.is_system_tag_of(system_tag))
}
pub fn is_snippet(&self) -> bool {
self.tags
.iter()
.any(|tag| tag.is_system_tag_of(SystemTag::Snippet))
}
pub fn is_uri(&self) -> bool {
!self.is_snippet()
&& !self.is_system_tag(SystemTag::Text)
&& !self.is_system_tag(SystemTag::Shell)
&& !self.is_system_tag(SystemTag::Markdown)
&& !self.is_system_tag(SystemTag::Env)
}
pub fn is_shell(&self) -> bool {
self.tags
.iter()
.any(|tag| tag.is_system_tag_of(SystemTag::Shell))
}
pub fn is_markdown(&self) -> bool {
self.tags
.iter()
.any(|tag| tag.is_system_tag_of(SystemTag::Markdown))
}
pub fn is_env(&self) -> bool {
self.tags
.iter()
.any(|tag| tag.is_system_tag_of(SystemTag::Env))
}
pub fn get_action_content(&self) -> &str {
if self.is_snippet() {
self.snippet_content() } else {
&self.url }
}
}
impl fmt::Display for Bookmark {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"[{}] {}: {} ({})",
self.id.map_or("New".to_string(), |id| id.to_string()),
self.title,
self.url,
Tag::format_tags(&self.tags)
)
}
}
impl fmt::Debug for Bookmark {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Bookmark")
.field("id", &self.id)
.field("url", &self.url)
.field("title", &self.title)
.field("description", &self.description)
.field("tags", &self.tags)
.field("access_count", &self.access_count)
.field("created_at", &self.created_at)
.field("updated_at", &self.updated_at)
.field("embedding", &self.embedding.as_ref().map(|_| "[...]"))
.field("content_hash", &self.content_hash)
.field("embeddable", &self.embeddable)
.field("accessed_at", &self.accessed_at)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::testing::init_test_env;
#[test]
fn given_valid_bookmark_data_when_new_then_creates_bookmark() {
let _ = init_test_env();
let mut tags = HashSet::new();
tags.insert(Tag::new("test").unwrap());
let bookmark = Bookmark::new(
"https://example.com",
"Example Site",
"An example website",
tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
assert_eq!(bookmark.url, "https://example.com");
assert_eq!(bookmark.title, "Example Site");
assert_eq!(bookmark.description, "An example website");
assert_eq!(bookmark.tags.len(), 1);
assert!(bookmark.tags.contains(&Tag::new("test").unwrap()));
assert_eq!(bookmark.access_count, 0);
}
#[test]
fn given_special_urls_when_validate_then_accepts_as_valid() {
let _ = init_test_env();
let tags = HashSet::new();
let shell_url = Bookmark::new(
"shell::echo hello",
"Shell Command",
"A shell command",
tags.clone(),
&crate::infrastructure::embeddings::DummyEmbedding,
);
assert!(shell_url.is_ok());
let file_url = Bookmark::new(
"/path/to/file.txt",
"File Path",
"A file path",
tags.clone(),
&crate::infrastructure::embeddings::DummyEmbedding,
);
assert!(file_url.is_ok());
let home_url = Bookmark::new(
"~/documents/file.txt",
"Home Path",
"A path in home directory",
tags,
&crate::infrastructure::embeddings::DummyEmbedding,
);
assert!(home_url.is_ok());
}
#[test]
fn given_bookmark_when_add_remove_tags_then_updates_tag_set() {
let _ = init_test_env();
let mut tags = HashSet::new();
tags.insert(Tag::new("initial").unwrap());
let mut bookmark = Bookmark::new(
"https://example.com",
"Example Site",
"An example website",
tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
bookmark.add_tag(Tag::new("added").unwrap()).unwrap();
assert_eq!(bookmark.tags.len(), 2);
assert!(bookmark.tags.contains(&Tag::new("added").unwrap()));
bookmark.remove_tag(&Tag::new("initial").unwrap()).unwrap();
assert_eq!(bookmark.tags.len(), 1);
assert!(!bookmark.tags.contains(&Tag::new("initial").unwrap()));
let result = bookmark.remove_tag(&Tag::new("nonexistent").unwrap());
assert!(result.is_err());
}
#[test]
fn given_bookmark_when_set_tags_then_replaces_tag_set() {
let _ = init_test_env();
let mut tags = HashSet::new();
tags.insert(Tag::new("initial").unwrap());
let mut bookmark = Bookmark::new(
"https://example.com",
"Example Site",
"An example website",
tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
let mut new_tags = HashSet::new();
new_tags.insert(Tag::new("new1").unwrap());
new_tags.insert(Tag::new("new2").unwrap());
bookmark.set_tags(new_tags.clone()).unwrap();
assert_eq!(bookmark.tags, new_tags);
assert_eq!(bookmark.tags.len(), 2);
}
#[test]
fn given_bookmark_when_record_access_then_increments_count_and_sets_accessed_at() {
let _ = init_test_env();
let mut tags = HashSet::new();
tags.insert(Tag::new("test").unwrap());
let mut bookmark = Bookmark::new(
"https://example.com",
"Example Site",
"An example website",
tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
assert_eq!(bookmark.access_count, 0);
assert!(bookmark.accessed_at.is_none());
let updated_at_before = bookmark.updated_at;
bookmark.record_access();
assert_eq!(bookmark.access_count, 1);
assert!(bookmark.accessed_at.is_some());
assert_eq!(bookmark.updated_at, updated_at_before, "record_access must not change updated_at");
bookmark.record_access();
assert_eq!(bookmark.access_count, 2);
assert_eq!(bookmark.updated_at, updated_at_before, "record_access must not change updated_at");
}
#[test]
fn given_bookmark_with_tags_when_format_then_returns_formatted_string() {
let _ = init_test_env();
let mut tags = HashSet::new();
tags.insert(Tag::new("tag1").unwrap());
tags.insert(Tag::new("tag2").unwrap());
let bookmark = Bookmark::new(
"https://example.com",
"Example Site",
"An example website",
tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
let formatted = bookmark.formatted_tags();
assert!(formatted == ",tag1,tag2," || formatted == ",tag2,tag1,");
}
#[test]
fn given_bookmark_when_get_embedding_content_then_returns_concatenated_text() {
let _ = init_test_env();
let mut tags = HashSet::new();
tags.insert(Tag::new("visible").unwrap());
tags.insert(Tag::new("_system").unwrap());
let bookmark = Bookmark::new(
"https://example.com",
"Example Site",
"An example website",
tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
let content = bookmark.get_content_for_embedding();
assert!(content.contains("visible"));
assert!(!content.contains("_system"));
assert!(content.contains("Example Site"));
assert!(content.contains("An example website"));
}
#[test]
fn given_bookmark_with_tags_when_match_then_validates_tag_presence() {
let _ = init_test_env();
let mut bookmark_tags = HashSet::new();
bookmark_tags.insert(Tag::new("tag1").unwrap());
bookmark_tags.insert(Tag::new("tag2").unwrap());
bookmark_tags.insert(Tag::new("tag3").unwrap());
let bookmark = Bookmark::new(
"https://example.com",
"Example Site",
"An example website",
bookmark_tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
let mut query_tags = HashSet::new();
query_tags.insert(Tag::new("tag1").unwrap());
query_tags.insert(Tag::new("tag2").unwrap());
assert!(bookmark.matches_all_tags(&query_tags));
query_tags.insert(Tag::new("tag4").unwrap());
assert!(!bookmark.matches_all_tags(&query_tags));
let mut query_tags = HashSet::new();
query_tags.insert(Tag::new("tag1").unwrap());
query_tags.insert(Tag::new("tag4").unwrap());
assert!(bookmark.matches_any_tag(&query_tags));
let mut query_tags = HashSet::new();
query_tags.insert(Tag::new("tag4").unwrap());
query_tags.insert(Tag::new("tag5").unwrap());
assert!(!bookmark.matches_any_tag(&query_tags));
let mut query_tags = HashSet::new();
query_tags.insert(Tag::new("tag1").unwrap());
query_tags.insert(Tag::new("tag2").unwrap());
query_tags.insert(Tag::new("tag3").unwrap());
assert!(bookmark.matches_exact_tags(&query_tags));
let mut query_tags = HashSet::new();
query_tags.insert(Tag::new("tag1").unwrap());
query_tags.insert(Tag::new("tag2").unwrap());
assert!(!bookmark.matches_exact_tags(&query_tags));
}
#[test]
fn given_bookmark_with_mixed_tags_when_get_system_tags_then_filters_system_only() {
let _ = init_test_env();
let mut tags = HashSet::new();
tags.insert(Tag::new("regular1").unwrap());
tags.insert(Tag::new("regular2").unwrap());
tags.insert(Tag::new("_partial").unwrap()); tags.insert(Tag::new("partial_").unwrap());
tags.insert(Tag::new("_system1_").unwrap());
tags.insert(Tag::new("_system2_").unwrap());
let bookmark = Bookmark::new(
"https://example.com",
"Example Site",
"An example website",
tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
let system_tags = bookmark.get_system_tags();
assert_eq!(system_tags.len(), 2);
assert!(system_tags.contains(&Tag::new("_system1_").unwrap()));
assert!(system_tags.contains(&Tag::new("_system2_").unwrap()));
assert!(!system_tags.contains(&Tag::new("_partial").unwrap()));
assert!(!system_tags.contains(&Tag::new("partial_").unwrap()));
}
#[test]
fn given_bookmark_with_mixed_tags_when_get_tags_then_filters_user_only() {
let _ = init_test_env();
let mut tags = HashSet::new();
tags.insert(Tag::new("regular1").unwrap());
tags.insert(Tag::new("regular2").unwrap());
tags.insert(Tag::new("_partial").unwrap()); tags.insert(Tag::new("partial_").unwrap());
tags.insert(Tag::new("_system1_").unwrap());
let bookmark = Bookmark::new(
"https://example.com",
"Example Site",
"An example website",
tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
let regular_tags = bookmark.get_tags();
assert_eq!(regular_tags.len(), 4);
assert!(regular_tags.contains(&Tag::new("regular1").unwrap()));
assert!(regular_tags.contains(&Tag::new("regular2").unwrap()));
assert!(regular_tags.contains(&Tag::new("_partial").unwrap()));
assert!(regular_tags.contains(&Tag::new("partial_").unwrap()));
assert!(!regular_tags.contains(&Tag::new("_system1_").unwrap()));
}
#[test]
fn given_bookmark_with_only_system_tags_when_get_tags_then_returns_empty() {
let _ = init_test_env();
let mut tags = HashSet::new();
tags.insert(Tag::new("_system1_").unwrap());
tags.insert(Tag::new("_system2_").unwrap());
let bookmark = Bookmark::new(
"https://example.com",
"Example Site",
"An example website",
tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
let regular_tags = bookmark.get_tags();
assert_eq!(regular_tags.len(), 0);
let system_tags = bookmark.get_system_tags();
assert_eq!(system_tags.len(), 2);
}
#[test]
fn given_bookmark_when_set_embeddable_then_updates_flag() {
let _ = init_test_env();
let mut tags = HashSet::new();
tags.insert(Tag::new("test").unwrap());
let mut bookmark = Bookmark::new(
"https://example.com",
"Example Site",
"An example website",
tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
assert!(!bookmark.embeddable);
bookmark.set_embeddable(true);
assert!(bookmark.embeddable);
bookmark.set_embeddable(false);
assert!(!bookmark.embeddable);
}
#[test]
fn given_tag_when_check_system_then_validates_system_status() {
let _ = init_test_env();
let mut tags = HashSet::new();
tags.insert(Tag::new("_imported_").unwrap());
let bookmark = Bookmark::new(
"https://example.com",
"Example Site",
"An example website",
tags,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
assert!(bookmark.is_system_tag(SystemTag::Text));
assert!(!bookmark.is_system_tag(SystemTag::Snippet));
}
#[test]
fn given_string_when_check_uri_then_validates_uri_format() {
let _ = init_test_env();
let tags_uri = HashSet::new();
let bookmark_uri = Bookmark::new(
"https://example.com",
"Example Site",
"A website with no system tags",
tags_uri,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
let mut tags_snippet = HashSet::new();
tags_snippet.insert(Tag::new("_snip_").unwrap());
let bookmark_snippet = Bookmark::new(
"print('Hello world')",
"Python Snippet",
"A Python code snippet",
tags_snippet,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
assert!(bookmark_uri.is_uri());
assert!(!bookmark_snippet.is_uri());
}
#[test]
fn given_bookmark_when_get_action_content_then_returns_appropriate_content() {
let _ = init_test_env();
let tags_uri = HashSet::new();
let bookmark_uri = Bookmark::new(
"https://example.com",
"Example Site",
"A website",
tags_uri,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
let mut tags_snippet = HashSet::new();
tags_snippet.insert(Tag::new("_snip_").unwrap());
let snippet_content = "print('Hello world')";
let bookmark_snippet = Bookmark::new(
snippet_content,
"Python Snippet",
"A Python code snippet",
tags_snippet,
&crate::infrastructure::embeddings::DummyEmbedding,
)
.unwrap();
assert_eq!(bookmark_uri.get_action_content(), "https://example.com");
assert_eq!(bookmark_snippet.get_action_content(), snippet_content);
}
}