use crate::domain::action_resolver::ActionResolver;
use crate::domain::bookmark::Bookmark;
use crate::domain::error::DomainResult;
use crate::domain::repositories::repository::BookmarkRepository;
use crate::util::validation::ValidationHelper;
use std::sync::Arc;
use tracing::{debug, instrument};
pub trait ActionService: Send + Sync {
fn execute_default_action(&self, bookmark: &Bookmark) -> DomainResult<()>;
fn execute_default_action_by_id(&self, id: i32) -> DomainResult<()>;
fn execute_default_action_with_options(
&self,
bookmark: &Bookmark,
no_edit: bool,
script_args: &[String],
) -> DomainResult<()>;
fn get_default_action_description(&self, bookmark: &Bookmark) -> &'static str;
}
pub struct ActionServiceImpl<R: BookmarkRepository> {
resolver: Arc<dyn ActionResolver>,
repository: Arc<R>,
}
impl<R: BookmarkRepository> ActionServiceImpl<R> {
pub fn new(resolver: Arc<dyn ActionResolver>, repository: Arc<R>) -> Self {
Self {
resolver,
repository,
}
}
}
impl<R: BookmarkRepository> ActionService for ActionServiceImpl<R> {
#[instrument(skip(self, bookmark), level = "debug")]
fn execute_default_action(&self, bookmark: &Bookmark) -> DomainResult<()> {
if let Some(id) = bookmark.id {
debug!("Recording access for bookmark {}", id);
self.record_bookmark_access(id)?;
}
let action = self.resolver.resolve_action(bookmark);
debug!("Executing action: {}", action.description());
action.execute(bookmark)
}
#[instrument(skip(self), level = "debug")]
fn execute_default_action_by_id(&self, id: i32) -> DomainResult<()> {
let bookmark = ValidationHelper::validate_and_get_bookmark_domain(id, &*self.repository)?;
self.execute_default_action(&bookmark)
}
#[instrument(skip(self, bookmark), level = "debug")]
fn execute_default_action_with_options(
&self,
bookmark: &Bookmark,
no_edit: bool,
script_args: &[String],
) -> DomainResult<()> {
use crate::application::actions::shell_action::ShellAction;
use crate::domain::action::BookmarkAction;
use crate::domain::system_tag::SystemTag;
use crate::infrastructure::interpolation::minijinja_engine::{
MiniJinjaEngine, SafeShellExecutor,
};
use std::sync::Arc;
if let Some(id) = bookmark.id {
debug!("Recording access for bookmark {}", id);
self.record_bookmark_access(id)?;
}
if no_edit && bookmark.tags.contains(&SystemTag::Shell.to_tag()?) {
debug!("Executing shell action with no-edit mode");
let shell_executor = Arc::new(SafeShellExecutor::new());
let template_engine = Arc::new(MiniJinjaEngine::new(shell_executor));
let interpolation_service = Arc::new(
crate::application::InterpolationServiceImpl::new(template_engine),
);
let shell_action =
ShellAction::new_direct_with_args(interpolation_service, script_args.to_vec());
return shell_action.execute(bookmark);
}
let action = self.resolver.resolve_action(bookmark);
debug!("Executing action: {}", action.description());
action.execute(bookmark) }
fn get_default_action_description(&self, bookmark: &Bookmark) -> &'static str {
let action = self.resolver.resolve_action(bookmark);
action.description()
}
}
impl<R: BookmarkRepository> ActionServiceImpl<R> {
#[instrument(skip(self), level = "trace")]
fn record_bookmark_access(&self, id: i32) -> DomainResult<()> {
let mut bookmark =
ValidationHelper::validate_and_get_bookmark_domain(id, &*self.repository)?;
bookmark.record_access();
self.repository.update_access(&bookmark)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::action::BookmarkAction;
use crate::domain::action_resolver::ActionResolver;
use crate::domain::bookmark::Bookmark;
use crate::domain::tag::Tag;
use crate::infrastructure::repositories::sqlite::repository::SqliteBookmarkRepository;
use crate::util::testing::{init_test_env, EnvGuard};
use std::collections::HashSet;
use std::sync::Arc;
#[derive(Debug)]
struct MockAction {
description: &'static str,
executed: Arc<std::sync::Mutex<bool>>,
}
impl MockAction {
fn new(description: &'static str) -> Self {
Self {
description,
executed: Arc::new(std::sync::Mutex::new(false)),
}
}
}
impl BookmarkAction for MockAction {
fn execute(&self, _bookmark: &Bookmark) -> DomainResult<()> {
*self.executed.lock().unwrap() = true;
Ok(())
}
fn description(&self) -> &'static str {
self.description
}
}
#[derive(Debug)]
struct MockActionResolver {
action: Arc<MockAction>,
}
impl MockActionResolver {
fn new(action: Arc<MockAction>) -> Self {
Self { action }
}
}
impl ActionResolver for MockActionResolver {
fn resolve_action(&self, _bookmark: &Bookmark) -> Box<dyn BookmarkAction> {
Box::new(MockAction::new(self.action.description))
}
}
fn create_test_repository() -> Arc<SqliteBookmarkRepository> {
let db_url = ":memory:".to_string();
let repository =
SqliteBookmarkRepository::from_url(&db_url).expect("Could not create test repository");
Arc::new(repository)
}
fn create_test_bookmark_with_shell_tag() -> Bookmark {
let mut tags = HashSet::new();
tags.insert(Tag::new("_shell_").unwrap());
Bookmark {
id: Some(1),
url: "echo 'test script'".to_string(),
title: "Test Shell Script".to_string(),
description: "".to_string(),
tags,
access_count: 0,
created_at: Some(chrono::Utc::now()),
updated_at: chrono::Utc::now(),
embedding: None,
content_hash: None,
embeddable: false,
file_path: None,
file_mtime: None,
file_hash: None,
opener: None,
accessed_at: None,
}
}
fn create_test_bookmark_without_shell_tag() -> Bookmark {
let tags = HashSet::new();
Bookmark {
id: Some(2),
url: "https://example.com".to_string(),
title: "Test URL".to_string(),
description: "".to_string(),
tags,
access_count: 0,
created_at: Some(chrono::Utc::now()),
updated_at: chrono::Utc::now(),
embedding: None,
content_hash: None,
embeddable: false,
file_path: None,
file_mtime: None,
file_hash: None,
opener: None,
accessed_at: None,
}
}
#[test]
fn given_shell_bookmark_when_execute_default_action_with_no_edit_then_uses_direct_shell_action()
{
let _env = init_test_env();
let _guard = EnvGuard::new();
let repository = create_test_repository();
let mock_action = Arc::new(MockAction::new("Mock action"));
let resolver = Arc::new(MockActionResolver::new(Arc::clone(&mock_action)));
let service = ActionServiceImpl::new(resolver, Arc::clone(&repository));
let bookmark = create_test_bookmark_with_shell_tag();
let mut bookmark_copy = bookmark.clone();
repository.add(&mut bookmark_copy).unwrap();
let stored_bookmark = repository.get_by_id(1).unwrap().unwrap();
let result = service.execute_default_action_with_options(&stored_bookmark, true, &[]);
assert!(result.is_ok(), "Should execute successfully with no-edit");
let updated_bookmark = repository.get_by_id(1).unwrap().unwrap();
assert_eq!(
updated_bookmark.access_count, 1,
"Access count should be incremented"
);
}
#[test]
fn given_non_shell_bookmark_when_execute_default_action_with_no_edit_then_uses_normal_resolver()
{
let _env = init_test_env();
let _guard = EnvGuard::new();
let repository = create_test_repository();
let mock_action = Arc::new(MockAction::new("Mock action"));
let resolver = Arc::new(MockActionResolver::new(Arc::clone(&mock_action)));
let service = ActionServiceImpl::new(resolver, Arc::clone(&repository));
let bookmark = create_test_bookmark_without_shell_tag();
let mut bookmark_copy = bookmark.clone();
repository.add(&mut bookmark_copy).unwrap();
let bookmark_id = bookmark_copy.id.unwrap();
let stored_bookmark = repository.get_by_id(bookmark_id).unwrap().unwrap();
let result = service.execute_default_action_with_options(&stored_bookmark, true, &[]);
assert!(result.is_ok(), "Should execute successfully");
let updated_bookmark = repository.get_by_id(bookmark_id).unwrap().unwrap();
assert_eq!(
updated_bookmark.access_count, 1,
"Access count should be incremented"
);
}
#[test]
fn given_shell_bookmark_when_execute_default_action_with_no_edit_false_then_uses_normal_resolver(
) {
let _env = init_test_env();
let _guard = EnvGuard::new();
let repository = create_test_repository();
let mock_action = Arc::new(MockAction::new("Mock action"));
let resolver = Arc::new(MockActionResolver::new(Arc::clone(&mock_action)));
let service = ActionServiceImpl::new(resolver, Arc::clone(&repository));
let bookmark = create_test_bookmark_with_shell_tag();
let mut bookmark_copy = bookmark.clone();
repository.add(&mut bookmark_copy).unwrap();
let stored_bookmark = repository.get_by_id(1).unwrap().unwrap();
let result = service.execute_default_action_with_options(&stored_bookmark, false, &[]);
assert!(
result.is_ok(),
"Should execute successfully without no-edit"
);
let updated_bookmark = repository.get_by_id(1).unwrap().unwrap();
assert_eq!(
updated_bookmark.access_count, 1,
"Access count should be incremented"
);
}
#[test]
fn given_bookmark_without_id_when_execute_default_action_with_options_then_still_executes() {
let _env = init_test_env();
let _guard = EnvGuard::new();
let repository = create_test_repository();
let mock_action = Arc::new(MockAction::new("Mock action"));
let resolver = Arc::new(MockActionResolver::new(Arc::clone(&mock_action)));
let service = ActionServiceImpl::new(resolver, Arc::clone(&repository));
let mut bookmark = create_test_bookmark_with_shell_tag();
bookmark.id = None;
let result = service.execute_default_action_with_options(&bookmark, true, &[]);
assert!(
result.is_ok(),
"Should execute successfully even without bookmark ID"
);
}
}