use super::{BotResponse, ConversationState, ResponseContentType}; use crate::fixtures::{Bot, Channel, Customer, Session}; use crate::harness::TestContext; use anyhow::{Context, Result}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use uuid::Uuid; #[derive(Debug, Clone)] pub struct BotRunnerConfig { pub working_dir: PathBuf, pub timeout: Duration, pub use_mocks: bool, pub env_vars: HashMap, pub capture_logs: bool, pub log_level: LogLevel, } impl Default for BotRunnerConfig { fn default() -> Self { Self { working_dir: std::env::temp_dir().join("bottest"), timeout: Duration::from_secs(30), use_mocks: true, env_vars: HashMap::new(), capture_logs: true, log_level: LogLevel::Info, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LogLevel { Trace, Debug, Info, Warn, Error, } impl Default for LogLevel { fn default() -> Self { Self::Info } } pub struct BotRunner { config: BotRunnerConfig, bot: Option, sessions: Arc>>, script_cache: Arc>>, metrics: Arc>, } struct SessionState { session: Session, customer: Customer, channel: Channel, context: HashMap, conversation_state: ConversationState, message_count: usize, started_at: Instant, } #[derive(Debug, Default, Clone)] pub struct RunnerMetrics { pub total_requests: u64, pub successful_requests: u64, pub failed_requests: u64, pub total_latency_ms: u64, pub min_latency_ms: u64, pub max_latency_ms: u64, pub script_executions: u64, pub transfer_to_human_count: u64, } impl RunnerMetrics { pub const fn avg_latency_ms(&self) -> u64 { if self.total_requests > 0 { self.total_latency_ms / self.total_requests } else { 0 } } pub fn success_rate(&self) -> f64 { if self.total_requests > 0 { (self.successful_requests as f64 / self.total_requests as f64) * 100.0 } else { 0.0 } } } #[derive(Debug, Clone)] pub struct ExecutionResult { pub session_id: Uuid, pub response: Option, pub state: ConversationState, pub execution_time: Duration, pub logs: Vec, pub error: Option, } #[derive(Debug, Clone)] pub struct LogEntry { pub timestamp: chrono::DateTime, pub level: LogLevel, pub message: String, pub context: HashMap, } impl BotRunner { pub fn new() -> Self { Self::with_config(BotRunnerConfig::default()) } pub fn with_config(config: BotRunnerConfig) -> Self { Self { config, bot: None, sessions: Arc::new(Mutex::new(HashMap::new())), script_cache: Arc::new(Mutex::new(HashMap::new())), metrics: Arc::new(Mutex::new(RunnerMetrics::default())), } } pub fn with_context(_ctx: &TestContext, config: BotRunnerConfig) -> Self { Self::with_config(config) } pub fn set_bot(&mut self, bot: Bot) -> &mut Self { self.bot = Some(bot); self } pub fn load_script(&self, name: &str, content: &str) -> &Self { self.script_cache .lock() .unwrap() .insert(name.to_string(), content.to_string()); self } pub fn load_script_file(&self, name: &str, path: &PathBuf) -> Result<&Self> { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read script file: {}", path.display()))?; self.script_cache .lock() .unwrap() .insert(name.to_string(), content); Ok(self) } pub fn start_session(&self, customer: Customer) -> Result { let session_id = Uuid::new_v4(); let bot_id = self.bot.as_ref().map_or_else(Uuid::new_v4, |b| b.id); let session = Session { id: session_id, bot_id, customer_id: customer.id, channel: customer.channel, ..Default::default() }; let state = SessionState { session, channel: customer.channel, customer, context: HashMap::new(), conversation_state: ConversationState::Initial, message_count: 0, started_at: Instant::now(), }; self.sessions.lock().unwrap().insert(session_id, state); Ok(session_id) } pub fn end_session(&self, session_id: Uuid) -> Result<()> { self.sessions.lock().unwrap().remove(&session_id); Ok(()) } pub async fn process_message( &self, session_id: Uuid, message: &str, ) -> Result { let start = Instant::now(); let mut logs = Vec::new(); { let mut metrics = self.metrics.lock().unwrap(); metrics.total_requests += 1; } let state = { let sessions = self.sessions.lock().unwrap(); sessions.get(&session_id).cloned() }; let Some(state) = state else { return Ok(ExecutionResult { session_id, response: None, state: ConversationState::Error, execution_time: start.elapsed(), logs, error: Some("Session not found".to_string()), }); }; if self.config.capture_logs { logs.push(LogEntry { timestamp: chrono::Utc::now(), level: LogLevel::Debug, message: format!("Processing message: {message}"), context: HashMap::new(), }); } let response = self.execute_bot_logic(session_id, message, &state).await; let execution_time = start.elapsed(); { let mut metrics = self.metrics.lock().unwrap(); let latency_ms = execution_time.as_millis() as u64; metrics.total_latency_ms += latency_ms; if metrics.min_latency_ms == 0 || latency_ms < metrics.min_latency_ms { metrics.min_latency_ms = latency_ms; } if latency_ms > metrics.max_latency_ms { metrics.max_latency_ms = latency_ms; } if response.is_ok() { metrics.successful_requests += 1; } else { metrics.failed_requests += 1; } } { let mut sessions = self.sessions.lock().unwrap(); if let Some(session_state) = sessions.get_mut(&session_id) { session_state.message_count += 1; session_state.conversation_state = ConversationState::WaitingForUser; } } match response { Ok(bot_response) => Ok(ExecutionResult { session_id, response: Some(bot_response), state: ConversationState::WaitingForUser, execution_time, logs, error: None, }), Err(e) => Ok(ExecutionResult { session_id, response: None, state: ConversationState::Error, execution_time, logs, error: Some(e.to_string()), }), } } async fn execute_bot_logic( &self, session_id: Uuid, message: &str, state: &SessionState, ) -> Result { let start = Instant::now(); let bot = self.bot.as_ref().context("No bot configured")?; let script_path = self .config .working_dir .join(&bot.name) .join("dialog") .join("start.bas"); let script_content = if script_path.exists() { tokio::fs::read_to_string(&script_path) .await .unwrap_or_default() } else { let cache = self.script_cache.lock().unwrap(); cache.get("default").cloned().unwrap_or_default() }; let response_content = if script_content.is_empty() { format!("Received: {message}") } else { Self::evaluate_basic_script(&script_content, message, &state.context) .unwrap_or_else(|e| format!("Error: {e}")) }; let latency = start.elapsed().as_millis() as u64; { let mut metrics = self.metrics.lock().unwrap(); metrics.total_requests += 1; metrics.successful_requests += 1; metrics.total_latency_ms += latency; } Ok(BotResponse { id: Uuid::new_v4(), content: response_content, content_type: ResponseContentType::Text, metadata: HashMap::from([ ( "session_id".to_string(), serde_json::Value::String(session_id.to_string()), ), ( "bot_name".to_string(), serde_json::Value::String(bot.name.clone()), ), ]), latency_ms: latency, }) } fn evaluate_basic_script( script: &str, input: &str, context: &HashMap, ) -> Result { let mut output = String::new(); let mut variables: HashMap = HashMap::new(); variables.insert("INPUT".to_string(), input.to_string()); for (key, value) in context { variables.insert(key.to_uppercase(), value.to_string()); } for line in script.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('\'') || line.starts_with("REM") { continue; } if line.to_uppercase().starts_with("TALK") { let content = line[4..].trim().trim_matches('"'); let expanded = Self::expand_variables(content, &variables); if !output.is_empty() { output.push('\n'); } output.push_str(&expanded); } else if line.to_uppercase().starts_with("HEAR") { variables.insert("LAST_INPUT".to_string(), input.to_string()); } else if line.contains('=') && !line.to_uppercase().starts_with("IF") { let parts: Vec<&str> = line.splitn(2, '=').collect(); if parts.len() == 2 { let var_name = parts[0].trim().to_uppercase(); let var_value = parts[1].trim().trim_matches('"').to_string(); let expanded = Self::expand_variables(&var_value, &variables); variables.insert(var_name, expanded); } } } if output.is_empty() { output = format!("Processed: {input}"); } Ok(output) } fn expand_variables(text: &str, variables: &HashMap) -> String { let mut result = text.to_string(); for (key, value) in variables { result = result.replace(&format!("{{{key}}}"), value); result = result.replace(&format!("${key}"), value); result = result.replace(key, value); } result } pub fn execute_script(&self, script_name: &str, input: &str) -> Result { let session_id = Uuid::new_v4(); let start = Instant::now(); let mut logs = Vec::new(); let script = { let cache = self.script_cache.lock().unwrap(); cache.get(script_name).cloned() }; let Some(script) = script else { return Ok(ExecutionResult { session_id, response: None, state: ConversationState::Error, execution_time: start.elapsed(), logs, error: Some(format!("Script '{script_name}' not found")), }); }; if self.config.capture_logs { logs.push(LogEntry { timestamp: chrono::Utc::now(), level: LogLevel::Debug, message: format!("Executing script: {script_name}"), context: HashMap::new(), }); } { let mut metrics = self.metrics.lock().unwrap(); metrics.script_executions += 1; } let result = Self::execute_script_internal(&script, input); let execution_time = start.elapsed(); match result { Ok(output) => Ok(ExecutionResult { session_id, response: Some(BotResponse { id: Uuid::new_v4(), content: output, content_type: ResponseContentType::Text, metadata: HashMap::new(), latency_ms: execution_time.as_millis() as u64, }), state: ConversationState::WaitingForUser, execution_time, logs, error: None, }), Err(e) => Ok(ExecutionResult { session_id, response: None, state: ConversationState::Error, execution_time, logs, error: Some(e.to_string()), }), } } fn execute_script_internal(script: &str, input: &str) -> Result { let context = HashMap::new(); Self::evaluate_basic_script(script, input, &context) } pub fn metrics(&self) -> RunnerMetrics { self.metrics.lock().unwrap().clone() } pub fn reset_metrics(&self) { *self.metrics.lock().unwrap() = RunnerMetrics::default(); } pub fn active_session_count(&self) -> usize { self.sessions.lock().unwrap().len() } pub fn get_session_info(&self, session_id: Uuid) -> Option { let sessions = self.sessions.lock().unwrap(); let info = sessions.get(&session_id).map(|s| SessionInfo { session_id: s.session.id, customer_id: s.customer.id, channel: s.channel, message_count: s.message_count, state: s.conversation_state, duration: s.started_at.elapsed(), }); drop(sessions); info } pub fn set_env(&mut self, key: &str, value: &str) -> &mut Self { self.config .env_vars .insert(key.to_string(), value.to_string()); self } pub const fn set_timeout(&mut self, timeout: Duration) -> &mut Self { self.config.timeout = timeout; self } } impl Default for BotRunner { fn default() -> Self { Self::new() } } #[derive(Debug, Clone)] pub struct SessionInfo { pub session_id: Uuid, pub customer_id: Uuid, pub channel: Channel, pub message_count: usize, pub state: ConversationState, pub duration: Duration, } impl Clone for SessionState { fn clone(&self) -> Self { Self { session: self.session.clone(), customer: self.customer.clone(), channel: self.channel, context: self.context.clone(), conversation_state: self.conversation_state, message_count: self.message_count, started_at: self.started_at, } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_bot_runner_config_default() { let config = BotRunnerConfig::default(); assert_eq!(config.timeout, Duration::from_secs(30)); assert!(config.use_mocks); assert!(config.capture_logs); } #[test] fn test_runner_metrics_avg_latency() { let metrics = RunnerMetrics { total_requests: 10, total_latency_ms: 1000, ..Default::default() }; assert_eq!(metrics.avg_latency_ms(), 100); } #[test] fn test_runner_metrics_success_rate() { let metrics = RunnerMetrics { total_requests: 100, successful_requests: 95, ..Default::default() }; assert!((metrics.success_rate() - 95.0).abs() < f64::EPSILON); } #[test] fn test_runner_metrics_zero_requests() { let metrics = RunnerMetrics::default(); assert_eq!(metrics.avg_latency_ms(), 0); assert!(metrics.success_rate().abs() < f64::EPSILON); } #[test] fn test_bot_runner_new() { let runner = BotRunner::new(); assert_eq!(runner.active_session_count(), 0); } #[test] fn test_load_script() { let runner = BotRunner::new(); runner.load_script("test", "TALK \"Hello\""); let cache = runner.script_cache.lock().unwrap(); assert!(cache.contains_key("test")); drop(cache); } #[test] fn test_start_session() { let runner = BotRunner::new(); let customer = Customer::default(); let session_id = runner.start_session(customer).unwrap(); assert_eq!(runner.active_session_count(), 1); assert!(runner.get_session_info(session_id).is_some()); } #[test] fn test_end_session() { let runner = BotRunner::new(); let customer = Customer::default(); let session_id = runner.start_session(customer).unwrap(); assert_eq!(runner.active_session_count(), 1); runner.end_session(session_id).unwrap(); assert_eq!(runner.active_session_count(), 0); } #[tokio::test] async fn test_process_message() { let runner = BotRunner::new(); let customer = Customer::default(); let session_id = runner.start_session(customer).unwrap(); let result = runner.process_message(session_id, "Hello").await.unwrap(); assert!(result.response.is_some()); assert!(result.error.is_none()); assert_eq!(result.state, ConversationState::WaitingForUser); } #[tokio::test] async fn test_process_message_invalid_session() { let runner = BotRunner::new(); let invalid_session_id = Uuid::new_v4(); let result = runner .process_message(invalid_session_id, "Hello") .await .unwrap(); assert!(result.response.is_none()); assert!(result.error.is_some()); assert_eq!(result.state, ConversationState::Error); } #[test] fn test_execute_script() { let runner = BotRunner::new(); runner.load_script("greeting", "TALK \"Hello\""); let result = runner.execute_script("greeting", "Hi").unwrap(); assert!(result.response.is_some()); assert!(result.error.is_none()); } #[test] fn test_execute_script_not_found() { let runner = BotRunner::new(); let result = runner.execute_script("nonexistent", "Hi").unwrap(); assert!(result.response.is_none()); assert!(result.error.is_some()); assert!(result.error.unwrap().contains("not found")); } #[test] fn test_metrics_tracking() { let runner = BotRunner::new(); let metrics = runner.metrics(); assert_eq!(metrics.total_requests, 0); assert_eq!(metrics.successful_requests, 0); } #[test] fn test_reset_metrics() { let runner = BotRunner::new(); { let mut metrics = runner.metrics.lock().unwrap(); metrics.total_requests = 100; } runner.reset_metrics(); let metrics = runner.metrics(); assert_eq!(metrics.total_requests, 0); } #[test] fn test_set_env() { let mut runner = BotRunner::new(); runner.set_env("API_KEY", "test123"); assert_eq!( runner.config.env_vars.get("API_KEY"), Some(&"test123".to_string()) ); } #[test] fn test_set_timeout() { let mut runner = BotRunner::new(); runner.set_timeout(Duration::from_secs(60)); assert_eq!(runner.config.timeout, Duration::from_secs(60)); } #[test] fn test_session_info() { let runner = BotRunner::new(); let customer = Customer::default(); let customer_id = customer.id; let session_id = runner.start_session(customer).unwrap(); let info = runner.get_session_info(session_id).unwrap(); assert_eq!(info.session_id, session_id); assert_eq!(info.customer_id, customer_id); assert_eq!(info.message_count, 0); assert_eq!(info.state, ConversationState::Initial); } #[test] fn test_log_level_default() { let level = LogLevel::default(); assert_eq!(level, LogLevel::Info); } }