1use async_trait::async_trait;
9use serde::Deserialize;
10use serde_json::{Value, json};
11use std::time::Duration;
12use zeroclaw_api::tool::{Tool, ToolResult};
13
14const WTTR_BASE_URL: &str = "https://wttr.in";
15const WTTR_TIMEOUT_SECS: u64 = 15;
16const WTTR_CONNECT_TIMEOUT_SECS: u64 = 10;
17
18#[derive(Debug, Deserialize)]
21struct WttrResponse {
22 current_condition: Vec<CurrentCondition>,
23 nearest_area: Vec<NearestArea>,
24 weather: Vec<WeatherDay>,
25}
26
27#[derive(Debug, Deserialize)]
28struct CurrentCondition {
29 #[serde(rename = "temp_C")]
30 temp_c: String,
31 #[serde(rename = "temp_F")]
32 temp_f: String,
33 #[serde(rename = "FeelsLikeC")]
34 feels_like_c: String,
35 #[serde(rename = "FeelsLikeF")]
36 feels_like_f: String,
37 humidity: String,
38 #[serde(rename = "weatherDesc")]
39 weather_desc: Vec<StringValue>,
40 #[serde(rename = "windspeedKmph")]
41 windspeed_kmph: String,
42 #[serde(rename = "windspeedMiles")]
43 windspeed_miles: String,
44 #[serde(rename = "winddir16Point")]
45 winddir_16point: String,
46 #[serde(rename = "precipMM")]
47 precip_mm: String,
48 #[serde(rename = "precipInches")]
49 precip_inches: String,
50 visibility: String,
51 #[serde(rename = "visibilityMiles")]
52 visibility_miles: String,
53 #[serde(rename = "uvIndex")]
54 uv_index: String,
55 #[serde(rename = "cloudcover")]
56 cloud_cover: String,
57 #[serde(rename = "pressure")]
58 pressure_mb: String,
59 #[serde(rename = "pressureInches")]
60 pressure_inches: String,
61 #[serde(rename = "observation_time")]
62 observation_time: String,
63}
64
65#[derive(Debug, Deserialize)]
66struct NearestArea {
67 #[serde(rename = "areaName")]
68 area_name: Vec<StringValue>,
69 country: Vec<StringValue>,
70 region: Vec<StringValue>,
71}
72
73#[derive(Debug, Deserialize)]
74struct WeatherDay {
75 date: String,
76 #[serde(rename = "maxtempC")]
77 max_temp_c: String,
78 #[serde(rename = "maxtempF")]
79 max_temp_f: String,
80 #[serde(rename = "mintempC")]
81 min_temp_c: String,
82 #[serde(rename = "mintempF")]
83 min_temp_f: String,
84 #[serde(rename = "avgtempC")]
85 avg_temp_c: String,
86 #[serde(rename = "avgtempF")]
87 avg_temp_f: String,
88 #[serde(rename = "sunHour")]
89 sun_hours: String,
90 #[serde(rename = "uvIndex")]
91 uv_index: String,
92 #[serde(rename = "totalSnow_cm")]
93 total_snow_cm: String,
94 astronomy: Vec<Astronomy>,
95 hourly: Vec<HourlyCondition>,
96}
97
98#[derive(Debug, Deserialize)]
99struct Astronomy {
100 sunrise: String,
101 sunset: String,
102 moon_phase: String,
103}
104
105#[derive(Debug, Deserialize)]
106struct HourlyCondition {
107 time: String,
108 #[serde(rename = "tempC")]
109 temp_c: String,
110 #[serde(rename = "tempF")]
111 temp_f: String,
112 #[serde(rename = "weatherDesc")]
113 weather_desc: Vec<StringValue>,
114 #[serde(rename = "chanceofrain")]
115 chance_of_rain: String,
116 #[serde(rename = "chanceofsnow")]
117 chance_of_snow: String,
118 #[serde(rename = "windspeedKmph")]
119 windspeed_kmph: String,
120 #[serde(rename = "windspeedMiles")]
121 windspeed_miles: String,
122 #[serde(rename = "winddir16Point")]
123 winddir_16point: String,
124}
125
126#[derive(Debug, Deserialize)]
127struct StringValue {
128 value: String,
129}
130
131pub struct WeatherTool;
135
136impl WeatherTool {
137 pub fn new() -> Self {
138 Self
139 }
140
141 fn build_url(location: &str) -> String {
143 let encoded = location.trim().replace(' ', "+");
145 format!("{WTTR_BASE_URL}/{encoded}?format=j1")
146 }
147
148 async fn fetch(location: &str) -> anyhow::Result<WttrResponse> {
150 let url = Self::build_url(location);
151
152 let builder = reqwest::Client::builder()
153 .timeout(Duration::from_secs(WTTR_TIMEOUT_SECS))
154 .connect_timeout(Duration::from_secs(WTTR_CONNECT_TIMEOUT_SECS))
155 .user_agent("zeroclaw-weather/1.0");
156
157 let builder =
158 zeroclaw_config::schema::apply_runtime_proxy_to_builder(builder, "tool.weather");
159 let client = builder.build()?;
160
161 let response = client.get(&url).send().await?;
162 let status = response.status();
163
164 if !status.is_success() {
165 anyhow::bail!(
166 "wttr.in returned HTTP {status} for location '{location}'. \
167 Check that the location is valid."
168 );
169 }
170
171 let body = response.text().await?;
172
173 if !body.trim_start().starts_with('{') {
175 anyhow::bail!(
176 "wttr.in could not resolve location '{location}'. \
177 Try a city name, airport code, GPS coordinates (lat,lon), or zip code."
178 );
179 }
180
181 let parsed: WttrResponse = serde_json::from_str(&body).map_err(|e| {
182 ::zeroclaw_log::record!(
183 WARN,
184 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
185 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
186 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
187 "weather_tool: failed to parse wttr.in response"
188 );
189 anyhow::Error::msg(format!("Failed to parse wttr.in response: {e}"))
190 })?;
191
192 Ok(parsed)
193 }
194
195 fn format_hourly(h: &HourlyCondition, metric: bool) -> String {
197 let hour_num: u32 = h.time.parse().unwrap_or(0);
199 let hour_display = format!("{:02}:00", hour_num / 100);
200 let temp = if metric {
201 format!("{}°C", h.temp_c)
202 } else {
203 format!("{}°F", h.temp_f)
204 };
205 let wind_speed = if metric {
206 format!("{} km/h", h.windspeed_kmph)
207 } else {
208 format!("{} mph", h.windspeed_miles)
209 };
210 let desc = h
211 .weather_desc
212 .first()
213 .map(|v| v.value.trim().to_string())
214 .unwrap_or_default();
215 format!(
216 " {hour_display}: {temp} — {desc} | Wind: {wind_speed} {} | Rain: {}% | Snow: {}%",
217 h.winddir_16point, h.chance_of_rain, h.chance_of_snow,
218 )
219 }
220
221 fn format_day(day: &WeatherDay, metric: bool, include_hourly: bool) -> String {
223 let (max, min, avg) = if metric {
224 (
225 format!("{}°C", day.max_temp_c),
226 format!("{}°C", day.min_temp_c),
227 format!("{}°C", day.avg_temp_c),
228 )
229 } else {
230 (
231 format!("{}°F", day.max_temp_f),
232 format!("{}°F", day.min_temp_f),
233 format!("{}°F", day.avg_temp_f),
234 )
235 };
236
237 let astronomy = day.astronomy.first();
238 let sunrise = astronomy.map(|a| a.sunrise.as_str()).unwrap_or("N/A");
239 let sunset = astronomy.map(|a| a.sunset.as_str()).unwrap_or("N/A");
240 let moon = astronomy.map(|a| a.moon_phase.as_str()).unwrap_or("N/A");
241
242 let snow_note = if day.total_snow_cm != "0.0" && day.total_snow_cm != "0" {
243 if metric {
244 format!(" | Snow: {} cm", day.total_snow_cm)
245 } else {
246 let cm: f64 = day.total_snow_cm.parse().unwrap_or(0.0);
248 format!(" | Snow: {:.1} in", cm / 2.54)
249 }
250 } else {
251 String::new()
252 };
253
254 let mut out = format!(
255 " {date}: High {max} / Low {min} / Avg {avg} | UV: {uv} | Sun: {sun_hours}h | {snow}\
256 Sunrise: {sunrise} | Sunset: {sunset} | Moon: {moon}",
257 date = day.date,
258 uv = day.uv_index,
259 sun_hours = day.sun_hours,
260 snow = snow_note,
261 );
262
263 if include_hourly && !day.hourly.is_empty() {
264 out.push('\n');
265 for h in day.hourly.iter().step_by(2) {
267 out.push('\n');
268 out.push_str(&Self::format_hourly(h, metric));
269 }
270 }
271
272 out
273 }
274
275 fn format_output(data: &WttrResponse, metric: bool, days: u8) -> String {
277 let current = match data.current_condition.first() {
278 Some(c) => c,
279 None => return "No current conditions available.".to_string(),
280 };
281
282 let area = data.nearest_area.first();
283 let location_str = area
284 .map(|a| {
285 let city = a.area_name.first().map(|v| v.value.as_str()).unwrap_or("");
286 let region = a.region.first().map(|v| v.value.as_str()).unwrap_or("");
287 let country = a.country.first().map(|v| v.value.as_str()).unwrap_or("");
288 match (city.is_empty(), region.is_empty()) {
289 (false, false) => format!("{city}, {region}, {country}"),
290 (false, true) => format!("{city}, {country}"),
291 _ => country.to_string(),
292 }
293 })
294 .unwrap_or_else(|| "Unknown location".to_string());
295
296 let desc = current
297 .weather_desc
298 .first()
299 .map(|v| v.value.trim().to_string())
300 .unwrap_or_else(|| "Unknown".to_string());
301
302 let (temp, feels_like, wind_speed, precip, visibility, pressure) = if metric {
303 (
304 format!("{}°C", current.temp_c),
305 format!("{}°C", current.feels_like_c),
306 format!("{} km/h", current.windspeed_kmph),
307 format!("{} mm", current.precip_mm),
308 format!("{} km", current.visibility),
309 format!("{} hPa", current.pressure_mb),
310 )
311 } else {
312 (
313 format!("{}°F", current.temp_f),
314 format!("{}°F", current.feels_like_f),
315 format!("{} mph", current.windspeed_miles),
316 format!("{} in", current.precip_inches),
317 format!("{} mi", current.visibility_miles),
318 format!("{} inHg", current.pressure_inches),
319 )
320 };
321
322 let mut out = format!(
323 "Weather for {location_str} (as of {obs_time})\n\
324 ─────────────────────────────────────────\n\
325 Conditions : {desc}\n\
326 Temperature: {temp} (feels like {feels_like})\n\
327 Humidity : {humidity}%\n\
328 Wind : {wind_speed} {winddir}\n\
329 Precipitation: {precip}\n\
330 Visibility : {visibility}\n\
331 Pressure : {pressure}\n\
332 Cloud Cover: {cloud}%\n\
333 UV Index : {uv}",
334 obs_time = current.observation_time,
335 humidity = current.humidity,
336 winddir = current.winddir_16point,
337 cloud = current.cloud_cover,
338 uv = current.uv_index,
339 );
340
341 let forecast_days: Vec<&WeatherDay> = data.weather.iter().take(days as usize).collect();
343 if !forecast_days.is_empty() {
344 out.push_str("\n\nForecast\n────────");
345 let include_hourly = days <= 2;
346 for day in &forecast_days {
347 out.push('\n');
348 out.push_str(&Self::format_day(day, metric, include_hourly));
349 }
350 }
351
352 out
353 }
354}
355
356impl Default for WeatherTool {
357 fn default() -> Self {
358 Self::new()
359 }
360}
361
362#[async_trait]
365impl Tool for WeatherTool {
366 fn name(&self) -> &str {
367 "weather"
368 }
369
370 fn description(&self) -> &str {
371 "Get current weather conditions and up to 3-day forecast for any location worldwide. \
372 Supports city names (in any language or script), airport IATA codes (e.g. 'LAX'), \
373 GPS coordinates (e.g. '51.5,-0.1'), postal/zip codes, and domain-based geolocation. \
374 No API key required. Units default to metric (°C, km/h, mm) but can be switched to \
375 imperial (°F, mph, inches) per request."
376 }
377
378 fn parameters_schema(&self) -> Value {
379 json!({
380 "type": "object",
381 "properties": {
382 "location": {
383 "type": "string",
384 "description": "Location to get weather for. Accepts city names in any \
385 language/script, IATA airport codes, GPS coordinates \
386 (e.g. '35.6762,139.6503'), postal/zip codes, or a \
387 domain name for geolocation (e.g. 'stackoverflow.com')."
388 },
389 "units": {
390 "type": "string",
391 "enum": ["metric", "imperial"],
392 "description": "Unit system. 'metric' = °C, km/h, mm (default). \
393 'imperial' = °F, mph, inches."
394 },
395 "days": {
396 "type": "integer",
397 "minimum": 0,
398 "maximum": 3,
399 "description": "Number of forecast days to include (0–3). \
400 0 returns current conditions only. Default: 1."
401 }
402 },
403 "required": ["location"]
404 })
405 }
406
407 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
408 let location = match args.get("location").and_then(|v| v.as_str()) {
409 Some(loc) if !loc.trim().is_empty() => loc.trim().to_string(),
410 _ => {
411 return Ok(ToolResult {
412 success: false,
413 output: String::new(),
414 error: Some("Missing required parameter 'location'".into()),
415 });
416 }
417 };
418
419 let metric = args
420 .get("units")
421 .and_then(|v| v.as_str())
422 .map(|u| u.to_lowercase() != "imperial")
423 .unwrap_or(true);
424
425 let days: u8 = args
426 .get("days")
427 .and_then(|v| v.as_u64())
428 .map(|d| d.min(3) as u8)
429 .unwrap_or(1);
430
431 match Self::fetch(&location).await {
432 Ok(data) => {
433 let output = Self::format_output(&data, metric, days);
434 Ok(ToolResult {
435 success: true,
436 output,
437 error: None,
438 })
439 }
440 Err(e) => Ok(ToolResult {
441 success: false,
442 output: String::new(),
443 error: Some(e.to_string()),
444 }),
445 }
446 }
447}
448
449#[cfg(test)]
452mod tests {
453 use super::*;
454
455 fn make_tool() -> WeatherTool {
456 WeatherTool::new()
457 }
458
459 #[test]
462 fn name_is_weather() {
463 assert_eq!(make_tool().name(), "weather");
464 }
465
466 #[test]
467 fn description_is_non_empty() {
468 assert!(!make_tool().description().is_empty());
469 }
470
471 #[test]
472 fn parameters_schema_is_valid_object() {
473 let schema = make_tool().parameters_schema();
474 assert_eq!(schema["type"], "object");
475 assert!(schema["properties"].is_object());
476 }
477
478 #[test]
479 fn schema_requires_location() {
480 let schema = make_tool().parameters_schema();
481 let required = schema["required"].as_array().unwrap();
482 assert!(required.contains(&Value::String("location".into())));
483 }
484
485 #[test]
486 fn schema_location_property_exists() {
487 let schema = make_tool().parameters_schema();
488 assert!(schema["properties"]["location"].is_object());
489 assert_eq!(schema["properties"]["location"]["type"], "string");
490 }
491
492 #[test]
493 fn schema_units_property_has_enum() {
494 let schema = make_tool().parameters_schema();
495 let units = &schema["properties"]["units"];
496 assert!(units.is_object());
497 let enums = units["enum"].as_array().unwrap();
498 assert!(enums.contains(&Value::String("metric".into())));
499 assert!(enums.contains(&Value::String("imperial".into())));
500 }
501
502 #[test]
503 fn schema_days_has_bounds() {
504 let schema = make_tool().parameters_schema();
505 let days = &schema["properties"]["days"];
506 assert_eq!(days["minimum"], 0);
507 assert_eq!(days["maximum"], 3);
508 }
509
510 #[test]
513 fn build_url_city_name() {
514 let url = WeatherTool::build_url("London");
515 assert_eq!(url, "https://wttr.in/London?format=j1");
516 }
517
518 #[test]
519 fn build_url_encodes_spaces() {
520 let url = WeatherTool::build_url("New York");
521 assert_eq!(url, "https://wttr.in/New+York?format=j1");
522 }
523
524 #[test]
525 fn build_url_trims_whitespace() {
526 let url = WeatherTool::build_url(" Paris ");
527 assert_eq!(url, "https://wttr.in/Paris?format=j1");
528 }
529
530 #[test]
531 fn build_url_gps_coordinates() {
532 let url = WeatherTool::build_url("51.5,-0.1");
533 assert_eq!(url, "https://wttr.in/51.5,-0.1?format=j1");
534 }
535
536 #[test]
537 fn build_url_airport_code() {
538 let url = WeatherTool::build_url("LAX");
539 assert_eq!(url, "https://wttr.in/LAX?format=j1");
540 }
541
542 #[test]
543 fn build_url_zip_code() {
544 let url = WeatherTool::build_url("74015");
545 assert_eq!(url, "https://wttr.in/74015?format=j1");
546 }
547
548 #[tokio::test]
551 async fn execute_missing_location_returns_error() {
552 let result = make_tool().execute(json!({})).await.unwrap();
553 assert!(!result.success);
554 assert!(result.error.unwrap().contains("location"));
555 }
556
557 #[tokio::test]
558 async fn execute_empty_location_returns_error() {
559 let result = make_tool()
560 .execute(json!({"location": " "}))
561 .await
562 .unwrap();
563 assert!(!result.success);
564 assert!(result.error.unwrap().contains("location"));
565 }
566
567 #[tokio::test]
568 async fn execute_null_location_returns_error() {
569 let result = make_tool()
570 .execute(json!({"location": null}))
571 .await
572 .unwrap();
573 assert!(!result.success);
574 }
575
576 #[test]
579 fn format_hourly_metric() {
580 let h = HourlyCondition {
581 time: "900".into(),
582 temp_c: "15".into(),
583 temp_f: "59".into(),
584 weather_desc: vec![StringValue {
585 value: "Sunny".into(),
586 }],
587 chance_of_rain: "5".into(),
588 chance_of_snow: "0".into(),
589 windspeed_kmph: "20".into(),
590 windspeed_miles: "12".into(),
591 winddir_16point: "SW".into(),
592 };
593 let formatted = WeatherTool::format_hourly(&h, true);
594 assert!(formatted.contains("09:00"));
595 assert!(formatted.contains("15°C"));
596 assert!(formatted.contains("Sunny"));
597 assert!(formatted.contains("20 km/h"));
598 assert!(formatted.contains("SW"));
599 }
600
601 #[test]
602 fn format_hourly_imperial() {
603 let h = HourlyCondition {
604 time: "1200".into(),
605 temp_c: "20".into(),
606 temp_f: "68".into(),
607 weather_desc: vec![StringValue {
608 value: "Clear".into(),
609 }],
610 chance_of_rain: "0".into(),
611 chance_of_snow: "0".into(),
612 windspeed_kmph: "16".into(),
613 windspeed_miles: "10".into(),
614 winddir_16point: "NW".into(),
615 };
616 let formatted = WeatherTool::format_hourly(&h, false);
617 assert!(formatted.contains("12:00"));
618 assert!(formatted.contains("68°F"));
619 assert!(formatted.contains("10 mph"));
620 }
621
622 #[test]
623 fn format_hourly_midnight_slot() {
624 let h = HourlyCondition {
625 time: "0".into(),
626 temp_c: "8".into(),
627 temp_f: "46".into(),
628 weather_desc: vec![StringValue {
629 value: "Clear".into(),
630 }],
631 chance_of_rain: "0".into(),
632 chance_of_snow: "0".into(),
633 windspeed_kmph: "5".into(),
634 windspeed_miles: "3".into(),
635 winddir_16point: "N".into(),
636 };
637 let formatted = WeatherTool::format_hourly(&h, true);
638 assert!(formatted.contains("00:00"));
639 }
640
641 fn make_day(date: &str) -> WeatherDay {
644 WeatherDay {
645 date: date.into(),
646 max_temp_c: "18".into(),
647 max_temp_f: "64".into(),
648 min_temp_c: "8".into(),
649 min_temp_f: "46".into(),
650 avg_temp_c: "13".into(),
651 avg_temp_f: "55".into(),
652 sun_hours: "8.5".into(),
653 uv_index: "3".into(),
654 total_snow_cm: "0.0".into(),
655 astronomy: vec![Astronomy {
656 sunrise: "06:00 AM".into(),
657 sunset: "06:30 PM".into(),
658 moon_phase: "Waxing Crescent".into(),
659 }],
660 hourly: vec![
661 HourlyCondition {
662 time: "600".into(),
663 temp_c: "10".into(),
664 temp_f: "50".into(),
665 weather_desc: vec![StringValue {
666 value: "Sunny".into(),
667 }],
668 chance_of_rain: "0".into(),
669 chance_of_snow: "0".into(),
670 windspeed_kmph: "10".into(),
671 windspeed_miles: "6".into(),
672 winddir_16point: "N".into(),
673 },
674 HourlyCondition {
675 time: "1200".into(),
676 temp_c: "16".into(),
677 temp_f: "61".into(),
678 weather_desc: vec![StringValue {
679 value: "Partly Cloudy".into(),
680 }],
681 chance_of_rain: "20".into(),
682 chance_of_snow: "0".into(),
683 windspeed_kmph: "15".into(),
684 windspeed_miles: "9".into(),
685 winddir_16point: "NE".into(),
686 },
687 ],
688 }
689 }
690
691 #[test]
692 fn format_day_metric_contains_temps() {
693 let day = make_day("2026-03-21");
694 let out = WeatherTool::format_day(&day, true, false);
695 assert!(out.contains("18°C"));
696 assert!(out.contains("8°C"));
697 assert!(out.contains("13°C"));
698 assert!(out.contains("2026-03-21"));
699 }
700
701 #[test]
702 fn format_day_imperial_contains_temps() {
703 let day = make_day("2026-03-21");
704 let out = WeatherTool::format_day(&day, false, false);
705 assert!(out.contains("64°F"));
706 assert!(out.contains("46°F"));
707 }
708
709 #[test]
710 fn format_day_includes_astronomy() {
711 let day = make_day("2026-03-21");
712 let out = WeatherTool::format_day(&day, true, false);
713 assert!(out.contains("06:00 AM"));
714 assert!(out.contains("06:30 PM"));
715 assert!(out.contains("Waxing Crescent"));
716 }
717
718 #[test]
719 fn format_day_with_hourly_expands_output() {
720 let day = make_day("2026-03-21");
721 let without = WeatherTool::format_day(&day, true, false);
722 let with_hourly = WeatherTool::format_day(&day, true, true);
723 assert!(with_hourly.len() > without.len());
724 assert!(with_hourly.contains("06:00"));
725 }
726
727 #[test]
728 fn format_day_snow_metric_shown_when_nonzero() {
729 let mut day = make_day("2026-03-21");
730 day.total_snow_cm = "5.0".into();
731 let out = WeatherTool::format_day(&day, true, false);
732 assert!(out.contains("5.0 cm"));
733 }
734
735 #[test]
736 fn format_day_snow_imperial_converted() {
737 let mut day = make_day("2026-03-21");
738 day.total_snow_cm = "2.54".into();
739 let out = WeatherTool::format_day(&day, false, false);
740 assert!(out.contains("1.0 in"));
741 }
742
743 #[test]
744 fn format_day_no_snow_note_when_zero() {
745 let day = make_day("2026-03-21");
746 let out = WeatherTool::format_day(&day, true, false);
747 assert!(!out.contains("Snow:"));
748 }
749
750 fn make_response() -> WttrResponse {
753 WttrResponse {
754 current_condition: vec![CurrentCondition {
755 temp_c: "12".into(),
756 temp_f: "54".into(),
757 feels_like_c: "10".into(),
758 feels_like_f: "50".into(),
759 humidity: "72".into(),
760 weather_desc: vec![StringValue {
761 value: "Partly cloudy".into(),
762 }],
763 windspeed_kmph: "18".into(),
764 windspeed_miles: "11".into(),
765 winddir_16point: "WSW".into(),
766 precip_mm: "0.1".into(),
767 precip_inches: "0.0".into(),
768 visibility: "10".into(),
769 visibility_miles: "6".into(),
770 uv_index: "2".into(),
771 cloud_cover: "55".into(),
772 pressure_mb: "1015".into(),
773 pressure_inches: "30".into(),
774 observation_time: "10:00 AM".into(),
775 }],
776 nearest_area: vec![NearestArea {
777 area_name: vec![StringValue {
778 value: "Tulsa".into(),
779 }],
780 country: vec![StringValue {
781 value: "United States".into(),
782 }],
783 region: vec![StringValue {
784 value: "Oklahoma".into(),
785 }],
786 }],
787 weather: vec![make_day("2026-03-20"), make_day("2026-03-21")],
788 }
789 }
790
791 #[test]
792 fn format_output_metric_current_only() {
793 let data = make_response();
794 let out = WeatherTool::format_output(&data, true, 0);
795 assert!(out.contains("Tulsa"));
796 assert!(out.contains("12°C"));
797 assert!(out.contains("10°C")); assert!(out.contains("Partly cloudy"));
799 assert!(out.contains("72%")); assert!(out.contains("18 km/h"));
801 assert!(out.contains("WSW"));
802 assert!(!out.contains("Forecast"));
803 }
804
805 #[test]
806 fn format_output_imperial_current_only() {
807 let data = make_response();
808 let out = WeatherTool::format_output(&data, false, 0);
809 assert!(out.contains("54°F"));
810 assert!(out.contains("50°F"));
811 assert!(out.contains("11 mph"));
812 }
813
814 #[test]
815 fn format_output_includes_forecast_when_days_gt_0() {
816 let data = make_response();
817 let out = WeatherTool::format_output(&data, true, 2);
818 assert!(out.contains("Forecast"));
819 assert!(out.contains("2026-03-20"));
820 assert!(out.contains("2026-03-21"));
821 }
822
823 #[test]
824 fn format_output_respects_days_limit() {
825 let data = make_response();
826 let out = WeatherTool::format_output(&data, true, 1);
828 assert!(out.contains("2026-03-20"));
829 assert!(!out.contains("2026-03-21"));
830 }
831
832 #[test]
833 fn format_output_includes_location_region_country() {
834 let data = make_response();
835 let out = WeatherTool::format_output(&data, true, 0);
836 assert!(out.contains("Tulsa"));
837 assert!(out.contains("Oklahoma"));
838 assert!(out.contains("United States"));
839 }
840
841 #[test]
842 fn format_output_empty_current_condition_is_graceful() {
843 let mut data = make_response();
844 data.current_condition.clear();
845 let out = WeatherTool::format_output(&data, true, 0);
846 assert!(out.contains("No current conditions available"));
847 }
848
849 #[test]
850 fn format_output_location_without_region() {
851 let mut data = make_response();
852 data.nearest_area[0].region.clear();
853 let out = WeatherTool::format_output(&data, true, 0);
854 assert!(out.contains("Tulsa"));
855 assert!(out.contains("United States"));
856 }
857
858 #[tokio::test]
861 async fn execute_clamps_days_above_3() {
862 let data = make_response();
866 let out = WeatherTool::format_output(&data, true, 3u8);
868 assert!(out.contains("Forecast"));
869 }
870
871 #[test]
874 fn spec_reflects_tool_metadata() {
875 let tool = make_tool();
876 let spec = tool.spec();
877 assert_eq!(spec.name, "weather");
878 assert_eq!(spec.description, tool.description());
879 assert!(spec.parameters.is_object());
880 }
881}