Skip to main content

things_mcp/core/applescript/
script.rs

1//! Pure AppleScript render functions for tag-admin ops. No I/O — each
2//! function takes the inputs the tool surface accepts and returns the
3//! AppleScript source as a `String`. The driver (`OsascriptDriver`) and
4//! the facade (`TagAdmin`) are the layers that actually run the script.
5
6/// Escape a user-supplied string for safe inclusion inside an AppleScript
7/// double-quoted literal. AppleScript's escape rules: backslash escapes
8/// itself and a literal double quote.
9pub fn escape_applescript_string(s: &str) -> String {
10    s.replace('\\', "\\\\").replace('"', "\\\"")
11}
12
13pub fn render_create_tag(name: &str, parent: Option<&str>) -> String {
14    let name_q = escape_applescript_string(name);
15    match parent {
16        Some(p) => {
17            let p_q = escape_applescript_string(p);
18            format!(
19                r#"tell application "Things3"
20    set newTag to make new tag with properties {{name:"{name_q}"}}
21    set parent tag of newTag to tag "{p_q}"
22end tell"#,
23            )
24        }
25        None => format!(
26            r#"tell application "Things3"
27    make new tag with properties {{name:"{name_q}"}}
28end tell"#,
29        ),
30    }
31}
32
33pub fn render_rename_tag(old: &str, new: &str) -> String {
34    let old_q = escape_applescript_string(old);
35    let new_q = escape_applescript_string(new);
36    format!(
37        r#"tell application "Things3"
38    set name of tag "{old_q}" to "{new_q}"
39end tell"#,
40    )
41}
42
43/// Reassign every to-do that carries `source` to also carry `target`, then
44/// delete the `source` tag. AppleScript surface: `to dos of tag "source"`
45/// enumerates the tasks; we add the target tag to each and then remove the
46/// source tag from the global tag list.
47pub fn render_merge_tags(source: &str, target: &str) -> String {
48    let s_q = escape_applescript_string(source);
49    let t_q = escape_applescript_string(target);
50    format!(
51        r#"tell application "Things3"
52    set sourceTag to tag "{s_q}"
53    set targetTag to tag "{t_q}"
54    repeat with t in (to dos of sourceTag)
55        set tag names of t to (tag names of t) & "{t_q}"
56    end repeat
57    delete sourceTag
58end tell"#,
59    )
60}
61
62pub fn render_delete_tag(name: &str) -> String {
63    let name_q = escape_applescript_string(name);
64    format!(
65        r#"tell application "Things3"
66    delete tag "{name_q}"
67end tell"#,
68    )
69}
70
71pub fn render_move_tag(name: &str, new_parent: Option<&str>) -> String {
72    let name_q = escape_applescript_string(name);
73    match new_parent {
74        Some(p) => {
75            let p_q = escape_applescript_string(p);
76            format!(
77                r#"tell application "Things3"
78    set parent tag of tag "{name_q}" to tag "{p_q}"
79end tell"#,
80            )
81        }
82        // Things 3 statically types the `parent tag` property as `tag` (no
83        // nullable marker in `Things.sdef`), so `set parent tag … to missing
84        // value` is rejected with a -1700 coercion error. `delete parent tag
85        // of …` is the only AppleScript form Things 3 accepts to clear the
86        // relationship — i.e., promote the tag to the root of the tag tree.
87        None => format!(
88            r#"tell application "Things3"
89    delete parent tag of tag "{name_q}"
90end tell"#,
91        ),
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn escape_handles_quotes_and_backslashes() {
101        assert_eq!(escape_applescript_string("plain"), "plain");
102        assert_eq!(escape_applescript_string("he said \"hi\""), "he said \\\"hi\\\"");
103        assert_eq!(escape_applescript_string("path\\to"), "path\\\\to");
104    }
105
106    #[test]
107    fn create_tag_no_parent_renders_make_tag() {
108        let s = render_create_tag("Work", None);
109        assert!(s.contains("tell application \"Things3\""));
110        assert!(s.contains("make new tag with properties {name:\"Work\"}"));
111        assert!(!s.contains("parent tag"));
112    }
113
114    #[test]
115    fn create_tag_with_parent_renders_parent_link() {
116        let s = render_create_tag("Urgent", Some("Work"));
117        assert!(s.contains("make new tag with properties {name:\"Urgent\"}"));
118        assert!(s.contains("set parent tag of newTag to tag \"Work\""));
119    }
120
121    #[test]
122    fn create_tag_escapes_quotes_in_name() {
123        let s = render_create_tag("She said \"yes\"", None);
124        assert!(s.contains("name:\"She said \\\"yes\\\"\""));
125    }
126
127    #[test]
128    fn rename_tag_renders_set_name() {
129        let s = render_rename_tag("Old", "New");
130        assert!(s.contains("set name of tag \"Old\" to \"New\""));
131    }
132
133    #[test]
134    fn rename_tag_escapes_quotes() {
135        let s = render_rename_tag("a\"b", "c\"d");
136        assert!(s.contains("set name of tag \"a\\\"b\" to \"c\\\"d\""));
137    }
138
139    #[test]
140    fn merge_tags_renders_loop_and_delete() {
141        let s = render_merge_tags("Source", "Target");
142        assert!(s.contains("set sourceTag to tag \"Source\""));
143        assert!(s.contains("set targetTag to tag \"Target\""));
144        assert!(s.contains("repeat with t in (to dos of sourceTag)"));
145        assert!(s.contains("delete sourceTag"));
146    }
147
148    #[test]
149    fn merge_tags_escapes_quotes_in_target_inside_loop_body() {
150        let s = render_merge_tags("A", "B \"quoted\"");
151        // The loop body assigns the target name via concatenation; the
152        // escaped form must appear in both the binding line and the loop.
153        assert!(s.contains("set targetTag to tag \"B \\\"quoted\\\"\""));
154        assert!(s.contains("& \"B \\\"quoted\\\"\""));
155    }
156
157    #[test]
158    fn delete_tag_renders_delete() {
159        let s = render_delete_tag("Stale");
160        assert!(s.contains("delete tag \"Stale\""));
161    }
162
163    #[test]
164    fn delete_tag_escapes_quotes() {
165        let s = render_delete_tag("Bad\"name");
166        assert!(s.contains("delete tag \"Bad\\\"name\""));
167    }
168
169    #[test]
170    fn move_tag_under_parent_renders_set_parent() {
171        let s = render_move_tag("Urgent", Some("Work"));
172        assert!(s.contains("set parent tag of tag \"Urgent\" to tag \"Work\""));
173    }
174
175    #[test]
176    fn move_tag_to_root_deletes_parent_property() {
177        // Things 3 statically types `parent tag` as `tag` (no nullable marker
178        // in the sdef), so `to missing value` is rejected with a -1700
179        // coercion error. The only working form is `delete parent tag of …`,
180        // which clears the relationship.
181        let s = render_move_tag("Urgent", None);
182        assert!(s.contains("delete parent tag of tag \"Urgent\""));
183        assert!(!s.contains("missing value"));
184    }
185
186    #[test]
187    fn move_tag_escapes_quotes_in_both_names() {
188        let s = render_move_tag("a\"b", Some("c\"d"));
189        assert!(s.contains("set parent tag of tag \"a\\\"b\" to tag \"c\\\"d\""));
190    }
191}