things_mcp/core/applescript/
admin.rs1use 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 pub action: String,
28 pub dry_run: bool,
32 pub latency_ms: u64,
34 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 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 if self.safety == SafetyMode::Forbidden {
81 return Err(ThingsError::TestDbWriteForbidden);
82 }
83
84 tracing::info!(action = action, "applescript: {} bytes", script.len());
87
88 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 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 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 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 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 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_uses_missing_value() {
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("set parent tag of tag \"Urgent\" to missing value"));
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 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 let out = truncate_first_line(input, 3);
253 assert_eq!(out.chars().count(), 3);
254 }
255}