Skip to main content

things_mcp/core/applescript/
driver.rs

1//! AppleScript driver seam.
2//!
3//! Production: `OsascriptDriver` spawns `/usr/bin/osascript -e <script>`.
4//! Tests: `RecordingAppleScript` captures every script string and pops queued
5//! responses, so unit tests can assert exactly which AppleScript was emitted
6//! without ever spawning `osascript`.
7
8use 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    /// Run the given AppleScript source. Returns stdout on success; returns
18    /// `ThingsError::AppleScriptFailed { stderr, exit }` on non-zero exit.
19    async fn run(&self, script: &str) -> Result<String, ThingsError>;
20}
21
22/// Production driver: shells out to `/usr/bin/osascript -e <script>`.
23///
24/// Things-not-running: a `tell application "Things3"` block in the rendered
25/// script transparently launches Things on first call, so no explicit "is
26/// Things running" probe is needed here. The startup `schema_probe` already
27/// covers DB-side health.
28#[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/// Test driver: records every script it's asked to run without spawning
54/// `osascript`. Tests assert on `scripts()` and seed `push_response()` to
55/// control return values.
56#[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    /// Returns every script that has been passed to `run()`, in call order.
68    pub fn scripts(&self) -> Vec<String> {
69        self.scripts.lock().unwrap().clone()
70    }
71
72    /// Queue a response that the *next* call to `run()` will return. If no
73    /// response has been queued, `run()` returns `Ok(String::new())`.
74    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        // Queue is now empty — the next call gets the default Ok(String::new()).
122        let r3 = rec.run("c").await.unwrap();
123        assert_eq!(r3, "");
124    }
125
126    // Manual smoke test: opt-in only — fires `/usr/bin/osascript` against
127    // the local machine. Run with `cargo test -- --ignored
128    // osascript_driver_smoke` only when you intend to.
129    #[tokio::test]
130    #[ignore = "fires /usr/bin/osascript on the local machine"]
131    async fn osascript_driver_smoke() {
132        let driver = OsascriptDriver;
133        // Trivial script that returns "hello" — does NOT talk to Things.
134        let out = driver
135            .run("return \"hello\"")
136            .await
137            .expect("osascript should run");
138        assert!(out.contains("hello"));
139    }
140}