things_mcp/core/applescript/
driver.rs1use std::collections::VecDeque;
9use std::sync::Mutex;
10
11use async_trait::async_trait;
12
13use crate::core::error::ThingsError;
14
15#[async_trait]
16pub trait AppleScriptDriver: Send + Sync + std::fmt::Debug {
17 async fn run(&self, script: &str) -> Result<String, ThingsError>;
20}
21
22#[derive(Debug, Default)]
29pub struct OsascriptDriver;
30
31#[async_trait]
32impl AppleScriptDriver for OsascriptDriver {
33 async fn run(&self, script: &str) -> Result<String, ThingsError> {
34 let output = tokio::process::Command::new("/usr/bin/osascript")
35 .arg("-e")
36 .arg(script)
37 .output()
38 .await
39 .map_err(|e| ThingsError::ExecutorFailed {
40 message: format!("spawn /usr/bin/osascript: {e}"),
41 })?;
42
43 if !output.status.success() {
44 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
45 let exit = output.status.code().unwrap_or(-1);
46 return Err(ThingsError::AppleScriptFailed { stderr, exit });
47 }
48
49 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
50 }
51}
52
53#[derive(Debug, Default)]
57pub struct RecordingAppleScript {
58 scripts: Mutex<Vec<String>>,
59 responses: Mutex<VecDeque<Result<String, ThingsError>>>,
60}
61
62impl RecordingAppleScript {
63 pub fn new() -> Self {
64 Self::default()
65 }
66
67 pub fn scripts(&self) -> Vec<String> {
69 self.scripts.lock().unwrap().clone()
70 }
71
72 pub fn push_response(&self, response: Result<String, ThingsError>) {
75 self.responses.lock().unwrap().push_back(response);
76 }
77}
78
79#[async_trait]
80impl AppleScriptDriver for RecordingAppleScript {
81 async fn run(&self, script: &str) -> Result<String, ThingsError> {
82 self.scripts.lock().unwrap().push(script.to_string());
83 match self.responses.lock().unwrap().pop_front() {
84 Some(r) => r,
85 None => Ok(String::new()),
86 }
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 #[tokio::test]
95 async fn recording_driver_captures_scripts_in_order() {
96 let rec = RecordingAppleScript::new();
97 rec.run("tell application \"Things3\" to make tag with properties {name:\"Work\"}")
98 .await
99 .unwrap();
100 rec.run("tell application \"Things3\" to delete tag \"Old\"")
101 .await
102 .unwrap();
103 let scripts = rec.scripts();
104 assert_eq!(scripts.len(), 2);
105 assert!(scripts[0].contains("make tag"));
106 assert!(scripts[1].contains("delete tag"));
107 }
108
109 #[tokio::test]
110 async fn recording_driver_replays_queued_responses_in_order() {
111 let rec = RecordingAppleScript::new();
112 rec.push_response(Ok("first".into()));
113 rec.push_response(Err(ThingsError::AppleScriptFailed {
114 stderr: "boom".into(),
115 exit: 1,
116 }));
117 let r1 = rec.run("a").await.unwrap();
118 assert_eq!(r1, "first");
119 let r2 = rec.run("b").await;
120 assert!(matches!(r2, Err(ThingsError::AppleScriptFailed { exit: 1, .. })));
121 let r3 = rec.run("c").await.unwrap();
123 assert_eq!(r3, "");
124 }
125
126 #[tokio::test]
130 #[ignore = "fires /usr/bin/osascript on the local machine"]
131 async fn osascript_driver_smoke() {
132 let driver = OsascriptDriver;
133 let out = driver
135 .run("return \"hello\"")
136 .await
137 .expect("osascript should run");
138 assert!(out.contains("hello"));
139 }
140}