things_mcp/core/writer/
executor.rs1use std::sync::Mutex;
7
8use async_trait::async_trait;
9
10use crate::core::error::ThingsError;
11
12#[async_trait]
13pub trait Executor: Send + Sync + std::fmt::Debug {
14 async fn open(&self, url: &str) -> Result<(), ThingsError>;
19}
20
21#[derive(Debug, Default)]
25pub struct OpenCommandExecutor;
26
27#[async_trait]
28impl Executor for OpenCommandExecutor {
29 async fn open(&self, url: &str) -> Result<(), ThingsError> {
30 let status = tokio::process::Command::new("/usr/bin/open")
31 .arg("-g")
32 .arg(url)
33 .status()
34 .await
35 .map_err(|e| ThingsError::ExecutorFailed {
36 message: format!("spawn /usr/bin/open: {e}"),
37 })?;
38 if !status.success() {
39 return Err(ThingsError::ExecutorFailed {
40 message: format!("/usr/bin/open exited {status}"),
41 });
42 }
43 Ok(())
44 }
45}
46
47#[derive(Debug, Default)]
50pub struct RecordingExecutor {
51 urls: Mutex<Vec<String>>,
52}
53
54impl RecordingExecutor {
55 pub fn new() -> Self {
56 Self::default()
57 }
58
59 pub fn urls(&self) -> Vec<String> {
60 self.urls.lock().unwrap().clone()
61 }
62}
63
64#[async_trait]
65impl Executor for RecordingExecutor {
66 async fn open(&self, url: &str) -> Result<(), ThingsError> {
67 self.urls.lock().unwrap().push(url.to_string());
68 Ok(())
69 }
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75
76 #[tokio::test]
77 async fn recording_executor_captures_urls_in_order() {
78 let rec = RecordingExecutor::new();
79 rec.open("things:///json?data=%5B%5D").await.unwrap();
80 rec.open("things:///json?data=%5Bx%5D").await.unwrap();
81 let urls = rec.urls();
82 assert_eq!(urls.len(), 2);
83 assert!(urls[0].contains("%5B%5D"));
84 assert!(urls[1].contains("%5Bx%5D"));
85 }
86
87 #[tokio::test]
91 #[ignore = "fires /usr/bin/open against the real Things app"]
92 async fn open_command_executor_smoke() {
93 let exec = OpenCommandExecutor;
94 exec.open("things:///")
95 .await
96 .expect("open should not fail");
97 }
98}