1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Class {
9 Read,
10 Write,
11 Destructive,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct ToolMeta {
16 pub class: Class,
17 pub group: &'static str,
18}
19
20const KNOWN_GROUPS: &[(&str, &str)] = &[
23 ("task_type", "task-type"),
24 ("audit_log", "audit-log"),
25 ("auth", "auth"),
26 ("workspace", "workspace"),
27 ("space", "space"),
28 ("folder", "folder"),
29 ("list", "list"),
30 ("task", "task"),
31 ("checklist", "checklist"),
32 ("comment", "comment"),
33 ("tag", "tag"),
34 ("field", "field"),
35 ("attachment", "attachment"),
36 ("time", "time"),
37 ("goal", "goal"),
38 ("view", "view"),
39 ("member", "member"),
40 ("user", "user"),
41 ("chat", "chat"),
42 ("doc", "doc"),
43 ("webhook", "webhook"),
44 ("template", "template"),
45 ("guest", "guest"),
46 ("group", "group"),
47 ("role", "role"),
48 ("shared", "shared"),
49 ("acl", "acl"),
50];
51
52const READ_VERBS: &[&str] = &[
53 "list",
54 "get",
55 "search",
56 "current",
57 "pages",
58 "followers",
59 "members",
60 "history",
61 "whoami",
62 "check",
63 "replies",
64 "tagged",
65 "query",
66];
67
68const WRITE_VERBS: &[&str] = &[
69 "create", "update", "set", "add", "start", "stop", "move", "apply", "invite", "rename",
70 "share", "attach", "link", "reply", "send", "dm", "edit", "upload",
71];
72
73const DESTRUCTIVE_VERBS: &[&str] = &["delete", "remove", "unshare", "unlink", "unset"];
74
75const OVERRIDES: &[(&str, Class, &str)] = &[
78 ("clickup_search", Class::Read, "workspace"),
79 ("clickup_whoami", Class::Read, "auth"),
80 ("clickup_workspace_plan", Class::Read, "workspace"),
81 ("clickup_workspace_seats", Class::Read, "workspace"),
82 ("clickup_task_replace_estimates", Class::Write, "task"),
83 ("clickup_task_time_in_status", Class::Read, "task"),
84 ("clickup_time_tags", Class::Read, "time"),
85 ("clickup_template_apply_list", Class::Write, "template"),
86 ("clickup_doc_get_page", Class::Read, "doc"),
87 ("clickup_chat_tagged_users", Class::Read, "chat"),
88 ("clickup_view_tasks", Class::Read, "view"),
89 ("clickup_guest_share_list", Class::Write, "guest"),
90];
91
92pub fn classify(tool_name: &str) -> Option<ToolMeta> {
93 if let Some(&(_, class, group)) = OVERRIDES.iter().find(|(n, _, _)| *n == tool_name) {
95 return Some(ToolMeta { class, group });
96 }
97
98 let rest = tool_name.strip_prefix("clickup_")?;
100 let (raw_prefix, normalized_group) = KNOWN_GROUPS
101 .iter()
102 .find(|(prefix, _)| rest == *prefix || rest.starts_with(&format!("{}_", prefix)))
103 .copied()?;
104 let remainder = rest
105 .strip_prefix(raw_prefix)
106 .and_then(|r| r.strip_prefix('_'))
107 .unwrap_or("");
108
109 if remainder.is_empty() {
110 return None;
111 }
112
113 let segments: Vec<&str> = remainder.split('_').collect();
114 let last = *segments.last().unwrap();
115
116 if segments.iter().any(|s| DESTRUCTIVE_VERBS.contains(s)) {
118 return Some(ToolMeta {
119 class: Class::Destructive,
120 group: normalized_group,
121 });
122 }
123
124 if WRITE_VERBS.contains(&last) {
126 return Some(ToolMeta {
127 class: Class::Write,
128 group: normalized_group,
129 });
130 }
131 if READ_VERBS.contains(&last) {
132 return Some(ToolMeta {
133 class: Class::Read,
134 group: normalized_group,
135 });
136 }
137
138 if segments.iter().any(|s| WRITE_VERBS.contains(s)) {
140 return Some(ToolMeta {
141 class: Class::Write,
142 group: normalized_group,
143 });
144 }
145
146 None
147}
148
149pub const ALL_GROUPS: &[&str] = &[
150 "auth",
151 "workspace",
152 "space",
153 "folder",
154 "list",
155 "task",
156 "checklist",
157 "comment",
158 "tag",
159 "field",
160 "task-type",
161 "attachment",
162 "time",
163 "goal",
164 "view",
165 "member",
166 "user",
167 "chat",
168 "doc",
169 "webhook",
170 "template",
171 "guest",
172 "group",
173 "role",
174 "shared",
175 "audit-log",
176 "acl",
177];
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn destructive_wins_over_write_in_same_name() {
185 assert_eq!(
186 classify("clickup_task_remove_tag").unwrap().class,
187 Class::Destructive
188 );
189 }
190
191 #[test]
192 fn trailing_read_beats_earlier_write() {
193 assert_eq!(
195 classify("clickup_chat_reply_list").unwrap().class,
196 Class::Read
197 );
198 }
199
200 #[test]
201 fn write_scan_catches_compound_verbs() {
202 assert_eq!(classify("clickup_goal_add_kr").unwrap().class, Class::Write);
203 assert_eq!(
204 classify("clickup_task_add_dep").unwrap().class,
205 Class::Write
206 );
207 }
208
209 #[test]
210 fn two_word_group_prefix_wins() {
211 let m = classify("clickup_task_type_list").unwrap();
212 assert_eq!(m.group, "task-type");
213 assert_eq!(m.class, Class::Read);
214 }
215
216 #[test]
217 fn override_table_short_circuits() {
218 assert_eq!(
219 classify("clickup_task_replace_estimates").unwrap().class,
220 Class::Write
221 );
222 assert_eq!(classify("clickup_search").unwrap().group, "workspace");
223 }
224
225 #[test]
226 fn unknown_tool_returns_none() {
227 assert!(classify("clickup_not_a_real_tool").is_none());
228 }
229}