10 · Project — Weather CLI¶
The capstone for Level 2: a command-line tool that looks up a city, fetches
its current conditions and an hourly forecast from a real public API, and
prints a summary. It deliberately pulls together everything from this
level: a trait (WeatherSource) so the fetch logic is swappable, a
generic function built against that trait, closures and iterator
adapters to crunch the forecast data, a custom error type that unifies
network and "city not found" failures, and unit tests for the pure
logic that don't require a network call to run.
The API — no key required¶
This project uses Open-Meteo, a free weather API that needs no API key or sign-up. It's actually two endpoints:
- Geocoding — turn a city name into coordinates:
https://geocoding-api.open-meteo.com/v1/search?name=Tokyo&count=1&language=en&format=json - Forecast — fetch current + hourly weather for those coordinates:
https://api.open-meteo.com/v1/forecast?latitude=..&longitude=..¤t_weather=true&hourly=temperature_2m
If you swap in a different weather API that does require a key, the only
place it belongs is inside OpenMeteoClient (or a renamed equivalent) —
that's exactly the point of hiding the fetch behind the WeatherSource
trait.
Project layout¶
weather_cli/
Cargo.toml
src/
main.rs -- CLI entry point, argument handling, report rendering
client.rs -- WeatherSource trait + the OpenMeteoClient implementation
error.rs -- WeatherError, the custom error type
models.rs -- Deserialize structs for the API responses + WeatherReport
stats.rs -- Pure functions over forecast data (unit tested)
# Cargo.toml
[package]
name = "weather_cli"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = { version = "0.12", features = ["blocking", "json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
reqwest's blocking feature avoids needing an async runtime for a small
CLI — fine here since the program does one thing and exits; a server would
want the async client instead. serde's derive feature is what generates
Deserialize implementations from #[derive(Deserialize)].
src/error.rs — one error type for everything that can go wrong¶
// src/error.rs
use std::fmt;
#[derive(Debug)]
pub enum WeatherError {
Http(reqwest::Error),
CityNotFound(String),
}
impl fmt::Display for WeatherError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
WeatherError::Http(e) => write!(f, "network error: {e}"),
WeatherError::CityNotFound(city) => write!(f, "could not find a city named '{city}'"),
}
}
}
impl std::error::Error for WeatherError {}
impl From<reqwest::Error> for WeatherError {
fn from(e: reqwest::Error) -> Self {
WeatherError::Http(e)
}
}
The From<reqwest::Error> impl is what lets ? inside client.rs convert
a reqwest::Error into a WeatherError automatically — the same pattern
from Module 05.
src/models.rs — API response shapes and the report we build from them¶
// src/models.rs
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct GeoResult {
pub name: String,
pub latitude: f64,
pub longitude: f64,
pub country: String,
}
#[derive(Debug, Deserialize)]
pub struct GeoResponse {
pub results: Option<Vec<GeoResult>>,
}
#[derive(Debug, Deserialize)]
pub struct CurrentWeather {
pub temperature: f64,
pub windspeed: f64,
pub weathercode: u32,
}
#[derive(Debug, Deserialize)]
pub struct HourlyData {
pub temperature_2m: Vec<f64>,
}
#[derive(Debug, Deserialize)]
pub struct ForecastResponse {
pub current_weather: CurrentWeather,
pub hourly: HourlyData,
}
/// The shape our own code works with, independent of the API's JSON layout --
/// if the API changed its field names tomorrow, only `client.rs` would need
/// to change, not every place that consumes a report.
#[derive(Debug)]
pub struct WeatherReport {
pub city: String,
pub country: String,
pub current_temp_c: f64,
pub windspeed_kmh: f64,
pub weathercode: u32,
pub hourly_temps: Vec<f64>,
}
results: Option<Vec<GeoResult>> matters: when a city genuinely isn't
found, Open-Meteo returns JSON with no "results" key at all rather than an
empty array — Option models "the field might not be there," exactly like
Level 1's error handling intro
covered for Option<T> in general.
src/stats.rs — pure logic, kept separate so it's testable¶
// src/stats.rs
/// Describes a WMO weather code as a short human-readable string.
/// See https://open-meteo.com/en/docs for the full code table -- this
/// covers the common buckets.
pub fn describe_weathercode(code: u32) -> &'static str {
match code {
0 => "clear sky",
1 | 2 | 3 => "partly cloudy",
45 | 48 => "fog",
51..=57 => "drizzle",
61..=67 => "rain",
71..=77 => "snow",
80..=82 => "rain showers",
95..=99 => "thunderstorm",
_ => "unknown conditions",
}
}
/// Returns (min, max, average) of a slice of hourly temperatures.
/// Returns None if the slice is empty -- an average of zero items is undefined.
pub fn temperature_stats(temps: &[f64]) -> Option<(f64, f64, f64)> {
if temps.is_empty() {
return None;
}
let min = temps.iter().cloned().fold(f64::INFINITY, f64::min);
let max = temps.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let sum: f64 = temps.iter().sum();
let avg = sum / temps.len() as f64;
Some((min, max, avg))
}
/// Counts how many of the next `hours` hourly readings are above `threshold`.
pub fn hours_above(temps: &[f64], hours: usize, threshold: f64) -> usize {
temps.iter().take(hours).filter(|&&t| t > threshold).count()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn describes_known_codes() {
assert_eq!(describe_weathercode(0), "clear sky");
assert_eq!(describe_weathercode(63), "rain");
assert_eq!(describe_weathercode(999), "unknown conditions");
}
#[test]
fn computes_stats_correctly() {
let temps = vec![10.0, 20.0, 15.0];
let (min, max, avg) = temperature_stats(&temps).unwrap();
assert_eq!(min, 10.0);
assert_eq!(max, 20.0);
assert!((avg - 15.0).abs() < f64::EPSILON);
}
#[test]
fn empty_slice_has_no_stats() {
assert_eq!(temperature_stats(&[]), None);
}
#[test]
fn counts_hours_above_threshold() {
let temps = vec![10.0, 25.0, 30.0, 5.0, 40.0];
assert_eq!(hours_above(&temps, 3, 20.0), 2);
assert_eq!(hours_above(&temps, 5, 20.0), 3);
}
}
This is a deliberate design choice worth calling out: none of these
functions touch the network, so cargo test runs them instantly and
deterministically, with no risk of flaking due to a slow or unreachable API.
Keeping "pure computation" separate from "I/O" like this is one of the most
useful habits for making Rust code testable.
src/client.rs — the trait, and the one implementation that talks HTTP¶
// src/client.rs
use crate::error::WeatherError;
use crate::models::{ForecastResponse, GeoResponse, WeatherReport};
/// Anything that can look up a weather report for a city name.
/// Defining this as a trait (rather than calling OpenMeteoClient directly
/// everywhere) means the code that consumes a report can work with any
/// implementation -- a mock for tests, or a different weather API --
/// without changing a single line of the code that consumes it.
pub trait WeatherSource {
fn fetch(&self, city: &str) -> Result<WeatherReport, WeatherError>;
}
pub struct OpenMeteoClient {
http: reqwest::blocking::Client,
}
impl OpenMeteoClient {
pub fn new() -> Self {
OpenMeteoClient {
http: reqwest::blocking::Client::new(),
}
}
}
impl WeatherSource for OpenMeteoClient {
fn fetch(&self, city: &str) -> Result<WeatherReport, WeatherError> {
// Step 1: turn the city name into coordinates via the (free, no-key)
// Open-Meteo geocoding API.
let geo_url = format!(
"https://geocoding-api.open-meteo.com/v1/search?name={}&count=1&language=en&format=json",
urlencode(city)
);
let geo: GeoResponse = self.http.get(&geo_url).send()?.json()?;
let place = geo
.results
.and_then(|mut results| if results.is_empty() { None } else { Some(results.remove(0)) })
.ok_or_else(|| WeatherError::CityNotFound(city.to_string()))?;
// Step 2: fetch current weather + an hourly forecast for those coordinates.
let forecast_url = format!(
"https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}¤t_weather=true&hourly=temperature_2m",
place.latitude, place.longitude
);
let forecast: ForecastResponse = self.http.get(&forecast_url).send()?.json()?;
Ok(WeatherReport {
city: place.name,
country: place.country,
current_temp_c: forecast.current_weather.temperature,
windspeed_kmh: forecast.current_weather.windspeed,
weathercode: forecast.current_weather.weathercode,
hourly_temps: forecast.hourly.temperature_2m,
})
}
}
/// Minimal query-string escaping -- good enough for city names, which are
/// mostly letters and spaces. A real project would pull in the `url` crate.
fn urlencode(s: &str) -> String {
s.chars()
.map(|c| if c == ' ' { "%20".to_string() } else { c.to_string() })
.collect()
}
The two ? calls after self.http.get(&url).send() and .json() both
return reqwest::Error on failure, and both convert into WeatherError
automatically via the From impl in error.rs. .ok_or_else() turns the
"no results" case into the other WeatherError variant — this single
function ends up demonstrating both directions of Module 05's error story:
automatic conversion via ?, and manual conversion via .ok_or_else()/.map_err().
src/main.rs — tying it together with a generic function¶
// src/main.rs
mod client;
mod error;
mod models;
mod stats;
use client::{OpenMeteoClient, WeatherSource};
use error::WeatherError;
use models::WeatherReport;
/// Generic over any WeatherSource -- this function doesn't care whether the
/// report came from OpenMeteoClient or a test double, only that it
/// implements the trait. This is the traits + generics half of the project.
fn print_report<S: WeatherSource>(source: &S, city: &str) -> Result<(), WeatherError> {
let report: WeatherReport = source.fetch(city)?;
render(&report);
Ok(())
}
/// Uses closures and iterator adapters (filter, count, fold via the stats
/// module) over the hourly forecast to build the printed summary.
fn render(report: &WeatherReport) {
println!("Weather for {}, {}", report.city, report.country);
println!(
" Right now: {:.1}C, wind {:.1} km/h, {}",
report.current_temp_c,
report.windspeed_kmh,
stats::describe_weathercode(report.weathercode)
);
if let Some((min, max, avg)) = stats::temperature_stats(&report.hourly_temps) {
println!(
" Next {} hours: min {:.1}C, max {:.1}C, avg {:.1}C",
report.hourly_temps.len(),
min,
max,
avg
);
}
let warm_hours = stats::hours_above(&report.hourly_temps, 24, 25.0);
println!(" Hours above 25C in the next day: {warm_hours}");
}
fn main() {
let city = std::env::args().nth(1).unwrap_or_else(|| "London".to_string());
let client = OpenMeteoClient::new();
if let Err(e) = print_report(&client, &city) {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
print_report<S: WeatherSource> never mentions OpenMeteoClient by name —
if you later write a struct FakeSource for testing that returns a
hand-built WeatherReport without touching the network, print_report
works with it unchanged, because it only depends on the trait.
Running it¶
Weather for Tokyo, Japan
Right now: 30.8C, wind 5.4 km/h, partly cloudy
Next 168 hours: min 19.9C, max 32.4C, avg 26.2C
Hours above 25C in the next day: 13
(Real numbers, from live weather at the time this was run — yours will differ.) An unknown city hits the custom error path instead of crashing:
Run the test suite — this exercises stats.rs only, with zero network
calls:
running 4 tests
test stats::tests::describes_known_codes ... ok
test stats::tests::computes_stats_correctly ... ok
test stats::tests::empty_slice_has_no_stats ... ok
test stats::tests::counts_hours_above_threshold ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
How It Actually Works¶
print_report<S: WeatherSource> being generic over the trait rather than
taking a concrete OpenMeteoClient is monomorphization put to real use: the
compiler generates one specialized copy of print_report for
OpenMeteoClient in production and a separate specialized copy for
FakeSource in tests, each with its fetch call inlined directly against
that concrete type — the "swap in a fake for testing" pattern costs nothing
at runtime because there's no dynamic dispatch involved at all, unlike the
dyn Trait approach from Module 3 which would add a vtable indirection at
every call. This is why the codebase can test stats.rs's pure numeric
logic with zero network calls: the generic boundary is a compile-time
seam, not a runtime one, so substituting FakeSource doesn't touch the
network client's code path at all.
The unknown-city case reaching a clean Error: could not find a city...
instead of a panic is the ?-and-From machinery from Module 5 doing real
work end to end: the HTTP client's error, the JSON-parsing error, and a
custom "not found" error all get unified into one AppError type via From
impls, so a single ? at each fallible step propagates whichever failure
occurred up to main, which prints it and exits non-zero — the same
mechanism, scaled up from a toy example to an actual network-calling CLI.
Stretch goals¶
- Add a
FakeSourceimplementingWeatherSourcethat returns a hand-builtWeatherReportwith no network call, and write an integration test intests/(see Module 06) that callsprint_reportagainst it. - Cache the geocoding lookup for a city in a local JSON file (using
serde_jsonand Module 08's file I/O) so repeat runs for the same city skip the geocoding API call. - Accept multiple cities on the command line and print a report for each,
using
std::env::args().skip(1)and aforloop — or, using Module 07'sRc<RefCell<T>>, collect results into a shared summary structure updated from a closure passed to.for_each(). - Add a
--jsonflag that, instead of the human-readable report, prints theWeatherReportserialized withserde_json::to_string_pretty(you'll need#[derive(Serialize)]onWeatherReport).