1use std::path::PathBuf;
2
3use agent_client_protocol as acp;
4use itertools::Itertools;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use util::ResultExt;
8
9pub enum ClaudeTool {
10 Task(Option<TaskToolParams>),
11 NotebookRead(Option<NotebookReadToolParams>),
12 NotebookEdit(Option<NotebookEditToolParams>),
13 Edit(Option<EditToolParams>),
14 MultiEdit(Option<MultiEditToolParams>),
15 ReadFile(Option<ReadToolParams>),
16 Write(Option<WriteToolParams>),
17 Ls(Option<LsToolParams>),
18 Glob(Option<GlobToolParams>),
19 Grep(Option<GrepToolParams>),
20 Terminal(Option<BashToolParams>),
21 WebFetch(Option<WebFetchToolParams>),
22 WebSearch(Option<WebSearchToolParams>),
23 TodoWrite(Option<TodoWriteToolParams>),
24 ExitPlanMode(Option<ExitPlanModeToolParams>),
25 Other {
26 name: String,
27 input: serde_json::Value,
28 },
29}
30
31impl ClaudeTool {
32 pub fn infer(tool_name: &str, input: serde_json::Value) -> Self {
33 match tool_name {
34 // Known tools
35 "mcp__zed__Read" => Self::ReadFile(serde_json::from_value(input).log_err()),
36 "mcp__zed__Edit" => Self::Edit(serde_json::from_value(input).log_err()),
37 "mcp__zed__Write" => Self::Write(serde_json::from_value(input).log_err()),
38 "MultiEdit" => Self::MultiEdit(serde_json::from_value(input).log_err()),
39 "Write" => Self::Write(serde_json::from_value(input).log_err()),
40 "LS" => Self::Ls(serde_json::from_value(input).log_err()),
41 "Glob" => Self::Glob(serde_json::from_value(input).log_err()),
42 "Grep" => Self::Grep(serde_json::from_value(input).log_err()),
43 "Bash" => Self::Terminal(serde_json::from_value(input).log_err()),
44 "WebFetch" => Self::WebFetch(serde_json::from_value(input).log_err()),
45 "WebSearch" => Self::WebSearch(serde_json::from_value(input).log_err()),
46 "TodoWrite" => Self::TodoWrite(serde_json::from_value(input).log_err()),
47 "exit_plan_mode" => Self::ExitPlanMode(serde_json::from_value(input).log_err()),
48 "Task" => Self::Task(serde_json::from_value(input).log_err()),
49 "NotebookRead" => Self::NotebookRead(serde_json::from_value(input).log_err()),
50 "NotebookEdit" => Self::NotebookEdit(serde_json::from_value(input).log_err()),
51 // Inferred from name
52 _ => {
53 let tool_name = tool_name.to_lowercase();
54
55 if tool_name.contains("edit") || tool_name.contains("write") {
56 Self::Edit(None)
57 } else if tool_name.contains("terminal") {
58 Self::Terminal(None)
59 } else {
60 Self::Other {
61 name: tool_name,
62 input,
63 }
64 }
65 }
66 }
67 }
68
69 pub fn label(&self) -> String {
70 match &self {
71 Self::Task(Some(params)) => params.description.clone(),
72 Self::Task(None) => "Task".into(),
73 Self::NotebookRead(Some(params)) => {
74 format!("Read Notebook {}", params.notebook_path.display())
75 }
76 Self::NotebookRead(None) => "Read Notebook".into(),
77 Self::NotebookEdit(Some(params)) => {
78 format!("Edit Notebook {}", params.notebook_path.display())
79 }
80 Self::NotebookEdit(None) => "Edit Notebook".into(),
81 Self::Terminal(Some(params)) => format!("`{}`", params.command),
82 Self::Terminal(None) => "Terminal".into(),
83 Self::ReadFile(_) => "Read File".into(),
84 Self::Ls(Some(params)) => {
85 format!("List Directory {}", params.path.display())
86 }
87 Self::Ls(None) => "List Directory".into(),
88 Self::Edit(Some(params)) => {
89 format!("Edit {}", params.abs_path.display())
90 }
91 Self::Edit(None) => "Edit".into(),
92 Self::MultiEdit(Some(params)) => {
93 format!("Multi Edit {}", params.file_path.display())
94 }
95 Self::MultiEdit(None) => "Multi Edit".into(),
96 Self::Write(Some(params)) => {
97 format!("Write {}", params.abs_path.display())
98 }
99 Self::Write(None) => "Write".into(),
100 Self::Glob(Some(params)) => {
101 format!("Glob `{params}`")
102 }
103 Self::Glob(None) => "Glob".into(),
104 Self::Grep(Some(params)) => format!("`{params}`"),
105 Self::Grep(None) => "Grep".into(),
106 Self::WebFetch(Some(params)) => format!("Fetch {}", params.url),
107 Self::WebFetch(None) => "Fetch".into(),
108 Self::WebSearch(Some(params)) => format!("Web Search: {}", params),
109 Self::WebSearch(None) => "Web Search".into(),
110 Self::TodoWrite(Some(params)) => format!(
111 "Update TODOs: {}",
112 params.todos.iter().map(|todo| &todo.content).join(", ")
113 ),
114 Self::TodoWrite(None) => "Update TODOs".into(),
115 Self::ExitPlanMode(_) => "Exit Plan Mode".into(),
116 Self::Other { name, .. } => name.clone(),
117 }
118 }
119 pub fn content(&self) -> Vec<acp::ToolCallContent> {
120 match &self {
121 Self::Other { input, .. } => vec![
122 format!(
123 "```json\n{}```",
124 serde_json::to_string_pretty(&input).unwrap_or("{}".to_string())
125 )
126 .into(),
127 ],
128 Self::Task(Some(params)) => vec![params.prompt.clone().into()],
129 Self::NotebookRead(Some(params)) => {
130 vec![params.notebook_path.display().to_string().into()]
131 }
132 Self::NotebookEdit(Some(params)) => vec![params.new_source.clone().into()],
133 Self::Terminal(Some(params)) => vec![
134 format!(
135 "`{}`\n\n{}",
136 params.command,
137 params.description.as_deref().unwrap_or_default()
138 )
139 .into(),
140 ],
141 Self::ReadFile(Some(params)) => vec![params.abs_path.display().to_string().into()],
142 Self::Ls(Some(params)) => vec![params.path.display().to_string().into()],
143 Self::Glob(Some(params)) => vec![params.to_string().into()],
144 Self::Grep(Some(params)) => vec![format!("`{params}`").into()],
145 Self::WebFetch(Some(params)) => vec![params.prompt.clone().into()],
146 Self::WebSearch(Some(params)) => vec![params.to_string().into()],
147 Self::ExitPlanMode(Some(params)) => vec![params.plan.clone().into()],
148 Self::Edit(Some(params)) => vec![acp::ToolCallContent::Diff {
149 diff: acp::Diff {
150 path: params.abs_path.clone(),
151 old_text: Some(params.old_text.clone()),
152 new_text: params.new_text.clone(),
153 },
154 }],
155 Self::Write(Some(params)) => vec![acp::ToolCallContent::Diff {
156 diff: acp::Diff {
157 path: params.abs_path.clone(),
158 old_text: None,
159 new_text: params.content.clone(),
160 },
161 }],
162 Self::MultiEdit(Some(params)) => {
163 // todo: show multiple edits in a multibuffer?
164 params
165 .edits
166 .first()
167 .map(|edit| {
168 vec![acp::ToolCallContent::Diff {
169 diff: acp::Diff {
170 path: params.file_path.clone(),
171 old_text: Some(edit.old_string.clone()),
172 new_text: edit.new_string.clone(),
173 },
174 }]
175 })
176 .unwrap_or_default()
177 }
178 Self::TodoWrite(Some(_)) => {
179 // These are mapped to plan updates later
180 vec![]
181 }
182 Self::Task(None)
183 | Self::NotebookRead(None)
184 | Self::NotebookEdit(None)
185 | Self::Terminal(None)
186 | Self::ReadFile(None)
187 | Self::Ls(None)
188 | Self::Glob(None)
189 | Self::Grep(None)
190 | Self::WebFetch(None)
191 | Self::WebSearch(None)
192 | Self::TodoWrite(None)
193 | Self::ExitPlanMode(None)
194 | Self::Edit(None)
195 | Self::Write(None)
196 | Self::MultiEdit(None) => vec![],
197 }
198 }
199
200 pub fn kind(&self) -> acp::ToolKind {
201 match self {
202 Self::Task(_) => acp::ToolKind::Think,
203 Self::NotebookRead(_) => acp::ToolKind::Read,
204 Self::NotebookEdit(_) => acp::ToolKind::Edit,
205 Self::Edit(_) => acp::ToolKind::Edit,
206 Self::MultiEdit(_) => acp::ToolKind::Edit,
207 Self::Write(_) => acp::ToolKind::Edit,
208 Self::ReadFile(_) => acp::ToolKind::Read,
209 Self::Ls(_) => acp::ToolKind::Search,
210 Self::Glob(_) => acp::ToolKind::Search,
211 Self::Grep(_) => acp::ToolKind::Search,
212 Self::Terminal(_) => acp::ToolKind::Execute,
213 Self::WebSearch(_) => acp::ToolKind::Search,
214 Self::WebFetch(_) => acp::ToolKind::Fetch,
215 Self::TodoWrite(_) => acp::ToolKind::Think,
216 Self::ExitPlanMode(_) => acp::ToolKind::Think,
217 Self::Other { .. } => acp::ToolKind::Other,
218 }
219 }
220
221 pub fn locations(&self) -> Vec<acp::ToolCallLocation> {
222 match &self {
223 Self::Edit(Some(EditToolParams { abs_path, .. })) => vec![acp::ToolCallLocation {
224 path: abs_path.clone(),
225 line: None,
226 }],
227 Self::MultiEdit(Some(MultiEditToolParams { file_path, .. })) => {
228 vec![acp::ToolCallLocation {
229 path: file_path.clone(),
230 line: None,
231 }]
232 }
233 Self::Write(Some(WriteToolParams {
234 abs_path: file_path,
235 ..
236 })) => {
237 vec![acp::ToolCallLocation {
238 path: file_path.clone(),
239 line: None,
240 }]
241 }
242 Self::ReadFile(Some(ReadToolParams {
243 abs_path, offset, ..
244 })) => vec![acp::ToolCallLocation {
245 path: abs_path.clone(),
246 line: *offset,
247 }],
248 Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => {
249 vec![acp::ToolCallLocation {
250 path: notebook_path.clone(),
251 line: None,
252 }]
253 }
254 Self::NotebookEdit(Some(NotebookEditToolParams { notebook_path, .. })) => {
255 vec![acp::ToolCallLocation {
256 path: notebook_path.clone(),
257 line: None,
258 }]
259 }
260 Self::Glob(Some(GlobToolParams {
261 path: Some(path), ..
262 })) => vec![acp::ToolCallLocation {
263 path: path.clone(),
264 line: None,
265 }],
266 Self::Ls(Some(LsToolParams { path, .. })) => vec![acp::ToolCallLocation {
267 path: path.clone(),
268 line: None,
269 }],
270 Self::Grep(Some(GrepToolParams {
271 path: Some(path), ..
272 })) => vec![acp::ToolCallLocation {
273 path: PathBuf::from(path),
274 line: None,
275 }],
276 Self::Task(_)
277 | Self::NotebookRead(None)
278 | Self::NotebookEdit(None)
279 | Self::Edit(None)
280 | Self::MultiEdit(None)
281 | Self::Write(None)
282 | Self::ReadFile(None)
283 | Self::Ls(None)
284 | Self::Glob(_)
285 | Self::Grep(_)
286 | Self::Terminal(_)
287 | Self::WebFetch(_)
288 | Self::WebSearch(_)
289 | Self::TodoWrite(_)
290 | Self::ExitPlanMode(_)
291 | Self::Other { .. } => vec![],
292 }
293 }
294
295 pub fn as_acp(&self, id: acp::ToolCallId) -> acp::ToolCall {
296 acp::ToolCall {
297 id,
298 kind: self.kind(),
299 status: acp::ToolCallStatus::InProgress,
300 title: self.label(),
301 content: self.content(),
302 locations: self.locations(),
303 raw_input: None,
304 raw_output: None,
305 }
306 }
307}
308
309/// Edit a file.
310///
311/// In sessions with mcp__zed__Edit always use it instead of Edit as it will
312/// allow the user to conveniently review changes.
313///
314/// File editing instructions:
315/// - The `old_text` param must match existing file content, including indentation.
316/// - The `old_text` param must come from the actual file, not an outline.
317/// - The `old_text` section must not be empty.
318/// - Be minimal with replacements:
319/// - For unique lines, include only those lines.
320/// - For non-unique lines, include enough context to identify them.
321/// - Do not escape quotes, newlines, or other characters.
322/// - Only edit the specified file.
323#[derive(Deserialize, JsonSchema, Debug)]
324pub struct EditToolParams {
325 /// The absolute path to the file to read.
326 pub abs_path: PathBuf,
327 /// The old text to replace (must be unique in the file)
328 pub old_text: String,
329 /// The new text.
330 pub new_text: String,
331}
332
333/// Reads the content of the given file in the project.
334///
335/// Never attempt to read a path that hasn't been previously mentioned.
336///
337/// In sessions with mcp__zed__Read always use it instead of Read as it contains the most up-to-date contents.
338#[derive(Deserialize, JsonSchema, Debug)]
339pub struct ReadToolParams {
340 /// The absolute path to the file to read.
341 pub abs_path: PathBuf,
342 /// Which line to start reading from. Omit to start from the beginning.
343 #[serde(skip_serializing_if = "Option::is_none")]
344 pub offset: Option<u32>,
345 /// How many lines to read. Omit for the whole file.
346 #[serde(skip_serializing_if = "Option::is_none")]
347 pub limit: Option<u32>,
348}
349
350/// Writes content to the specified file in the project.
351///
352/// In sessions with mcp__zed__Write always use it instead of Write as it will
353/// allow the user to conveniently review changes.
354#[derive(Deserialize, JsonSchema, Debug)]
355pub struct WriteToolParams {
356 /// The absolute path of the file to write.
357 pub abs_path: PathBuf,
358 /// The full content to write.
359 pub content: String,
360}
361
362#[derive(Deserialize, JsonSchema, Debug)]
363pub struct BashToolParams {
364 /// Shell command to execute
365 pub command: String,
366 /// 5-10 word description of what command does
367 #[serde(skip_serializing_if = "Option::is_none")]
368 pub description: Option<String>,
369 /// Timeout in ms (max 600000ms/10min, default 120000ms)
370 #[serde(skip_serializing_if = "Option::is_none")]
371 pub timeout: Option<u32>,
372}
373
374#[derive(Deserialize, JsonSchema, Debug)]
375pub struct GlobToolParams {
376 /// Glob pattern like **/*.js or src/**/*.ts
377 pub pattern: String,
378 /// Directory to search in (omit for current directory)
379 #[serde(skip_serializing_if = "Option::is_none")]
380 pub path: Option<PathBuf>,
381}
382
383impl std::fmt::Display for GlobToolParams {
384 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
385 if let Some(path) = &self.path {
386 write!(f, "{}", path.display())?;
387 }
388 write!(f, "{}", self.pattern)
389 }
390}
391
392#[derive(Deserialize, JsonSchema, Debug)]
393pub struct LsToolParams {
394 /// Absolute path to directory
395 pub path: PathBuf,
396 /// Array of glob patterns to ignore
397 #[serde(default, skip_serializing_if = "Vec::is_empty")]
398 pub ignore: Vec<String>,
399}
400
401#[derive(Deserialize, JsonSchema, Debug)]
402pub struct GrepToolParams {
403 /// Regex pattern to search for
404 pub pattern: String,
405 /// File/directory to search (defaults to current directory)
406 #[serde(skip_serializing_if = "Option::is_none")]
407 pub path: Option<String>,
408 /// "content" (shows lines), "files_with_matches" (default), "count"
409 #[serde(skip_serializing_if = "Option::is_none")]
410 pub output_mode: Option<GrepOutputMode>,
411 /// Filter files with glob pattern like "*.js"
412 #[serde(skip_serializing_if = "Option::is_none")]
413 pub glob: Option<String>,
414 /// File type filter like "js", "py", "rust"
415 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
416 pub file_type: Option<String>,
417 /// Case insensitive search
418 #[serde(rename = "-i", default, skip_serializing_if = "is_false")]
419 pub case_insensitive: bool,
420 /// Show line numbers (content mode only)
421 #[serde(rename = "-n", default, skip_serializing_if = "is_false")]
422 pub line_numbers: bool,
423 /// Lines after match (content mode only)
424 #[serde(rename = "-A", skip_serializing_if = "Option::is_none")]
425 pub after_context: Option<u32>,
426 /// Lines before match (content mode only)
427 #[serde(rename = "-B", skip_serializing_if = "Option::is_none")]
428 pub before_context: Option<u32>,
429 /// Lines before and after match (content mode only)
430 #[serde(rename = "-C", skip_serializing_if = "Option::is_none")]
431 pub context: Option<u32>,
432 /// Enable multiline/cross-line matching
433 #[serde(default, skip_serializing_if = "is_false")]
434 pub multiline: bool,
435 /// Limit output to first N results
436 #[serde(skip_serializing_if = "Option::is_none")]
437 pub head_limit: Option<u32>,
438}
439
440impl std::fmt::Display for GrepToolParams {
441 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442 write!(f, "grep")?;
443
444 // Boolean flags
445 if self.case_insensitive {
446 write!(f, " -i")?;
447 }
448 if self.line_numbers {
449 write!(f, " -n")?;
450 }
451
452 // Context options
453 if let Some(after) = self.after_context {
454 write!(f, " -A {}", after)?;
455 }
456 if let Some(before) = self.before_context {
457 write!(f, " -B {}", before)?;
458 }
459 if let Some(context) = self.context {
460 write!(f, " -C {}", context)?;
461 }
462
463 // Output mode
464 if let Some(mode) = &self.output_mode {
465 match mode {
466 GrepOutputMode::FilesWithMatches => write!(f, " -l")?,
467 GrepOutputMode::Count => write!(f, " -c")?,
468 GrepOutputMode::Content => {} // Default mode
469 }
470 }
471
472 // Head limit
473 if let Some(limit) = self.head_limit {
474 write!(f, " | head -{}", limit)?;
475 }
476
477 // Glob pattern
478 if let Some(glob) = &self.glob {
479 write!(f, " --include=\"{}\"", glob)?;
480 }
481
482 // File type
483 if let Some(file_type) = &self.file_type {
484 write!(f, " --type={}", file_type)?;
485 }
486
487 // Multiline
488 if self.multiline {
489 write!(f, " -P")?; // Perl-compatible regex for multiline
490 }
491
492 // Pattern (escaped if contains special characters)
493 write!(f, " \"{}\"", self.pattern)?;
494
495 // Path
496 if let Some(path) = &self.path {
497 write!(f, " {}", path)?;
498 }
499
500 Ok(())
501 }
502}
503
504#[derive(Default, Deserialize, Serialize, JsonSchema, strum::Display, Debug)]
505#[serde(rename_all = "snake_case")]
506pub enum TodoPriority {
507 High,
508 #[default]
509 Medium,
510 Low,
511}
512
513impl Into<acp::PlanEntryPriority> for TodoPriority {
514 fn into(self) -> acp::PlanEntryPriority {
515 match self {
516 TodoPriority::High => acp::PlanEntryPriority::High,
517 TodoPriority::Medium => acp::PlanEntryPriority::Medium,
518 TodoPriority::Low => acp::PlanEntryPriority::Low,
519 }
520 }
521}
522
523#[derive(Deserialize, Serialize, JsonSchema, Debug)]
524#[serde(rename_all = "snake_case")]
525pub enum TodoStatus {
526 Pending,
527 InProgress,
528 Completed,
529}
530
531impl Into<acp::PlanEntryStatus> for TodoStatus {
532 fn into(self) -> acp::PlanEntryStatus {
533 match self {
534 TodoStatus::Pending => acp::PlanEntryStatus::Pending,
535 TodoStatus::InProgress => acp::PlanEntryStatus::InProgress,
536 TodoStatus::Completed => acp::PlanEntryStatus::Completed,
537 }
538 }
539}
540
541#[derive(Deserialize, Serialize, JsonSchema, Debug)]
542pub struct Todo {
543 /// Task description
544 pub content: String,
545 /// Current status of the todo
546 pub status: TodoStatus,
547 /// Priority level of the todo
548 #[serde(default)]
549 pub priority: TodoPriority,
550}
551
552impl Into<acp::PlanEntry> for Todo {
553 fn into(self) -> acp::PlanEntry {
554 acp::PlanEntry {
555 content: self.content,
556 priority: self.priority.into(),
557 status: self.status.into(),
558 }
559 }
560}
561
562#[derive(Deserialize, JsonSchema, Debug)]
563pub struct TodoWriteToolParams {
564 pub todos: Vec<Todo>,
565}
566
567#[derive(Deserialize, JsonSchema, Debug)]
568pub struct ExitPlanModeToolParams {
569 /// Implementation plan in markdown format
570 pub plan: String,
571}
572
573#[derive(Deserialize, JsonSchema, Debug)]
574pub struct TaskToolParams {
575 /// Short 3-5 word description of task
576 pub description: String,
577 /// Detailed task for agent to perform
578 pub prompt: String,
579}
580
581#[derive(Deserialize, JsonSchema, Debug)]
582pub struct NotebookReadToolParams {
583 /// Absolute path to .ipynb file
584 pub notebook_path: PathBuf,
585 /// Specific cell ID to read
586 #[serde(skip_serializing_if = "Option::is_none")]
587 pub cell_id: Option<String>,
588}
589
590#[derive(Deserialize, Serialize, JsonSchema, Debug)]
591#[serde(rename_all = "snake_case")]
592pub enum CellType {
593 Code,
594 Markdown,
595}
596
597#[derive(Deserialize, Serialize, JsonSchema, Debug)]
598#[serde(rename_all = "snake_case")]
599pub enum EditMode {
600 Replace,
601 Insert,
602 Delete,
603}
604
605#[derive(Deserialize, JsonSchema, Debug)]
606pub struct NotebookEditToolParams {
607 /// Absolute path to .ipynb file
608 pub notebook_path: PathBuf,
609 /// New cell content
610 pub new_source: String,
611 /// Cell ID to edit
612 #[serde(skip_serializing_if = "Option::is_none")]
613 pub cell_id: Option<String>,
614 /// Type of cell (code or markdown)
615 #[serde(skip_serializing_if = "Option::is_none")]
616 pub cell_type: Option<CellType>,
617 /// Edit operation mode
618 #[serde(skip_serializing_if = "Option::is_none")]
619 pub edit_mode: Option<EditMode>,
620}
621
622#[derive(Deserialize, Serialize, JsonSchema, Debug)]
623pub struct MultiEditItem {
624 /// The text to search for and replace
625 pub old_string: String,
626 /// The replacement text
627 pub new_string: String,
628 /// Whether to replace all occurrences or just the first
629 #[serde(default, skip_serializing_if = "is_false")]
630 pub replace_all: bool,
631}
632
633#[derive(Deserialize, JsonSchema, Debug)]
634pub struct MultiEditToolParams {
635 /// Absolute path to file
636 pub file_path: PathBuf,
637 /// List of edits to apply
638 pub edits: Vec<MultiEditItem>,
639}
640
641fn is_false(v: &bool) -> bool {
642 !*v
643}
644
645#[derive(Deserialize, JsonSchema, Debug)]
646#[serde(rename_all = "snake_case")]
647pub enum GrepOutputMode {
648 Content,
649 FilesWithMatches,
650 Count,
651}
652
653#[derive(Deserialize, JsonSchema, Debug)]
654pub struct WebFetchToolParams {
655 /// Valid URL to fetch
656 #[serde(rename = "url")]
657 pub url: String,
658 /// What to extract from content
659 pub prompt: String,
660}
661
662#[derive(Deserialize, JsonSchema, Debug)]
663pub struct WebSearchToolParams {
664 /// Search query (min 2 chars)
665 pub query: String,
666 /// Only include these domains
667 #[serde(default, skip_serializing_if = "Vec::is_empty")]
668 pub allowed_domains: Vec<String>,
669 /// Exclude these domains
670 #[serde(default, skip_serializing_if = "Vec::is_empty")]
671 pub blocked_domains: Vec<String>,
672}
673
674impl std::fmt::Display for WebSearchToolParams {
675 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
676 write!(f, "\"{}\"", self.query)?;
677
678 if !self.allowed_domains.is_empty() {
679 write!(f, " (allowed: {})", self.allowed_domains.join(", "))?;
680 }
681
682 if !self.blocked_domains.is_empty() {
683 write!(f, " (blocked: {})", self.blocked_domains.join(", "))?;
684 }
685
686 Ok(())
687 }
688}