Skip to main content

zeroclaw_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 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// ── 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("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        // wttr.in returns a plain-text error string (not JSON) for unknown locations.
174        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    /// Format a single hourly slot for the forecast block.
196    fn format_hourly(h: &HourlyCondition, metric: bool) -> String {
197        // wttr.in encodes time as "0", "300", "600" … "2100" (HHMM without leading zero)
198        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    /// Format a full day forecast block.
222    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                // convert cm → inches for imperial display
247                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            // Emit every other slot (3-hourly → 6-hourly) to keep output concise
266            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    /// Build the final human-readable output string.
276    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        // Forecast days (wttr.in always returns 3 days; day 0 = today)
342        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// ── Tool trait ────────────────────────────────────────────────────────────────
363
364#[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// ── Tests ─────────────────────────────────────────────────────────────────────
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    fn make_tool() -> WeatherTool {
456        WeatherTool::new()
457    }
458
459    // ── Metadata ──────────────────────────────────────────────────────────────
460
461    #[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    // ── URL building ──────────────────────────────────────────────────────────
511
512    #[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    // ── execute: parameter validation ─────────────────────────────────────────
549
550    #[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    // ── format_hourly ─────────────────────────────────────────────────────────
577
578    #[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    // ── format_day ────────────────────────────────────────────────────────────
642
643    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    // ── format_output ─────────────────────────────────────────────────────────
751
752    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")); // feels like
798        assert!(out.contains("Partly cloudy"));
799        assert!(out.contains("72%")); // humidity
800        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        // Only 1 day requested
827        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    // ── days clamping ─────────────────────────────────────────────────────────
859
860    #[tokio::test]
861    async fn execute_clamps_days_above_3() {
862        // We can't hit the network in unit tests, but we can verify that
863        // the days argument is clamped before it reaches fetch by inspecting
864        // format_output: supply a mock response and call format_output directly.
865        let data = make_response();
866        // 99 clamped to 3 → should only emit up to 2 days (our mock has 2)
867        let out = WeatherTool::format_output(&data, true, 3u8);
868        assert!(out.contains("Forecast"));
869    }
870
871    // ── spec ──────────────────────────────────────────────────────────────────
872
873    #[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}