1use std::fmt::Write;
4
5use agentic_tools_core::fmt::{TextFormat, TextOptions};
6use chrono::{DateTime, Utc};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Deserialize, JsonSchema)]
16pub struct WebFetchInput {
17 pub url: String,
19 #[serde(default)]
22 pub summarize: bool,
23 #[serde(default)]
25 pub max_bytes: Option<usize>,
26}
27
28#[derive(Debug, Clone, Serialize, JsonSchema)]
30pub struct WebFetchOutput {
31 pub final_url: String,
33 pub title: Option<String>,
35 pub content_type: String,
37 pub word_count: usize,
39 pub truncated: bool,
41 pub retrieved_at: DateTime<Utc>,
43 pub content: String,
45 pub summary: Option<String>,
47}
48
49impl TextFormat for WebFetchOutput {
50 fn fmt_text(&self, _opts: &TextOptions) -> String {
51 let mut out = String::new();
52 let _ = writeln!(out, "URL: {}", self.final_url);
53 if let Some(title) = &self.title {
54 let _ = writeln!(out, "Title: {title}");
55 }
56 let _ = write!(
57 out,
58 "Retrieved: {} | Words: {}",
59 self.retrieved_at.format("%Y-%m-%d %H:%M UTC"),
60 self.word_count
61 );
62 if self.truncated {
63 out.push_str(" | TRUNCATED");
64 }
65 out.push('\n');
66 if let Some(summary) = &self.summary {
67 out.push_str("\n--- Summary ---\n");
68 out.push_str(summary);
69 out.push('\n');
70 }
71 out.push_str("\n--- Content ---\n");
72 out.push_str(&self.content);
73 out
74 }
75}
76
77#[derive(Debug, Clone, Deserialize, JsonSchema)]
83pub struct WebSearchInput {
84 pub query: String,
87 #[serde(default)]
89 pub num_results: Option<u32>,
90}
91
92#[derive(Debug, Clone, Serialize, JsonSchema)]
94pub struct WebSearchOutput {
95 pub query: String,
97 pub retrieved_at: DateTime<Utc>,
99 pub context: Option<String>,
101 pub results: Vec<WebSearchResultCard>,
103}
104
105#[derive(Debug, Clone, Serialize, JsonSchema)]
107pub struct WebSearchResultCard {
108 pub url: String,
110 pub domain: String,
112 pub title: Option<String>,
114 pub published_date: Option<String>,
116 pub author: Option<String>,
118 pub score: Option<u32>,
120 pub snippet: Option<String>,
122}
123
124impl TextFormat for WebSearchOutput {
125 fn fmt_text(&self, _opts: &TextOptions) -> String {
126 let mut out = String::new();
127 let _ = writeln!(out, "Query: {}", self.query);
128 let _ = writeln!(
129 out,
130 "Retrieved: {}",
131 self.retrieved_at.format("%Y-%m-%d %H:%M UTC")
132 );
133
134 if let Some(ctx) = &self.context {
135 let _ = write!(out, "\n--- Context ---\n{ctx}\n");
136 }
137
138 let _ = write!(out, "\n--- Results ({}) ---\n", self.results.len());
139 for (i, card) in self.results.iter().enumerate() {
140 let _ = write!(
141 out,
142 "\n{}. {} ({})\n {}\n",
143 i + 1,
144 card.title.as_deref().unwrap_or("(untitled)"),
145 card.domain,
146 card.url,
147 );
148 let mut meta = Vec::new();
149 if let Some(date) = &card.published_date {
150 meta.push(format!("Date: {date}"));
151 }
152 if let Some(author) = &card.author {
153 meta.push(format!("Author: {author}"));
154 }
155 if !meta.is_empty() {
156 let _ = writeln!(out, " {}", meta.join(" | "));
157 }
158 if let Some(score) = card.score {
159 let _ = writeln!(out, " Score: {score}/100");
160 }
161 if let Some(snippet) = &card.snippet {
162 let _ = writeln!(out, " {snippet}");
163 }
164 }
165 out
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn author_displayed_without_date() {
175 let output = WebSearchOutput {
176 query: "test query".into(),
177 retrieved_at: Utc::now(),
178 context: None,
179 results: vec![WebSearchResultCard {
180 url: "https://example.com".into(),
181 domain: "example.com".into(),
182 title: Some("Test Title".into()),
183 published_date: None, author: Some("Jane Doe".into()), score: None,
186 snippet: None,
187 }],
188 };
189
190 let text = output.fmt_text(&TextOptions::default());
191 assert!(
192 text.contains("Author: Jane Doe"),
193 "Author should be displayed even when date is missing"
194 );
195 }
196
197 #[test]
198 fn date_and_author_displayed_together() {
199 let output = WebSearchOutput {
200 query: "test query".into(),
201 retrieved_at: Utc::now(),
202 context: None,
203 results: vec![WebSearchResultCard {
204 url: "https://example.com".into(),
205 domain: "example.com".into(),
206 title: Some("Test Title".into()),
207 published_date: Some("2025-01-15".into()),
208 author: Some("John Smith".into()),
209 score: None,
210 snippet: None,
211 }],
212 };
213
214 let text = output.fmt_text(&TextOptions::default());
215 assert!(
216 text.contains("Date: 2025-01-15 | Author: John Smith"),
217 "Date and author should be joined with pipe"
218 );
219 }
220
221 #[test]
222 fn date_displayed_without_author() {
223 let output = WebSearchOutput {
224 query: "test query".into(),
225 retrieved_at: Utc::now(),
226 context: None,
227 results: vec![WebSearchResultCard {
228 url: "https://example.com".into(),
229 domain: "example.com".into(),
230 title: Some("Test Title".into()),
231 published_date: Some("2025-01-15".into()),
232 author: None,
233 score: None,
234 snippet: None,
235 }],
236 };
237
238 let text = output.fmt_text(&TextOptions::default());
239 assert!(
240 text.contains("Date: 2025-01-15"),
241 "Date should be displayed when author is missing"
242 );
243 assert!(
244 !text.contains("Author:"),
245 "Author should not appear when not present"
246 );
247 }
248}