From 17b087e6188cec00407688642cca323fec87b777 Mon Sep 17 00:00:00 2001 From: Micheal Smith Date: Thu, 20 Nov 2025 04:17:09 -0600 Subject: [PATCH] Implemented external processes as potential plugins. --- Cargo.lock | 11 ++ Cargo.toml | 48 ++++--- src/command.rs | 183 ++++++++++++++++++++++++++ src/ipc.rs | 26 ---- src/lib.rs | 2 +- tests/command_test.rs | 290 ++++++++++++++++++++++++++++++++++++++++++ tests/event_test.rs | 2 +- 7 files changed, 513 insertions(+), 49 deletions(-) create mode 100644 src/command.rs delete mode 100644 src/ipc.rs create mode 100644 tests/command_test.rs diff --git a/Cargo.lock b/Cargo.lock index 590684a..1b8895a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2130,6 +2130,7 @@ name = "robotnik" version = "0.1.0" dependencies = [ "better-panic", + "bytes", "cargo-husky", "clap", "color-eyre", @@ -2494,6 +2495,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + [[package]] name = "slab" version = "0.4.11" @@ -2702,6 +2712,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index d0fb103..e70a435 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] better-panic = "0.3.0" +bytes = "1" color-eyre = "0.6.3" directories = "6.0" futures = "0.3" @@ -15,36 +16,41 @@ serde_json = "1.0" tracing = "0.1" tracing-subscriber = "0.3" - [dependencies.nix] - version = "0.30.1" - features = [ "fs" ] +[dependencies.nix] +version = "0.30.1" +features = ["fs"] - [dependencies.clap] - version = "4.5" - features = [ "derive" ] +[dependencies.clap] +version = "4.5" +features = ["derive"] - [dependencies.config] - version = "0.15" - features = [ "toml" ] +[dependencies.config] +version = "0.15" +features = ["toml"] - [dependencies.serde] - version = "1.0" - features = [ "derive" ] +[dependencies.serde] +version = "1.0" +features = ["derive"] - [dependencies.tokio] - version = "1" - features = [ "io-util", "macros", "net", "rt-multi-thread", "sync" ] +[dependencies.tokio] +version = "1" +features = [ + "io-util", + "macros", + "net", + "process", + "rt-multi-thread", + "sync", + "time", +] [dev-dependencies] rstest = "0.24" tempfile = "3.13" - [dev-dependencies.cargo-husky] - version = "1" - features = [ - "run-cargo-check", - "run-cargo-clippy", -] +[dev-dependencies.cargo-husky] +version = "1" +features = ["run-cargo-check", "run-cargo-clippy"] [profile.release] strip = true diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 0000000..b441757 --- /dev/null +++ b/src/command.rs @@ -0,0 +1,183 @@ +// Commands that are associated with external processes (commands). + +use std::{ + path::{Path, PathBuf}, + time::Duration, +}; + +use bytes::Bytes; +use color_eyre::{Result, eyre::eyre}; +use tokio::{fs::try_exists, process::Command, time::timeout}; +use tracing::{Level, event}; + +#[derive(Debug)] +pub struct CommandDir { + command_path: PathBuf, +} + +impl CommandDir { + pub fn new(command_path: impl AsRef) -> Self { + event!( + Level::INFO, + "CommandDir initialized with path: {:?}", + command_path.as_ref() + ); + CommandDir { + command_path: command_path.as_ref().to_path_buf(), + } + } + + async fn find_command(&self, name: impl AsRef) -> Result { + let path = self.command_path.join(name.as_ref()); + + event!( + Level::INFO, + "Looking for {} command.", + name.as_ref().display() + ); + + match try_exists(&path).await { + Ok(true) => Ok(path.to_string_lossy().to_string()), + Ok(false) => Err(eyre!(format!("{} Not found.", path.to_string_lossy()))), + Err(e) => Err(e.into()), + } + } + + pub async fn run_command( + &self, + command_name: impl AsRef, + input: impl AsRef, + ) -> Result { + let path = self.find_command(Path::new(command_name.as_ref())).await?; + // Well it exists let's cross our fingers... + let output = Command::new(path).arg(input.as_ref()).output().await?; + + if output.status.success() { + // So far so good + Ok(Bytes::from(output.stdout)) + } else { + // Whoops + Err(eyre!(format!( + "Error running {}: {}", + command_name.as_ref(), + output.status + ))) + } + } + + pub async fn run_command_with_timeout( + &self, + command_name: impl AsRef, + input: impl AsRef, + time_out: Duration, + ) -> Result { + timeout(time_out, self.run_command(command_name, input)).await? + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs::{self, Permissions}, + os::unix::fs::PermissionsExt, + }; + use tempfile::TempDir; + + fn create_test_script(dir: &Path, name: &str, script: &str) -> PathBuf { + let path = dir.join(name); + fs::write(&path, script).unwrap(); + fs::set_permissions(&path, Permissions::from_mode(0o755)).unwrap(); + path + } + + #[test] + fn test_command_dir_new() { + let dir = CommandDir::new("/some/path"); + assert_eq!(dir.command_path, PathBuf::from("/some/path")); + } + + #[tokio::test] + async fn test_find_command_exists() { + let temp = TempDir::new().unwrap(); + create_test_script(temp.path(), "test_cmd", "#!/bin/bash\necho hello"); + + let cmd_dir = CommandDir::new(temp.path()); + let result = cmd_dir.find_command("test_cmd").await; + + assert!(result.is_ok()); + assert!(result.unwrap().contains("test_cmd")); + } + + #[tokio::test] + async fn test_find_command_not_found() { + let temp = TempDir::new().unwrap(); + let cmd_dir = CommandDir::new(temp.path()); + + let result = cmd_dir.find_command("nonexistent").await; + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Not found")); + } + + #[tokio::test] + async fn test_run_command_success() { + let temp = TempDir::new().unwrap(); + create_test_script(temp.path(), "echo_cmd", "#!/bin/bash\necho \"$1\""); + + let cmd_dir = CommandDir::new(temp.path()); + let result = cmd_dir.run_command("echo_cmd", "hello world").await; + + assert!(result.is_ok()); + let output = result.unwrap(); + assert_eq!(output.as_ref(), b"hello world\n"); + } + + #[tokio::test] + async fn test_run_command_failure() { + let temp = TempDir::new().unwrap(); + create_test_script(temp.path(), "fail_cmd", "#!/bin/bash\nexit 1"); + + let cmd_dir = CommandDir::new(temp.path()); + let result = cmd_dir.run_command("fail_cmd", "input").await; + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Error running")); + } + + #[tokio::test] + async fn test_run_command_not_found() { + let temp = TempDir::new().unwrap(); + let cmd_dir = CommandDir::new(temp.path()); + + let result = cmd_dir.run_command("missing", "input").await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_run_command_with_timeout_success() { + let temp = TempDir::new().unwrap(); + create_test_script(temp.path(), "fast_cmd", "#!/bin/bash\necho \"$1\""); + + let cmd_dir = CommandDir::new(temp.path()); + let result = cmd_dir + .run_command_with_timeout("fast_cmd", "quick", Duration::from_secs(5)) + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_run_command_with_timeout_expires() { + let temp = TempDir::new().unwrap(); + create_test_script(temp.path(), "slow_cmd", "#!/bin/bash\nsleep 10\necho done"); + + let cmd_dir = CommandDir::new(temp.path()); + let result = cmd_dir + .run_command_with_timeout("slow_cmd", "input", Duration::from_millis(100)) + .await; + + assert!(result.is_err()); + } +} diff --git a/src/ipc.rs b/src/ipc.rs deleted file mode 100644 index 2a66903..0000000 --- a/src/ipc.rs +++ /dev/null @@ -1,26 +0,0 @@ -// Provides an IPC socket to communicate with other processes. - -use std::path::Path; - -use color_eyre::Result; -use tokio::net::UnixListener; - -pub struct IPC { - listener: UnixListener, -} - -impl IPC { - pub fn new(path: impl AsRef) -> Result { - let listener = UnixListener::bind(path)?; - Ok(Self { listener }) - } - - pub async fn run(&self) -> Result<()> { - loop { - match self.listener.accept().await { - Ok((_stream, _addr)) => {} - Err(e) => return Err(e.into()), - } - } - } -} diff --git a/src/lib.rs b/src/lib.rs index 6f75378..7e7f1e6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,9 +9,9 @@ use tracing::{Level, info}; use tracing_subscriber::FmtSubscriber; pub mod chat; +pub mod command; pub mod event; pub mod event_manager; -pub mod ipc; pub mod plugin; pub mod qna; pub mod setup; diff --git a/tests/command_test.rs b/tests/command_test.rs new file mode 100644 index 0000000..56f40d2 --- /dev/null +++ b/tests/command_test.rs @@ -0,0 +1,290 @@ +use std::{ + fs::{self, Permissions}, + os::unix::fs::PermissionsExt, + path::Path, + time::Duration, +}; + +use robotnik::command::CommandDir; +use tempfile::TempDir; + +/// Helper to create executable test scripts +fn create_command(dir: &Path, name: &str, script: &str) { + let path = dir.join(name); + fs::write(&path, script).unwrap(); + fs::set_permissions(&path, Permissions::from_mode(0o755)).unwrap(); +} + +/// Parse a bot message like "!weather 07008" into (command_name, argument) +fn parse_bot_message(message: &str) -> Option<(&str, &str)> { + if !message.starts_with('!') { + return None; + } + let without_prefix = &message[1..]; + let mut parts = without_prefix.splitn(2, ' '); + let command = parts.next()?; + let arg = parts.next().unwrap_or(""); + Some((command, arg)) +} + +#[tokio::test] +async fn test_bot_message_finds_and_runs_command() { + let temp = TempDir::new().unwrap(); + + // Create a weather command that echoes the zip code + create_command( + temp.path(), + "weather", + r#"#!/bin/bash +echo "Weather for $1: Sunny, 72°F" +"#, + ); + + let cmd_dir = CommandDir::new(temp.path()); + let message = "!weather 73135"; + + // Parse the message + let (command_name, arg) = parse_bot_message(message).unwrap(); + assert_eq!(command_name, "weather"); + assert_eq!(arg, "73135"); + + // Find and run the command + let result = cmd_dir.run_command(command_name, arg).await; + + assert!(result.is_ok()); + let bytes = result.unwrap(); + let output = String::from_utf8_lossy(&bytes); + assert!(output.contains("Weather for 73135")); + assert!(output.contains("Sunny")); +} + +#[tokio::test] +async fn test_bot_message_command_not_found() { + let temp = TempDir::new().unwrap(); + let cmd_dir = CommandDir::new(temp.path()); + + let message = "!nonexistent arg"; + let (command_name, arg) = parse_bot_message(message).unwrap(); + + let result = cmd_dir.run_command(command_name, arg).await; + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Not found")); +} + +#[tokio::test] +async fn test_bot_message_with_multiple_arguments() { + let temp = TempDir::new().unwrap(); + + // Create a command that handles multiple words as a single argument + create_command( + temp.path(), + "echo", + r#"#!/bin/bash +echo "You said: $1" +"#, + ); + + let cmd_dir = CommandDir::new(temp.path()); + let message = "!echo hello world how are you"; + + let (command_name, arg) = parse_bot_message(message).unwrap(); + assert_eq!(command_name, "echo"); + assert_eq!(arg, "hello world how are you"); + + let result = cmd_dir.run_command(command_name, arg).await; + + assert!(result.is_ok()); + let bytes = result.unwrap(); + let output = String::from_utf8_lossy(&bytes); + assert!(output.contains("hello world how are you")); +} + +#[tokio::test] +async fn test_bot_message_without_argument() { + let temp = TempDir::new().unwrap(); + + create_command( + temp.path(), + "help", + r#"#!/bin/bash +echo "Available commands: weather, echo, help" +"#, + ); + + let cmd_dir = CommandDir::new(temp.path()); + let message = "!help"; + + let (command_name, arg) = parse_bot_message(message).unwrap(); + assert_eq!(command_name, "help"); + assert_eq!(arg, ""); + + let result = cmd_dir.run_command(command_name, arg).await; + + assert!(result.is_ok()); + let bytes = result.unwrap(); + let output = String::from_utf8_lossy(&bytes); + assert!(output.contains("Available commands")); +} + +#[tokio::test] +async fn test_bot_message_command_returns_error_exit_code() { + let temp = TempDir::new().unwrap(); + + // Create a command that fails for invalid input + create_command( + temp.path(), + "validate", + r#"#!/bin/bash +if [ -z "$1" ]; then + echo "Error: Input required" >&2 + exit 1 +fi +echo "Valid: $1" +"#, + ); + + let cmd_dir = CommandDir::new(temp.path()); + let message = "!validate"; + + let (command_name, arg) = parse_bot_message(message).unwrap(); + let result = cmd_dir.run_command(command_name, arg).await; + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Error running")); +} + +#[tokio::test] +async fn test_bot_message_with_timeout() { + let temp = TempDir::new().unwrap(); + + create_command( + temp.path(), + "quick", + r#"#!/bin/bash +echo "Result: $1" +"#, + ); + + let cmd_dir = CommandDir::new(temp.path()); + let message = "!quick test"; + + let (command_name, arg) = parse_bot_message(message).unwrap(); + let result = cmd_dir + .run_command_with_timeout(command_name, arg, Duration::from_secs(5)) + .await; + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_bot_message_command_times_out() { + let temp = TempDir::new().unwrap(); + + create_command( + temp.path(), + "slow", + r#"#!/bin/bash +sleep 10 +echo "Done" +"#, + ); + + let cmd_dir = CommandDir::new(temp.path()); + let message = "!slow arg"; + + let (command_name, arg) = parse_bot_message(message).unwrap(); + let result = cmd_dir + .run_command_with_timeout(command_name, arg, Duration::from_millis(100)) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_multiple_commands_in_directory() { + let temp = TempDir::new().unwrap(); + + create_command( + temp.path(), + "weather", + r#"#!/bin/bash +echo "Weather: Sunny" +"#, + ); + + create_command( + temp.path(), + "time", + r#"#!/bin/bash +echo "Time: 12:00" +"#, + ); + + create_command( + temp.path(), + "joke", + r#"#!/bin/bash +echo "Why did the robot go on vacation? To recharge!" +"#, + ); + + let cmd_dir = CommandDir::new(temp.path()); + + // Test each command + let messages = ["!weather", "!time", "!joke"]; + let expected = ["Sunny", "12:00", "recharge"]; + + for (message, expect) in messages.iter().zip(expected.iter()) { + let (command_name, arg) = parse_bot_message(message).unwrap(); + let result = cmd_dir.run_command(command_name, arg).await; + assert!(result.is_ok()); + let bytes = result.unwrap(); + let output = String::from_utf8_lossy(&bytes); + assert!( + output.contains(expect), + "Expected '{}' in '{}'", + expect, + output + ); + } +} + +#[tokio::test] +async fn test_non_bot_message_ignored() { + // Messages not starting with ! should be ignored + let messages = ["hello world", "weather 73135", "?help", "/command", ""]; + + for message in messages { + assert!( + parse_bot_message(message).is_none(), + "Should ignore: {}", + message + ); + } +} + +#[tokio::test] +async fn test_command_output_is_bytes() { + let temp = TempDir::new().unwrap(); + + // Create a command that outputs binary-safe content + create_command( + temp.path(), + "binary", + r#"#!/bin/bash +printf "Hello\x00World" +"#, + ); + + let cmd_dir = CommandDir::new(temp.path()); + let message = "!binary test"; + + let (command_name, arg) = parse_bot_message(message).unwrap(); + let result = cmd_dir.run_command(command_name, arg).await; + + assert!(result.is_ok()); + let output = result.unwrap(); + // Should preserve the null byte + assert_eq!(&output[..], b"Hello\x00World"); +} diff --git a/tests/event_test.rs b/tests/event_test.rs index 1a1b6ca..e30a84b 100644 --- a/tests/event_test.rs +++ b/tests/event_test.rs @@ -486,7 +486,7 @@ async fn test_json_deserialization_of_received_events() { reader.read_line(&mut line).await.unwrap(); // Should be valid JSON - let parsed: serde_json::Value = serde_json::from_str(&line.trim()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(line.trim()).unwrap(); assert_eq!(parsed["message"], test_message);