Skip to main content

construct/tools/
weather_tool.rs

1//! Weather tool — fetches current conditions and forecast via wttr.in.
2//!
3//! Uses the free, no-API-key wttr.in service (`?format=j1` JSON endpoint).
4//! Supports any location wttr.in accepts: city names (in any language/script),
5//! airport IATA codes, GPS coordinates, zip/postal codes, and domain-based
6//! geolocation. Units default to metric but can be overridden per-call.
7
8use 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// ── wttr.in JSON response types ───────────────────────────────────────────────
19
20#[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
131// ── Tool struct ───────────────────────────────────────────────────────────────
132
133/// Fetches weather data from wttr.in — no API key required, global coverage.
134pub struct WeatherTool;
135
136impl WeatherTool {
137    pub fn new() -> Self {
138        Self
139    }
140
141    /// Build the wttr.in request URL for the given location.
142    fn build_url(location: &str) -> String {
143        // Percent-encode spaces; wttr.in also accepts `+` but %20 is safer.
144        let encoded = location.trim().replace(' ', "+");
145        format!("{WTTR_BASE_URL}/{encoded}?format=j1")
146    }
147
148    /// Fetch and parse the wttr.in JSON response.
149    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        // wttr.in returns a plain-text error string (not JSON) for unknown locations.
173        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    /// Format a single hourly slot for the forecast block.
187    fn format_hourly(h: &HourlyCondition, metric: bool) -> String {
188        // wttr.in encodes time as "0", "300", "600" … "2100" (HHMM without leading zero)
189        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    /// Format a full day forecast block.
213    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                // convert cm → inches for imperial display
238                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            // Emit every other slot (3-hourly → 6-hourly) to keep output concise
257            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    /// Build the final human-readable output string.
267    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        // Forecast days (wttr.in always returns 3 days; day 0 = today)
333        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// ── Tool trait ────────────────────────────────────────────────────────────────
354
355#[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// ── Tests ─────────────────────────────────────────────────────────────────────
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    fn make_tool() -> WeatherTool {
447        WeatherTool::new()
448    }
449
450    // ── Metadata ──────────────────────────────────────────────────────────────
451
452    #[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    // ── URL building ──────────────────────────────────────────────────────────
502
503    #[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    // ── execute: parameter validation ─────────────────────────────────────────
540
541    #[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    // ── format_hourly ─────────────────────────────────────────────────────────
568
569    #[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    // ── format_day ────────────────────────────────────────────────────────────
633
634    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    // ── format_output ─────────────────────────────────────────────────────────
742
743    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")); // feels like
789        assert!(out.contains("Partly cloudy"));
790        assert!(out.contains("72%")); // humidity
791        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        // Only 1 day requested
818        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    // ── days clamping ─────────────────────────────────────────────────────────
850
851    #[tokio::test]
852    async fn execute_clamps_days_above_3() {
853        // We can't hit the network in unit tests, but we can verify that
854        // the days argument is clamped before it reaches fetch by inspecting
855        // format_output: supply a mock response and call format_output directly.
856        let data = make_response();
857        // 99 clamped to 3 → should only emit up to 2 days (our mock has 2)
858        let out = WeatherTool::format_output(&data, true, 3u8);
859        assert!(out.contains("Forecast"));
860    }
861
862    // ── spec ──────────────────────────────────────────────────────────────────
863
864    #[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}