use std::collections::VecDeque;
use std::sync::Mutex;
use async_trait::async_trait;
use crate::core::error::ThingsError;
#[async_trait]
pub trait AppleScriptDriver: Send + Sync + std::fmt::Debug {
async fn run(&self, script: &str) -> Result<String, ThingsError>;
}
#[derive(Debug, Default)]
pub struct OsascriptDriver;
#[async_trait]
impl AppleScriptDriver for OsascriptDriver {
async fn run(&self, script: &str) -> Result<String, ThingsError> {
let output = tokio::process::Command::new("/usr/bin/osascript")
.arg("-e")
.arg(script)
.output()
.await
.map_err(|e| ThingsError::ExecutorFailed {
message: format!("spawn /usr/bin/osascript: {e}"),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let exit = output.status.code().unwrap_or(-1);
return Err(ThingsError::AppleScriptFailed { stderr, exit });
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
}
#[derive(Debug, Default)]
pub struct RecordingAppleScript {
scripts: Mutex<Vec<String>>,
responses: Mutex<VecDeque<Result<String, ThingsError>>>,
}
impl RecordingAppleScript {
pub fn new() -> Self {
Self::default()
}
pub fn scripts(&self) -> Vec<String> {
self.scripts.lock().unwrap().clone()
}
pub fn push_response(&self, response: Result<String, ThingsError>) {
self.responses.lock().unwrap().push_back(response);
}
}
#[async_trait]
impl AppleScriptDriver for RecordingAppleScript {
async fn run(&self, script: &str) -> Result<String, ThingsError> {
self.scripts.lock().unwrap().push(script.to_string());
match self.responses.lock().unwrap().pop_front() {
Some(r) => r,
None => Ok(String::new()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn recording_driver_captures_scripts_in_order() {
let rec = RecordingAppleScript::new();
rec.run("tell application \"Things3\" to make tag with properties {name:\"Work\"}")
.await
.unwrap();
rec.run("tell application \"Things3\" to delete tag \"Old\"")
.await
.unwrap();
let scripts = rec.scripts();
assert_eq!(scripts.len(), 2);
assert!(scripts[0].contains("make tag"));
assert!(scripts[1].contains("delete tag"));
}
#[tokio::test]
async fn recording_driver_replays_queued_responses_in_order() {
let rec = RecordingAppleScript::new();
rec.push_response(Ok("first".into()));
rec.push_response(Err(ThingsError::AppleScriptFailed {
stderr: "boom".into(),
exit: 1,
}));
let r1 = rec.run("a").await.unwrap();
assert_eq!(r1, "first");
let r2 = rec.run("b").await;
assert!(matches!(r2, Err(ThingsError::AppleScriptFailed { exit: 1, .. })));
let r3 = rec.run("c").await.unwrap();
assert_eq!(r3, "");
}
#[tokio::test]
#[ignore = "fires /usr/bin/osascript on the local machine"]
async fn osascript_driver_smoke() {
let driver = OsascriptDriver;
let out = driver
.run("return \"hello\"")
.await
.expect("osascript should run");
assert!(out.contains("hello"));
}
}