use crate::application::services::interpolation_service::InterpolationService;
use crate::domain::action::BookmarkAction;
use crate::domain::bookmark::Bookmark;
use crate::domain::error::{DomainError, DomainResult};
use crate::util::interpolation::InterpolationHelper;
use rustyline::{config::Configurer, error::ReadlineError, history::FileHistory, EditMode, Editor};
use std::io::Write;
use std::process::Command;
use std::sync::Arc;
use tracing::{debug, instrument};
#[derive(Debug)]
pub struct ShellAction {
interpolation_service: Arc<dyn InterpolationService>,
interactive: bool,
script_args: Vec<String>,
}
impl ShellAction {
pub fn new(interpolation_service: Arc<dyn InterpolationService>, interactive: bool) -> Self {
Self {
interpolation_service,
interactive,
script_args: Vec::new(),
}
}
#[allow(dead_code)]
pub fn new_direct(interpolation_service: Arc<dyn InterpolationService>) -> Self {
Self {
interpolation_service,
interactive: false, script_args: Vec::new(),
}
}
pub fn new_direct_with_args(
interpolation_service: Arc<dyn InterpolationService>,
script_args: Vec<String>,
) -> Self {
Self {
interpolation_service,
interactive: false, script_args,
}
}
fn interactive_execute(&self, script: &str) -> DomainResult<()> {
let mut rl = self.create_configured_editor()?;
match rl.readline_with_initial("", (script, "")) {
Ok(final_command) => {
if final_command.trim().is_empty() {
debug!("User provided empty command, skipping execution");
return Ok(());
}
let _ = rl.add_history_entry(&final_command);
if let Err(e) = rl.save_history(&self.get_history_file_path()) {
debug!("Failed to save history: {}", e);
}
debug!("Executing interactive command: {}", final_command);
self.execute_script(&final_command)
}
Err(ReadlineError::Interrupted) => {
debug!("User cancelled shell execution with Ctrl-C");
Ok(())
}
Err(e) => Err(DomainError::Other(format!("Readline error: {}", e))),
}
}
fn create_configured_editor(&self) -> DomainResult<Editor<(), FileHistory>> {
let mut rl = Editor::new()
.map_err(|e| DomainError::Other(format!("Failed to create readline editor: {}", e)))?;
rl.set_auto_add_history(true);
rl.set_history_ignore_space(true);
let _ = rl.set_history_ignore_dups(true);
rl.set_edit_mode(self.detect_edit_mode());
let history_file = self.get_history_file_path();
if let Err(e) = rl.load_history(&history_file) {
debug!("No existing history file or failed to load: {}", e);
}
Ok(rl)
}
fn detect_edit_mode(&self) -> EditMode {
if let Ok(shell) = std::env::var("SHELL") {
if shell.contains("zsh") {
if std::env::var("ZSH_VI_MODE").is_ok() {
return EditMode::Vi;
}
}
}
if let Ok(inputrc) = std::env::var("INPUTRC") {
if let Ok(content) = std::fs::read_to_string(&inputrc) {
if content.contains("set editing-mode vi") {
return EditMode::Vi;
}
}
}
if let Some(home_dir) = dirs::home_dir() {
let inputrc_path = home_dir.join(".inputrc");
if let Ok(content) = std::fs::read_to_string(&inputrc_path) {
if content.contains("set editing-mode vi") {
return EditMode::Vi;
}
}
}
if std::env::var("BASH_VI_MODE").is_ok() {
return EditMode::Vi;
}
EditMode::Emacs
}
fn get_history_file_path(&self) -> std::path::PathBuf {
if let Some(config_dir) = dirs::config_dir() {
let bkmr_dir = config_dir.join("bkmr");
std::fs::create_dir_all(&bkmr_dir).ok(); bkmr_dir.join("shell_history.txt")
} else {
std::env::temp_dir().join("bkmr_shell_history.txt")
}
}
fn execute_script(&self, script: &str) -> DomainResult<()> {
let mut temp_file = tempfile::NamedTempFile::new()
.map_err(|e| DomainError::Other(format!("Failed to create temporary file: {}", e)))?;
temp_file
.write_all(script.as_bytes())
.map_err(|e| DomainError::Other(format!("Failed to write to temporary file: {}", e)))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = temp_file
.as_file()
.metadata()
.map_err(|e| DomainError::Other(format!("Failed to get file metadata: {}", e)))?;
let mut perms = metadata.permissions();
perms.set_mode(0o755); std::fs::set_permissions(temp_file.path(), perms).map_err(|e| {
DomainError::Other(format!("Failed to set file permissions: {}", e))
})?;
}
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let mut command = Command::new(&shell);
command.arg(temp_file.path());
if !self.script_args.is_empty() {
command.args(&self.script_args);
debug!(
"Executing shell script with arguments: {:?}",
self.script_args
);
}
let status = command
.status()
.map_err(|e| DomainError::Other(format!("Failed to execute shell script: {}", e)))?;
if status.success() {
Ok(())
} else {
Err(DomainError::Other(format!(
"Shell script exited with non-zero status: {:?}",
status.code()
)))
}
}
}
impl BookmarkAction for ShellAction {
#[instrument(skip(self, bookmark), level = "debug")]
fn execute(&self, bookmark: &Bookmark) -> DomainResult<()> {
let script = &bookmark.url;
let rendered_script = InterpolationHelper::render_if_needed(
script,
bookmark,
&self.interpolation_service,
"shell script",
)?;
debug!(
"Shell script (interactive={}): {}",
self.interactive, rendered_script
);
eprintln!("---");
let result = if self.interactive {
self.interactive_execute(&rendered_script)
} else {
self.execute_script(&rendered_script)
};
eprintln!("---");
result
}
fn description(&self) -> &'static str {
"Execute as shell script"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::application::InterpolationServiceImpl;
use crate::domain::tag::Tag;
use crate::infrastructure::interpolation::minijinja_engine::{
MiniJinjaEngine, SafeShellExecutor,
};
use std::collections::HashSet;
#[test]
fn given_shell_script_when_execute_direct_then_runs_without_edit() {
let shell_executor = Arc::new(SafeShellExecutor::new());
let interpolation_engine = Arc::new(MiniJinjaEngine::new(shell_executor));
let interpolation_service = Arc::new(InterpolationServiceImpl::new(interpolation_engine));
let action = ShellAction::new_direct(interpolation_service);
let script = "echo 'Hello from shell script'";
let mut tags = HashSet::new();
tags.insert(Tag::new("_shell_").unwrap());
let bookmark = Bookmark {
id: Some(1),
url: script.to_string(),
title: "Test Shell Script".to_string(),
description: "A test shell script".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,
};
let result = action.execute(&bookmark);
assert!(result.is_ok(), "Shell action execution should succeed");
}
#[test]
fn given_script_with_template_when_execute_direct_then_interpolates_content() {
let shell_executor = Arc::new(SafeShellExecutor::new());
let interpolation_engine = Arc::new(MiniJinjaEngine::new(shell_executor.clone()));
let interpolation_service = Arc::new(InterpolationServiceImpl::new(interpolation_engine));
let action = ShellAction::new_direct(interpolation_service);
let script = "echo 'Current date: {{ current_date | strftime(\"%Y-%m-%d\") }}'";
let mut tags = HashSet::new();
tags.insert(Tag::new("_shell_").unwrap());
let bookmark = Bookmark {
id: Some(1),
url: script.to_string(),
title: "Test Shell Script with Interpolation".to_string(),
description: "A test shell script with interpolation".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,
};
let result = action.execute(&bookmark);
assert!(result.is_ok(), "Shell action execution should succeed");
}
#[test]
fn given_failing_script_when_execute_direct_then_returns_error() {
let shell_executor = Arc::new(SafeShellExecutor::new());
let interpolation_engine = Arc::new(MiniJinjaEngine::new(shell_executor));
let interpolation_service = Arc::new(InterpolationServiceImpl::new(interpolation_engine));
let action = ShellAction::new_direct(interpolation_service);
let script = "exit 1";
let mut tags = HashSet::new();
tags.insert(Tag::new("_shell_").unwrap());
let bookmark = Bookmark {
id: Some(1),
url: script.to_string(),
title: "Test Failing Shell Script".to_string(),
description: "A test shell script that fails".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,
};
let result = action.execute(&bookmark);
assert!(result.is_err(), "Shell action execution should fail");
if let Err(DomainError::Other(msg)) = result {
assert!(
msg.contains("non-zero status"),
"Error should mention non-zero status"
);
} else {
panic!("Expected DomainError::Other");
}
}
#[test]
fn given_interactive_mode_when_create_shell_action_then_uses_defaults() {
let shell_executor = Arc::new(SafeShellExecutor::new());
let interpolation_engine = Arc::new(MiniJinjaEngine::new(shell_executor));
let interpolation_service = Arc::new(InterpolationServiceImpl::new(interpolation_engine));
let interactive_action = ShellAction::new(interpolation_service.clone(), true);
let direct_action = ShellAction::new_direct(interpolation_service);
assert!(
interactive_action.interactive,
"new() with true should set interactive mode"
);
assert!(
!direct_action.interactive,
"new_direct() should set non-interactive mode"
);
}
#[test]
fn given_shell_script_when_execute_method_called_then_returns_success() {
let shell_executor = Arc::new(SafeShellExecutor::new());
let interpolation_engine = Arc::new(MiniJinjaEngine::new(shell_executor));
let interpolation_service = Arc::new(InterpolationServiceImpl::new(interpolation_engine));
let action = ShellAction::new_direct(interpolation_service);
let result = action.execute_script("echo 'test execute_script method'");
assert!(result.is_ok(), "execute_script should work directly");
}
#[test]
fn given_script_with_parameters_when_execute_then_passes_args() {
let shell_executor = Arc::new(SafeShellExecutor::new());
let interpolation_engine = Arc::new(MiniJinjaEngine::new(shell_executor));
let interpolation_service = Arc::new(InterpolationServiceImpl::new(interpolation_engine));
let action = ShellAction::new_direct(interpolation_service);
let script_with_params = "echo 'Hello' && echo 'World' && echo 'Parameters work!'";
let result = action.execute_script(script_with_params);
assert!(
result.is_ok(),
"Shell script with parameters should execute successfully"
);
}
#[test]
fn given_environment_when_detect_edit_mode_then_returns_appropriate_mode() {
let shell_executor = Arc::new(SafeShellExecutor::new());
let interpolation_engine = Arc::new(MiniJinjaEngine::new(shell_executor));
let interpolation_service = Arc::new(InterpolationServiceImpl::new(interpolation_engine));
let action = ShellAction::new(interpolation_service, true);
let edit_mode = action.detect_edit_mode();
assert!(
matches!(edit_mode, EditMode::Emacs) || matches!(edit_mode, EditMode::Vi),
"Should detect either Emacs or Vi mode, got: {:?}",
edit_mode
);
}
#[test]
fn given_home_directory_when_get_history_path_then_returns_history_file() {
let shell_executor = Arc::new(SafeShellExecutor::new());
let interpolation_engine = Arc::new(MiniJinjaEngine::new(shell_executor));
let interpolation_service = Arc::new(InterpolationServiceImpl::new(interpolation_engine));
let action = ShellAction::new(interpolation_service, true);
let history_path = action.get_history_file_path();
assert!(
history_path.to_string_lossy().contains("shell_history.txt"),
"Should create a history file path"
);
}
#[test]
fn given_configuration_when_create_editor_then_returns_configured_editor() {
let shell_executor = Arc::new(SafeShellExecutor::new());
let interpolation_engine = Arc::new(MiniJinjaEngine::new(shell_executor));
let interpolation_service = Arc::new(InterpolationServiceImpl::new(interpolation_engine));
let action = ShellAction::new(interpolation_service, true);
let result = action.create_configured_editor();
assert!(
result.is_ok(),
"Should successfully create configured editor"
);
}
#[test]
fn given_template_service_and_args_when_new_direct_then_creates_action_with_args() {
let shell_executor = Arc::new(SafeShellExecutor::new());
let interpolation_engine = Arc::new(MiniJinjaEngine::new(shell_executor));
let interpolation_service = Arc::new(InterpolationServiceImpl::new(interpolation_engine));
let args = vec![
"--option1".to_string(),
"value1".to_string(),
"arg2".to_string(),
];
let action = ShellAction::new_direct_with_args(interpolation_service, args.clone());
assert!(!action.interactive, "Should be non-interactive");
assert_eq!(action.script_args, args, "Should store script arguments");
}
#[test]
fn given_script_with_arguments_when_execute_then_passes_to_shell() {
let shell_executor = Arc::new(SafeShellExecutor::new());
let interpolation_engine = Arc::new(MiniJinjaEngine::new(shell_executor));
let interpolation_service = Arc::new(InterpolationServiceImpl::new(interpolation_engine));
let args = vec!["arg1".to_string(), "arg2".to_string()];
let action = ShellAction::new_direct_with_args(interpolation_service, args);
let script = "echo \"First arg: $1, Second arg: $2\"";
let mut tags = HashSet::new();
tags.insert(Tag::new("_shell_").unwrap());
let bookmark = Bookmark {
id: Some(1),
url: script.to_string(),
title: "Test Shell Script with Args".to_string(),
description: "A test shell script that uses arguments".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,
};
let result = action.execute(&bookmark);
assert!(
result.is_ok(),
"Shell action with arguments should execute successfully"
);
}
}