Skip to main content

things_mcp/core/applescript/
admin.rs

1//! `TagAdmin` — facade over the AppleScript driver. Owns the safety gate
2//! and composes a `TagOutcome` per call. Each method renders the script
3//! via the pure helpers in `script.rs`, then either short-circuits
4//! (DryRun), errors out (Forbidden), or hands the script to the injected
5//! `AppleScriptDriver`.
6
7use std::sync::Arc;
8use std::time::Instant;
9
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13use crate::core::applescript::driver::AppleScriptDriver;
14use crate::core::applescript::script;
15use crate::core::error::ThingsError;
16use crate::core::writer::writer::SafetyMode;
17
18#[derive(Debug)]
19pub struct TagAdmin {
20    pub driver: Arc<dyn AppleScriptDriver>,
21    pub safety: SafetyMode,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
25pub struct TagOutcome {
26    /// Snake-case action name — `"create_tag"`, `"rename_tag"`, …
27    pub action: String,
28    /// `true` when the safety gate short-circuited (DryRun mode). When
29    /// `true`, the script was rendered but never run; `osascript_stdout`
30    /// is empty.
31    pub dry_run: bool,
32    /// Wall-clock latency in milliseconds. `0` when `dry_run` is true.
33    pub latency_ms: u64,
34    /// First line of `osascript` stdout, truncated to 200 chars. Empty when
35    /// the script returned no output (the common case for tag-admin ops).
36    pub osascript_stdout: String,
37}
38
39impl TagAdmin {
40    pub async fn create(&self, name: &str, parent: Option<&str>) -> Result<TagOutcome, ThingsError> {
41        let script = script::render_create_tag(name, parent);
42        self.dispatch("create_tag", script).await
43    }
44
45    pub async fn rename(&self, old: &str, new: &str) -> Result<TagOutcome, ThingsError> {
46        let script = script::render_rename_tag(old, new);
47        self.dispatch("rename_tag", script).await
48    }
49
50    pub async fn merge(&self, source: &str, target: &str) -> Result<TagOutcome, ThingsError> {
51        // Defense-in-depth: the tool adapter also rejects this, but a stray
52        // direct caller would render a script that deletes the only copy
53        // and then tries to read from it.
54        if source == target {
55            return Err(ThingsError::InvalidInput {
56                field: "source".into(),
57                reason: "source and target must differ".into(),
58            });
59        }
60        let script = script::render_merge_tags(source, target);
61        self.dispatch("merge_tags", script).await
62    }
63
64    pub async fn delete(&self, name: &str) -> Result<TagOutcome, ThingsError> {
65        let script = script::render_delete_tag(name);
66        self.dispatch("delete_tag", script).await
67    }
68
69    pub async fn move_under(
70        &self,
71        name: &str,
72        new_parent: Option<&str>,
73    ) -> Result<TagOutcome, ThingsError> {
74        let script = script::render_move_tag(name, new_parent);
75        self.dispatch("move_tag", script).await
76    }
77
78    async fn dispatch(&self, action: &str, script: String) -> Result<TagOutcome, ThingsError> {
79        // 1. Safety gate — Forbidden refuses outright.
80        if self.safety == SafetyMode::Forbidden {
81            return Err(ThingsError::TestDbWriteForbidden);
82        }
83
84        // 2. Log the script (no secrets to mask — AppleScript doesn't carry
85        // the auth-token).
86        tracing::info!(action = action, "applescript: {} bytes", script.len());
87
88        // 3. DryRun short-circuit — render only, no driver call.
89        if self.safety == SafetyMode::DryRun {
90            return Ok(TagOutcome {
91                action: action.to_string(),
92                dry_run: true,
93                latency_ms: 0,
94                osascript_stdout: String::new(),
95            });
96        }
97
98        // 4. Live: hand the script to the driver.
99        let started = Instant::now();
100        let stdout = self.driver.run(&script).await?;
101        let latency_ms = started.elapsed().as_millis() as u64;
102        let truncated = truncate_first_line(&stdout, 200);
103
104        Ok(TagOutcome {
105            action: action.to_string(),
106            dry_run: false,
107            latency_ms,
108            osascript_stdout: truncated,
109        })
110    }
111}
112
113fn truncate_first_line(s: &str, max: usize) -> String {
114    let first = s.lines().next().unwrap_or("");
115    // Char-based throughout so multi-byte sequences (emoji, CJK in
116    // osascript stdout) never get split mid-codepoint.
117    if first.chars().count() <= max {
118        first.to_string()
119    } else {
120        first.chars().take(max).collect()
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::core::applescript::driver::RecordingAppleScript;
128
129    fn admin(safety: SafetyMode) -> (Arc<RecordingAppleScript>, TagAdmin) {
130        let rec = Arc::new(RecordingAppleScript::new());
131        let admin = TagAdmin {
132            driver: rec.clone(),
133            safety,
134        };
135        (rec, admin)
136    }
137
138    #[tokio::test]
139    async fn forbidden_mode_refuses_outright() {
140        let (rec, admin) = admin(SafetyMode::Forbidden);
141        let res = admin.create("Work", None).await;
142        assert!(matches!(res, Err(ThingsError::TestDbWriteForbidden)));
143        // Driver must NOT have been called.
144        assert!(rec.scripts().is_empty());
145    }
146
147    #[tokio::test]
148    async fn dry_run_mode_short_circuits_without_calling_driver() {
149        let (rec, admin) = admin(SafetyMode::DryRun);
150        let out = admin.create("Work", None).await.unwrap();
151        assert!(out.dry_run);
152        assert_eq!(out.action, "create_tag");
153        assert_eq!(out.latency_ms, 0);
154        assert_eq!(out.osascript_stdout, "");
155        // Driver was never invoked.
156        assert!(rec.scripts().is_empty());
157    }
158
159    #[tokio::test]
160    async fn live_create_calls_driver_with_rendered_script() {
161        let (rec, admin) = admin(SafetyMode::Live);
162        let out = admin.create("Work", Some("Personal")).await.unwrap();
163        assert!(!out.dry_run);
164        assert_eq!(out.action, "create_tag");
165        let scripts = rec.scripts();
166        assert_eq!(scripts.len(), 1);
167        assert!(scripts[0].contains("make new tag with properties {name:\"Work\"}"));
168        assert!(scripts[0].contains("set parent tag of newTag to tag \"Personal\""));
169    }
170
171    #[tokio::test]
172    async fn live_rename_calls_driver_with_rendered_script() {
173        let (rec, admin) = admin(SafetyMode::Live);
174        let _out = admin.rename("Old", "New").await.unwrap();
175        let scripts = rec.scripts();
176        assert!(scripts[0].contains("set name of tag \"Old\" to \"New\""));
177    }
178
179    #[tokio::test]
180    async fn live_merge_calls_driver_with_rendered_script() {
181        let (rec, admin) = admin(SafetyMode::Live);
182        let _out = admin.merge("Source", "Target").await.unwrap();
183        let scripts = rec.scripts();
184        assert!(scripts[0].contains("set sourceTag to tag \"Source\""));
185        assert!(scripts[0].contains("delete sourceTag"));
186    }
187
188    #[tokio::test]
189    async fn merge_self_rejected_with_invalid_input() {
190        let (rec, admin) = admin(SafetyMode::Live);
191        let res = admin.merge("Same", "Same").await;
192        match res {
193            Err(ThingsError::InvalidInput { field, .. }) => assert_eq!(field, "source"),
194            other => panic!("expected InvalidInput, got {:?}", other),
195        }
196        // Driver must not have been called for the self-merge.
197        assert!(rec.scripts().is_empty());
198    }
199
200    #[tokio::test]
201    async fn live_delete_calls_driver_with_rendered_script() {
202        let (rec, admin) = admin(SafetyMode::Live);
203        let _out = admin.delete("Stale").await.unwrap();
204        let scripts = rec.scripts();
205        assert!(scripts[0].contains("delete tag \"Stale\""));
206    }
207
208    #[tokio::test]
209    async fn live_move_under_parent_calls_driver_with_rendered_script() {
210        let (rec, admin) = admin(SafetyMode::Live);
211        let _out = admin.move_under("Urgent", Some("Work")).await.unwrap();
212        let scripts = rec.scripts();
213        assert!(scripts[0].contains("set parent tag of tag \"Urgent\" to tag \"Work\""));
214    }
215
216    #[tokio::test]
217    async fn live_move_to_root_deletes_parent_property() {
218        let (rec, admin) = admin(SafetyMode::Live);
219        let _out = admin.move_under("Urgent", None).await.unwrap();
220        let scripts = rec.scripts();
221        assert!(scripts[0].contains("delete parent tag of tag \"Urgent\""));
222    }
223
224    #[tokio::test]
225    async fn driver_error_propagates_unchanged() {
226        let (rec, admin) = admin(SafetyMode::Live);
227        rec.push_response(Err(ThingsError::AppleScriptFailed {
228            stderr: "tag not found".into(),
229            exit: 1,
230        }));
231        let res = admin.delete("Ghost").await;
232        match res {
233            Err(ThingsError::AppleScriptFailed { stderr, exit }) => {
234                assert_eq!(stderr, "tag not found");
235                assert_eq!(exit, 1);
236            }
237            other => panic!("expected AppleScriptFailed, got {:?}", other),
238        }
239    }
240
241    #[test]
242    fn truncate_first_line_counts_chars_not_bytes() {
243        // 5 emoji = 5 chars but 20 bytes (4 bytes each in UTF-8). With a
244        // 5-char cap, the result should be the unchanged input.
245        let input = "🏷🏷🏷🏷🏷";
246        assert_eq!(input.chars().count(), 5);
247        assert_eq!(input.len(), 20);
248        assert_eq!(truncate_first_line(input, 5), input);
249        // With a 3-char cap, the result should be 3 emoji — no byte-boundary
250        // split would panic if the byte-based check had skipped the truncate
251        // branch.
252        let out = truncate_first_line(input, 3);
253        assert_eq!(out.chars().count(), 3);
254    }
255}