1use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
23#[serde(into = "String", from = "String")]
24pub enum Tool {
25 AskUserQuestion,
28 EnterPlanMode,
30 ExitPlanMode,
32
33 Bash,
35 Read,
36 Write,
37 Edit,
38 Grep,
39 Glob,
40 Task,
41 WebSearch,
42 WebFetch,
43 TodoWrite,
44 NotebookEdit,
45 NotebookRead,
46
47 Other(String),
50}
51
52impl Tool {
53 #[must_use]
62 pub fn is_interactive(&self) -> bool {
63 matches!(
64 self,
65 Self::AskUserQuestion | Self::EnterPlanMode | Self::ExitPlanMode
66 )
67 }
68
69 #[must_use]
71 pub fn as_str(&self) -> &str {
72 match self {
73 Self::AskUserQuestion => "AskUserQuestion",
74 Self::EnterPlanMode => "EnterPlanMode",
75 Self::ExitPlanMode => "ExitPlanMode",
76 Self::Bash => "Bash",
77 Self::Read => "Read",
78 Self::Write => "Write",
79 Self::Edit => "Edit",
80 Self::Grep => "Grep",
81 Self::Glob => "Glob",
82 Self::Task => "Task",
83 Self::WebSearch => "WebSearch",
84 Self::WebFetch => "WebFetch",
85 Self::TodoWrite => "TodoWrite",
86 Self::NotebookEdit => "NotebookEdit",
87 Self::NotebookRead => "NotebookRead",
88 Self::Other(s) => s.as_str(),
89 }
90 }
91}
92
93impl std::fmt::Display for Tool {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.write_str(self.as_str())
96 }
97}
98
99impl Tool {
100 fn try_from_known(s: &str) -> Option<Self> {
104 Some(match s {
105 "AskUserQuestion" => Self::AskUserQuestion,
106 "EnterPlanMode" => Self::EnterPlanMode,
107 "ExitPlanMode" => Self::ExitPlanMode,
108 "Bash" => Self::Bash,
109 "Read" => Self::Read,
110 "Write" => Self::Write,
111 "Edit" => Self::Edit,
112 "Grep" => Self::Grep,
113 "Glob" => Self::Glob,
114 "Task" => Self::Task,
115 "WebSearch" => Self::WebSearch,
116 "WebFetch" => Self::WebFetch,
117 "TodoWrite" => Self::TodoWrite,
118 "NotebookEdit" => Self::NotebookEdit,
119 "NotebookRead" => Self::NotebookRead,
120 _ => return None,
121 })
122 }
123}
124
125impl From<&str> for Tool {
126 fn from(s: &str) -> Self {
127 let trimmed = s.trim();
131 Self::try_from_known(trimmed).unwrap_or_else(|| Self::Other(trimmed.to_string()))
132 }
133}
134
135impl From<String> for Tool {
136 fn from(s: String) -> Self {
137 let trimmed = s.trim();
140 if let Some(known) = Self::try_from_known(trimmed) {
141 return known;
142 }
143 if trimmed.len() == s.len() {
145 Self::Other(s)
146 } else {
147 Self::Other(trimmed.to_string())
148 }
149 }
150}
151
152impl From<Tool> for String {
153 fn from(t: Tool) -> Self {
154 match t {
155 Tool::Other(s) => s,
156 other => other.as_str().to_string(),
157 }
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn known_tools_roundtrip() {
167 for variant in [
168 Tool::AskUserQuestion,
169 Tool::EnterPlanMode,
170 Tool::ExitPlanMode,
171 Tool::Bash,
172 Tool::Read,
173 Tool::Write,
174 Tool::Edit,
175 Tool::Grep,
176 Tool::Glob,
177 Tool::Task,
178 Tool::WebSearch,
179 Tool::WebFetch,
180 Tool::TodoWrite,
181 Tool::NotebookEdit,
182 Tool::NotebookRead,
183 ] {
184 let s = variant.as_str().to_string();
185 assert_eq!(Tool::from(s), variant);
186 }
187 }
188
189 #[test]
190 fn unknown_tool_round_trips_via_other() {
191 assert_eq!(
192 Tool::from("mcp__github__list_issues"),
193 Tool::Other("mcp__github__list_issues".to_string())
194 );
195 assert_eq!(
196 Tool::from("custom_pi_tool"),
197 Tool::Other("custom_pi_tool".to_string())
198 );
199 }
200
201 #[test]
202 fn is_interactive_only_for_three() {
203 assert!(Tool::AskUserQuestion.is_interactive());
204 assert!(Tool::EnterPlanMode.is_interactive());
205 assert!(Tool::ExitPlanMode.is_interactive());
206
207 for not_interactive in [
208 Tool::Bash,
209 Tool::Read,
210 Tool::Write,
211 Tool::Edit,
212 Tool::Grep,
213 Tool::Glob,
214 Tool::Task,
215 Tool::WebSearch,
216 Tool::WebFetch,
217 Tool::TodoWrite,
218 Tool::NotebookEdit,
219 Tool::NotebookRead,
220 Tool::Other("anything".into()),
221 ] {
222 assert!(
223 !not_interactive.is_interactive(),
224 "{not_interactive} should not be interactive"
225 );
226 }
227 }
228
229 #[test]
230 fn whitespace_trimmed_into_canonical_variant() {
231 assert_eq!(Tool::from(" AskUserQuestion "), Tool::AskUserQuestion);
232 assert_eq!(Tool::from("\tEnterPlanMode\n"), Tool::EnterPlanMode);
233 assert!(Tool::from(" AskUserQuestion ").is_interactive());
234 }
235
236 #[test]
237 fn case_sensitive_match() {
238 assert_eq!(
239 Tool::from("askuserquestion"),
240 Tool::Other("askuserquestion".into())
241 );
242 assert_eq!(
243 Tool::from("ASKUSERQUESTION"),
244 Tool::Other("ASKUSERQUESTION".into())
245 );
246 assert!(!Tool::from("askuserquestion").is_interactive());
247 }
248
249 #[test]
250 fn empty_string_becomes_empty_other_and_is_not_interactive() {
251 assert_eq!(Tool::from(""), Tool::Other(String::new()));
252 assert!(!Tool::from("").is_interactive());
253 assert!(!Tool::from(" ").is_interactive());
254 }
255
256 #[test]
257 fn serde_roundtrip_known_and_other() {
258 assert_eq!(serde_json::to_string(&Tool::Bash).unwrap(), "\"Bash\"");
260 assert_eq!(
261 serde_json::to_string(&Tool::Other("mcp__plugin__do_thing".into())).unwrap(),
262 "\"mcp__plugin__do_thing\""
263 );
264
265 assert_eq!(
266 serde_json::from_str::<Tool>("\"Bash\"").unwrap(),
267 Tool::Bash
268 );
269 assert_eq!(
270 serde_json::from_str::<Tool>("\"mcp__x__y\"").unwrap(),
271 Tool::Other("mcp__x__y".into())
272 );
273 }
274}