1use std::sync::Arc;
7
8use thiserror::Error;
9
10#[derive(Debug, Error)]
14pub enum ActionError {
15 #[error("Command not allowed: {0}")]
16 CommandNotAllowed(String),
17
18 #[error("Command execution failed: {0}")]
19 ExecutionFailed(String),
20
21 #[error("Timeout")]
22 Timeout,
23
24 #[error("Invalid arguments: {0}")]
25 InvalidArguments(String),
26
27 #[error("IO error: {0}")]
28 Io(#[from] std::io::Error),
29}
30
31#[derive(Debug, Clone, PartialEq)]
35pub enum Action {
36 ExecuteCommand { command: String, args: Vec<String> },
38 WebSearch { query: String },
40 ScheduleTask {
42 description: String,
43 cron: Option<String>,
44 },
45 StoreFact {
47 subject: String,
48 predicate: String,
49 object: String,
50 },
51 Recall { query: String },
53 SendMessage {
55 channel: String,
56 recipient: String,
57 content: String,
58 },
59}
60
61#[derive(Debug, Clone)]
63pub struct ActionResult {
64 pub success: bool,
65 pub output: String,
66 pub error: Option<String>,
67}
68
69#[derive(Debug, Clone)]
71pub struct MemoryFact {
72 pub namespace: String,
73 pub subject: String,
74 pub predicate: String,
75 pub object: String,
76 pub confidence: f64,
77}
78
79#[async_trait::async_trait]
81pub trait MemoryBackend: Send + Sync {
82 async fn store_fact(
83 &self,
84 namespace: &str,
85 category: &str,
86 subject: &str,
87 predicate: &str,
88 object: &str,
89 ) -> Result<String, ActionError>;
90
91 async fn recall(
92 &self,
93 query: &str,
94 top_k: usize,
95 namespace: Option<&str>,
96 ) -> Result<Vec<MemoryFact>, ActionError>;
97}
98
99#[derive(Debug, Clone)]
101pub struct SearchHit {
102 pub title: String,
103 pub url: String,
104 pub snippet: String,
105}
106
107#[async_trait::async_trait]
109pub trait WebSearchBackend: Send + Sync {
110 async fn search(&self, query: &str, top_k: usize) -> Result<Vec<SearchHit>, ActionError>;
111}
112
113#[derive(Debug, Clone)]
115pub struct ScheduleOutcome {
116 pub schedule_id: String,
117 pub status: String,
118}
119
120#[async_trait::async_trait]
122pub trait SchedulingBackend: Send + Sync {
123 async fn schedule(
124 &self,
125 description: &str,
126 cron: Option<&str>,
127 namespace: &str,
128 ) -> Result<ScheduleOutcome, ActionError>;
129}
130
131#[derive(Debug, Clone)]
133pub struct MessageOutcome {
134 pub delivery_id: String,
135 pub status: String,
136}
137
138#[async_trait::async_trait]
140pub trait MessageBackend: Send + Sync {
141 async fn send(
142 &self,
143 channel: &str,
144 recipient: &str,
145 content: &str,
146 namespace: &str,
147 ) -> Result<MessageOutcome, ActionError>;
148}
149
150impl ActionResult {
151 pub fn success(output: impl Into<String>) -> Self {
153 Self {
154 success: true,
155 output: output.into(),
156 error: None,
157 }
158 }
159
160 pub fn failure(error: impl Into<String>) -> Self {
162 Self {
163 success: false,
164 output: String::new(),
165 error: Some(error.into()),
166 }
167 }
168}
169
170#[derive(Debug, Clone)]
174pub struct ActionConfig {
175 pub command_allowlist: Vec<String>,
177 pub command_timeout_secs: u64,
179 pub enable_web_search: bool,
181 pub enable_scheduling: bool,
183 pub enable_channel_send: bool,
185 pub web_search_top_k: usize,
187}
188
189impl Default for ActionConfig {
190 fn default() -> Self {
191 Self {
192 command_allowlist: vec![
193 "ls".to_string(),
194 "cat".to_string(),
195 "grep".to_string(),
196 "find".to_string(),
197 "git".to_string(),
198 "cargo".to_string(),
199 "rustc".to_string(),
200 "pwd".to_string(),
201 "echo".to_string(),
202 "head".to_string(),
203 "tail".to_string(),
204 ],
205 command_timeout_secs: 30,
206 enable_web_search: true,
207 enable_scheduling: false,
208 enable_channel_send: false,
209 web_search_top_k: 5,
210 }
211 }
212}
213
214pub struct ActionDispatcher {
216 config: ActionConfig,
217 memory_backend: Option<Arc<dyn MemoryBackend>>,
218 web_search_backend: Option<Arc<dyn WebSearchBackend>>,
219 scheduling_backend: Option<Arc<dyn SchedulingBackend>>,
220 message_backend: Option<Arc<dyn MessageBackend>>,
221 namespace: String,
222}
223
224impl ActionDispatcher {
225 pub fn new(config: ActionConfig) -> Self {
227 Self {
228 config,
229 memory_backend: None,
230 web_search_backend: None,
231 scheduling_backend: None,
232 message_backend: None,
233 namespace: "personal".to_string(),
234 }
235 }
236
237 pub fn with_memory_backend(
239 config: ActionConfig,
240 memory_backend: Arc<dyn MemoryBackend>,
241 ) -> Self {
242 Self::new(config).with_memory(memory_backend)
243 }
244
245 pub fn with_defaults() -> Self {
247 Self::new(ActionConfig::default())
248 }
249
250 pub fn with_memory(mut self, memory_backend: Arc<dyn MemoryBackend>) -> Self {
252 self.memory_backend = Some(memory_backend);
253 self
254 }
255
256 pub fn with_web_search_backend(mut self, backend: Arc<dyn WebSearchBackend>) -> Self {
258 self.web_search_backend = Some(backend);
259 self
260 }
261
262 pub fn with_scheduling_backend(mut self, backend: Arc<dyn SchedulingBackend>) -> Self {
264 self.scheduling_backend = Some(backend);
265 self
266 }
267
268 pub fn with_message_backend(mut self, backend: Arc<dyn MessageBackend>) -> Self {
270 self.message_backend = Some(backend);
271 self
272 }
273
274 pub fn set_namespace(&mut self, namespace: impl Into<String>) {
276 self.namespace = namespace.into();
277 }
278
279 fn active_namespace(&self) -> &str {
280 let trimmed = self.namespace.trim();
281 if trimmed.is_empty() {
282 "personal"
283 } else {
284 trimmed
285 }
286 }
287
288 pub async fn dispatch(&self, action: &Action) -> ActionResult {
290 match action {
291 Action::ExecuteCommand { command, args } => self.execute_command(command, args).await,
292 Action::WebSearch { query } => self.web_search(query).await,
293 Action::ScheduleTask { description, cron } => {
294 self.schedule_task(description, cron.as_deref()).await
295 }
296 Action::StoreFact {
297 subject,
298 predicate,
299 object,
300 } => self.store_fact(subject, predicate, object).await,
301 Action::Recall { query } => self.recall(query).await,
302 Action::SendMessage {
303 channel,
304 recipient,
305 content,
306 } => self.send_message(channel, recipient, content).await,
307 }
308 }
309
310 async fn execute_command(&self, command: &str, args: &[String]) -> ActionResult {
312 if !self.config.command_allowlist.contains(&command.to_string()) {
314 return ActionResult::failure(format!("Command '{}' is not in the allowlist", command));
315 }
316
317 let mut cmd = tokio::process::Command::new(command);
319 cmd.args(args)
320 .stdout(std::process::Stdio::piped())
321 .stderr(std::process::Stdio::piped());
322
323 match tokio::time::timeout(
325 tokio::time::Duration::from_secs(self.config.command_timeout_secs),
326 cmd.output(),
327 )
328 .await
329 {
330 Ok(Ok(output)) => {
331 let stdout = String::from_utf8_lossy(&output.stdout);
332 let stderr = String::from_utf8_lossy(&output.stderr);
333
334 if output.status.success() {
335 ActionResult::success(stdout.to_string())
336 } else {
337 ActionResult::failure(format!(
338 "Exit code: {:?}\nstderr: {}",
339 output.status.code(),
340 stderr
341 ))
342 }
343 }
344 Ok(Err(e)) => ActionResult::failure(format!("Failed to execute: {}", e)),
345 Err(_) => ActionResult::failure("Command timed out"),
346 }
347 }
348
349 async fn web_search(&self, query: &str) -> ActionResult {
351 if !self.config.enable_web_search {
352 return ActionResult::failure("Web search is disabled by config");
353 }
354 let Some(backend) = &self.web_search_backend else {
355 return ActionResult::failure("Web search backend not configured");
356 };
357 let top_k = self.config.web_search_top_k.max(1);
358 match backend.search(query, top_k).await {
359 Ok(hits) => {
360 if hits.is_empty() {
361 return ActionResult::success(format!(
362 "web_search ok query=\"{}\" top_k={} hits=0",
363 query, top_k
364 ));
365 }
366 let lines = hits
367 .iter()
368 .enumerate()
369 .map(|(i, hit)| {
370 format!("{}. {} ({}) - {}", i + 1, hit.title, hit.url, hit.snippet)
371 })
372 .collect::<Vec<_>>()
373 .join("\n");
374 ActionResult::success(format!(
375 "web_search ok query=\"{}\" top_k={} hits={}\n{}",
376 query,
377 top_k,
378 hits.len(),
379 lines
380 ))
381 }
382 Err(e) => ActionResult::failure(format!("Web search failed: {e}")),
383 }
384 }
385
386 async fn schedule_task(&self, description: &str, cron: Option<&str>) -> ActionResult {
388 if !self.config.enable_scheduling {
389 return ActionResult::failure("Scheduling is disabled by config");
390 }
391 let Some(backend) = &self.scheduling_backend else {
392 return ActionResult::failure("Scheduling backend not configured");
393 };
394 let namespace = self.active_namespace();
395 match backend.schedule(description, cron, namespace).await {
396 Ok(outcome) => ActionResult::success(format!(
397 "schedule_task ok id={} status={} namespace={} cron={} description=\"{}\"",
398 outcome.schedule_id,
399 outcome.status,
400 namespace,
401 cron.unwrap_or("none"),
402 description
403 )),
404 Err(e) => ActionResult::failure(format!("Schedule task failed: {e}")),
405 }
406 }
407
408 async fn store_fact(&self, subject: &str, predicate: &str, object: &str) -> ActionResult {
410 let Some(memory) = &self.memory_backend else {
411 return ActionResult::failure("Memory backend not available");
412 };
413 let namespace = self.active_namespace();
414
415 match memory
416 .store_fact(namespace, "action", subject, predicate, object)
417 .await
418 {
419 Ok(id) => ActionResult::success(format!(
420 "Fact stored [{}] [{}]: {} {} {}",
421 id, namespace, subject, predicate, object
422 )),
423 Err(e) => ActionResult::failure(format!("Failed to store fact: {e}")),
424 }
425 }
426
427 async fn recall(&self, query: &str) -> ActionResult {
429 let Some(memory) = &self.memory_backend else {
430 return ActionResult::failure("Memory backend not available");
431 };
432 let namespace = self.active_namespace();
433
434 match memory.recall(query, 10, Some(namespace)).await {
435 Ok(results) if results.is_empty() => ActionResult::success("No matching facts found."),
436 Ok(results) => {
437 let lines = results
438 .iter()
439 .map(|r| {
440 format!(
441 "[{}] {} {} {} (confidence: {:.2})",
442 r.namespace, r.subject, r.predicate, r.object, r.confidence
443 )
444 })
445 .collect::<Vec<_>>()
446 .join("\n");
447 ActionResult::success(format!("Found {} fact(s):\n{}", results.len(), lines))
448 }
449 Err(e) => ActionResult::failure(format!("Recall failed: {e}")),
450 }
451 }
452
453 async fn send_message(&self, channel: &str, recipient: &str, content: &str) -> ActionResult {
455 if !self.config.enable_channel_send {
456 return ActionResult::failure("Channel sending is disabled by config");
457 }
458 let Some(backend) = &self.message_backend else {
459 return ActionResult::failure("Message backend not configured");
460 };
461 let namespace = self.active_namespace();
462 match backend.send(channel, recipient, content, namespace).await {
463 Ok(outcome) => ActionResult::success(format!(
464 "send_message ok id={} status={} channel={} recipient={} namespace={}",
465 outcome.delivery_id, outcome.status, channel, recipient, namespace
466 )),
467 Err(e) => ActionResult::failure(format!("Send message failed: {e}")),
468 }
469 }
470}
471
472#[derive(Debug, Clone, serde::Serialize)]
476pub struct ToolDefinition {
477 pub name: String,
478 pub description: String,
479 pub parameters: serde_json::Value,
480}
481
482pub fn get_available_tools() -> Vec<ToolDefinition> {
484 vec![
485 ToolDefinition {
486 name: "execute_command".to_string(),
487 description: "Execute a sandboxed shell command".to_string(),
488 parameters: serde_json::json!({
489 "type": "object",
490 "properties": {
491 "command": {
492 "type": "string",
493 "description": "The command to execute"
494 },
495 "args": {
496 "type": "array",
497 "items": {"type": "string"},
498 "description": "Command arguments"
499 }
500 },
501 "required": ["command"]
502 }),
503 },
504 ToolDefinition {
505 name: "web_search".to_string(),
506 description: "Search the web for information".to_string(),
507 parameters: serde_json::json!({
508 "type": "object",
509 "properties": {
510 "query": {
511 "type": "string",
512 "description": "The search query"
513 }
514 },
515 "required": ["query"]
516 }),
517 },
518 ToolDefinition {
519 name: "store_fact".to_string(),
520 description: "Store a fact in memory".to_string(),
521 parameters: serde_json::json!({
522 "type": "object",
523 "properties": {
524 "subject": {"type": "string"},
525 "predicate": {"type": "string"},
526 "object": {"type": "string"}
527 },
528 "required": ["subject", "predicate", "object"]
529 }),
530 },
531 ToolDefinition {
532 name: "recall".to_string(),
533 description: "Search memory for relevant information".to_string(),
534 parameters: serde_json::json!({
535 "type": "object",
536 "properties": {
537 "query": {
538 "type": "string",
539 "description": "What to search for"
540 }
541 },
542 "required": ["query"]
543 }),
544 },
545 ]
546}
547
548#[cfg(test)]
551mod tests {
552 use super::*;
553 use std::sync::{Arc, Mutex};
554
555 struct MockMemoryBackend {
556 facts: Mutex<Vec<MemoryFact>>,
557 }
558
559 #[async_trait::async_trait]
560 impl MemoryBackend for MockMemoryBackend {
561 async fn store_fact(
562 &self,
563 namespace: &str,
564 _category: &str,
565 subject: &str,
566 predicate: &str,
567 object: &str,
568 ) -> Result<String, ActionError> {
569 self.facts.lock().unwrap().push(MemoryFact {
570 namespace: namespace.to_string(),
571 subject: subject.to_string(),
572 predicate: predicate.to_string(),
573 object: object.to_string(),
574 confidence: 1.0,
575 });
576 Ok("fact-1".to_string())
577 }
578
579 async fn recall(
580 &self,
581 query: &str,
582 _top_k: usize,
583 namespace: Option<&str>,
584 ) -> Result<Vec<MemoryFact>, ActionError> {
585 let facts = self.facts.lock().unwrap();
586 Ok(facts
587 .iter()
588 .filter(|f| {
589 namespace.is_none_or(|ns| f.namespace == ns)
590 && (f.subject.contains(query)
591 || f.predicate.contains(query)
592 || f.object.contains(query))
593 })
594 .cloned()
595 .collect())
596 }
597 }
598
599 struct MockWebSearchBackend;
600
601 #[async_trait::async_trait]
602 impl WebSearchBackend for MockWebSearchBackend {
603 async fn search(&self, query: &str, top_k: usize) -> Result<Vec<SearchHit>, ActionError> {
604 Ok((0..top_k)
605 .map(|i| SearchHit {
606 title: format!("{query} hit {}", i + 1),
607 url: format!("https://example.com/{i}"),
608 snippet: "snippet".to_string(),
609 })
610 .collect())
611 }
612 }
613
614 struct MockSchedulingBackend {
615 calls: Mutex<Vec<(String, Option<String>, String)>>,
616 }
617
618 #[async_trait::async_trait]
619 impl SchedulingBackend for MockSchedulingBackend {
620 async fn schedule(
621 &self,
622 description: &str,
623 cron: Option<&str>,
624 namespace: &str,
625 ) -> Result<ScheduleOutcome, ActionError> {
626 self.calls.lock().expect("calls lock").push((
627 description.to_string(),
628 cron.map(|c| c.to_string()),
629 namespace.to_string(),
630 ));
631 Ok(ScheduleOutcome {
632 schedule_id: "sched-1".to_string(),
633 status: "scheduled".to_string(),
634 })
635 }
636 }
637
638 struct MockMessageBackend {
639 calls: Mutex<Vec<(String, String, String, String)>>,
640 }
641
642 #[async_trait::async_trait]
643 impl MessageBackend for MockMessageBackend {
644 async fn send(
645 &self,
646 channel: &str,
647 recipient: &str,
648 content: &str,
649 namespace: &str,
650 ) -> Result<MessageOutcome, ActionError> {
651 self.calls.lock().expect("calls lock").push((
652 channel.to_string(),
653 recipient.to_string(),
654 content.to_string(),
655 namespace.to_string(),
656 ));
657 Ok(MessageOutcome {
658 delivery_id: "msg-1".to_string(),
659 status: "accepted".to_string(),
660 })
661 }
662 }
663
664 #[test]
665 fn test_action_result_success() {
666 let result = ActionResult::success("output");
667 assert!(result.success);
668 assert_eq!(result.output, "output");
669 assert!(result.error.is_none());
670 }
671
672 #[test]
673 fn test_action_result_failure() {
674 let result = ActionResult::failure("error");
675 assert!(!result.success);
676 assert_eq!(result.error, Some("error".to_string()));
677 }
678
679 #[test]
680 fn test_action_config_default() {
681 let config = ActionConfig::default();
682 assert!(config.command_allowlist.contains(&"ls".to_string()));
683 assert_eq!(config.command_timeout_secs, 30);
684 assert!(config.enable_web_search);
685 assert_eq!(config.web_search_top_k, 5);
686 }
687
688 #[tokio::test]
689 async fn test_execute_allowed_command() {
690 let dispatcher = ActionDispatcher::with_defaults();
691 let action = Action::ExecuteCommand {
692 command: "echo".to_string(),
693 args: vec!["hello".to_string()],
694 };
695
696 let result = dispatcher.dispatch(&action).await;
697 assert!(result.success);
698 assert!(result.output.contains("hello"));
699 }
700
701 #[tokio::test]
702 async fn test_execute_disallowed_command() {
703 let dispatcher = ActionDispatcher::with_defaults();
704 let action = Action::ExecuteCommand {
705 command: "rm".to_string(),
706 args: vec!["-rf".to_string(), "/".to_string()],
707 };
708
709 let result = dispatcher.dispatch(&action).await;
710 assert!(!result.success);
711 assert!(result
712 .error
713 .as_ref()
714 .unwrap()
715 .contains("not in the allowlist"));
716 }
717
718 #[test]
719 fn test_get_available_tools() {
720 let tools = get_available_tools();
721 assert!(!tools.is_empty());
722 assert!(tools.iter().any(|t| t.name == "execute_command"));
723 assert!(tools.iter().any(|t| t.name == "web_search"));
724 }
725
726 #[tokio::test]
727 async fn test_store_fact_with_memory_backend() {
728 let backend = Arc::new(MockMemoryBackend {
729 facts: Mutex::new(Vec::new()),
730 });
731 let dispatcher = ActionDispatcher::with_memory_backend(ActionConfig::default(), backend);
732
733 let action = Action::StoreFact {
734 subject: "user".to_string(),
735 predicate: "likes".to_string(),
736 object: "Rust".to_string(),
737 };
738 let result = dispatcher.dispatch(&action).await;
739 assert!(result.success);
740 assert!(result.output.contains("Fact stored"));
741 }
742
743 #[tokio::test]
744 async fn test_recall_with_memory_backend() {
745 let backend = Arc::new(MockMemoryBackend {
746 facts: Mutex::new(Vec::new()),
747 });
748 let mut dispatcher =
749 ActionDispatcher::with_memory_backend(ActionConfig::default(), backend.clone());
750
751 dispatcher.set_namespace("work");
752 let store = Action::StoreFact {
753 subject: "user".to_string(),
754 predicate: "likes".to_string(),
755 object: "Rust".to_string(),
756 };
757 let _ = dispatcher.dispatch(&store).await;
758
759 dispatcher.set_namespace("personal");
760 let store_personal = Action::StoreFact {
761 subject: "user".to_string(),
762 predicate: "likes".to_string(),
763 object: "Go".to_string(),
764 };
765 let _ = dispatcher.dispatch(&store_personal).await;
766
767 dispatcher.set_namespace("work");
768 let recall = Action::Recall {
769 query: "Rust".to_string(),
770 };
771 let result = dispatcher.dispatch(&recall).await;
772 assert!(result.success);
773 assert!(result.output.contains("Found 1 fact"));
774 assert!(result.output.contains("[work]"));
775 }
776
777 #[tokio::test]
778 async fn test_memory_actions_fail_without_backend() {
779 let dispatcher = ActionDispatcher::with_defaults();
780 let action = Action::Recall {
781 query: "anything".to_string(),
782 };
783 let result = dispatcher.dispatch(&action).await;
784 assert!(!result.success);
785 assert!(result
786 .error
787 .as_deref()
788 .unwrap_or_default()
789 .contains("Memory backend not available"));
790 }
791
792 #[tokio::test]
793 async fn test_web_search_disabled() {
794 let mut cfg = ActionConfig::default();
795 cfg.enable_web_search = false;
796 let dispatcher = ActionDispatcher::new(cfg);
797 let result = dispatcher
798 .dispatch(&Action::WebSearch {
799 query: "rust".to_string(),
800 })
801 .await;
802 assert!(!result.success);
803 assert!(result
804 .error
805 .as_deref()
806 .unwrap_or_default()
807 .contains("disabled by config"));
808 }
809
810 #[tokio::test]
811 async fn test_web_search_backend_not_configured() {
812 let dispatcher = ActionDispatcher::with_defaults();
813 let result = dispatcher
814 .dispatch(&Action::WebSearch {
815 query: "rust".to_string(),
816 })
817 .await;
818 assert!(!result.success);
819 assert!(result
820 .error
821 .as_deref()
822 .unwrap_or_default()
823 .contains("backend not configured"));
824 }
825
826 #[tokio::test]
827 async fn test_web_search_success_with_backend() {
828 let dispatcher = ActionDispatcher::with_defaults()
829 .with_web_search_backend(Arc::new(MockWebSearchBackend));
830 let result = dispatcher
831 .dispatch(&Action::WebSearch {
832 query: "rust".to_string(),
833 })
834 .await;
835 assert!(result.success);
836 assert!(result.output.contains("web_search ok"));
837 assert!(result.output.contains("hits=5"));
838 }
839
840 #[tokio::test]
841 async fn test_schedule_task_backend_matrix() {
842 let mut disabled = ActionConfig::default();
843 disabled.enable_scheduling = false;
844 let dispatcher = ActionDispatcher::new(disabled.clone());
845 let result = dispatcher
846 .dispatch(&Action::ScheduleTask {
847 description: "ship release".to_string(),
848 cron: Some("0 10 * * 1".to_string()),
849 })
850 .await;
851 assert!(!result.success);
852 assert!(result
853 .error
854 .as_deref()
855 .unwrap_or_default()
856 .contains("disabled by config"));
857
858 disabled.enable_scheduling = true;
859 let unconfigured = ActionDispatcher::new(disabled.clone());
860 let result = unconfigured
861 .dispatch(&Action::ScheduleTask {
862 description: "ship release".to_string(),
863 cron: Some("0 10 * * 1".to_string()),
864 })
865 .await;
866 assert!(!result.success);
867 assert!(result
868 .error
869 .as_deref()
870 .unwrap_or_default()
871 .contains("backend not configured"));
872
873 let backend = Arc::new(MockSchedulingBackend {
874 calls: Mutex::new(Vec::new()),
875 });
876 let backend_trait: Arc<dyn SchedulingBackend> = backend.clone();
877 let mut configured = ActionDispatcher::new(disabled).with_scheduling_backend(backend_trait);
878 configured.set_namespace("work");
879 let result = configured
880 .dispatch(&Action::ScheduleTask {
881 description: "ship release".to_string(),
882 cron: Some("0 10 * * 1".to_string()),
883 })
884 .await;
885 assert!(result.success);
886 let calls = backend.calls.lock().expect("calls lock");
887 assert_eq!(calls.len(), 1);
888 assert_eq!(calls[0].2, "work");
889 }
890
891 #[tokio::test]
892 async fn test_send_message_backend_matrix() {
893 let mut disabled = ActionConfig::default();
894 disabled.enable_channel_send = false;
895 let dispatcher = ActionDispatcher::new(disabled.clone());
896 let result = dispatcher
897 .dispatch(&Action::SendMessage {
898 channel: "ops".to_string(),
899 recipient: "alice".to_string(),
900 content: "deploy now".to_string(),
901 })
902 .await;
903 assert!(!result.success);
904 assert!(result
905 .error
906 .as_deref()
907 .unwrap_or_default()
908 .contains("disabled by config"));
909
910 disabled.enable_channel_send = true;
911 let unconfigured = ActionDispatcher::new(disabled.clone());
912 let result = unconfigured
913 .dispatch(&Action::SendMessage {
914 channel: "ops".to_string(),
915 recipient: "alice".to_string(),
916 content: "deploy now".to_string(),
917 })
918 .await;
919 assert!(!result.success);
920 assert!(result
921 .error
922 .as_deref()
923 .unwrap_or_default()
924 .contains("backend not configured"));
925
926 let backend = Arc::new(MockMessageBackend {
927 calls: Mutex::new(Vec::new()),
928 });
929 let backend_trait: Arc<dyn MessageBackend> = backend.clone();
930 let mut configured = ActionDispatcher::new(disabled).with_message_backend(backend_trait);
931 configured.set_namespace("project-x");
932 let result = configured
933 .dispatch(&Action::SendMessage {
934 channel: "ops".to_string(),
935 recipient: "alice".to_string(),
936 content: "deploy now".to_string(),
937 })
938 .await;
939 assert!(result.success);
940 let calls = backend.calls.lock().expect("calls lock");
941 assert_eq!(calls.len(), 1);
942 assert_eq!(calls[0].3, "project-x");
943 }
944}