Skip to main content

things_mcp/core/writer/
executor.rs

1//! Executor seam: how a built URL gets handed to macOS so Things can
2//! process it. Production uses `OpenCommandExecutor` (spawns
3//! `/usr/bin/open -g <url>`). Tests substitute `RecordingExecutor`,
4//! which captures URLs without spawning anything.
5
6use 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    /// Hand a `things://` URL to the platform. Returns once `/usr/bin/open`
15    /// (or the test substitute) has been invoked — does NOT wait for the
16    /// Things app to actually process it. Post-write verification is the
17    /// `verify` module's job.
18    async fn open(&self, url: &str) -> Result<(), ThingsError>;
19}
20
21/// Production executor: shells out to `/usr/bin/open -g <url>`.
22/// The `-g` flag opens the URL in the background so Things doesn't yank
23/// focus from whatever the user is doing.
24#[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/// Test executor: records every URL it's asked to open without spawning
48/// anything. Use `urls()` to inspect what was captured.
49#[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    // Manual smoke test: opt-in only — fires `/usr/bin/open` against the
88    // user's real Things app. Run with `cargo test -- --ignored
89    // open_command_executor_smoke` only when you mean to.
90    #[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}