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