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