Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,22 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- Live mode key bindings: `q` / `Esc` / `Ctrl+C` quit immediately, `r` forces an immediate refresh.
- Optional one-line footer in live mode showing key hints and the last-update timestamp.
Toggle via `live_mode_footer = true|false` in the config file or `--no-footer` on the CLI.
- Distinct night-time icons and ASCII art for Clear and Partly Cloudy conditions. The moon (🌙) replaces the sun
(☀️) at night, and Partly Cloudy switches to a moon-and-cloud combination. Day/night is detected automatically
per location and updates live as the sun crosses the horizon
([#29](https://github.com/Tairesh/rustormy/pull/29)).
- `is_day`, `latitude`, and `longitude` fields in JSON output.

### Changed

- JSON output: `location_name` is now nested as `location.name`, alongside `location.latitude` and
`location.longitude`. Scripts parsing JSON output should switch from `location_name` to `location.name`.

### Fixed

- Live mode now uses the terminal's alternate screen buffer, so your existing terminal contents are preserved on exit
([#28](https://github.com/Tairesh/rustormy/pull/28)).
- World Weather Online: trailing whitespace in weather descriptions (e.g. `"Cloudy "`) is now trimmed.

## [0.4.4] - 2026-04-07

Expand Down
7 changes: 5 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use crate::display::formatter::WeatherFormatter;
use crate::errors::RustormyError;
use crate::live::run as run_live;
use crate::models::{Provider, Weather};
use crate::weather::{GetWeather, GetWeatherProvider};
use crate::weather::{GetWeather, GetWeatherProvider, enrich};
use reqwest::blocking::Client;
use std::time::Duration;

Expand Down Expand Up @@ -42,7 +42,10 @@ impl App {
pub fn fetch_with_fallback(&mut self) -> Result<Weather, RustormyError> {
loop {
match self.provider.get_weather(&self.client, &self.config) {
Ok(weather) => return Ok(weather),
Ok(mut weather) => {
enrich(&mut weather, &self.client, &self.config)?;
return Ok(weather);
}
Err(error) => match error {
RustormyError::ApiReturnedError(_) | RustormyError::HttpRequestFailed(_) => {
let p: Provider = (&self.provider).into();
Expand Down
5 changes: 5 additions & 0 deletions src/config/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,11 @@ impl Config {
&self.api_keys
}

#[cfg(test)]
pub fn api_keys_mut(&mut self) -> &mut ApiKeys {
&mut self.api_keys
}

pub fn city(&self) -> Option<&str> {
self.city.as_deref()
}
Expand Down
54 changes: 46 additions & 8 deletions src/display/formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ impl WeatherFormatter {
fn format_one_line(&self, weather: &Weather) -> String {
let color_theme = &self.config.color_theme;
let (temp_unit, wind_unit, _) = unit_strings(self.config.units, self.config.language);
let emoji = weather.icon.emoji();
let emoji = weather.icon.emoji(weather.is_day.unwrap_or(true));
let mut temperature = format!("{:.1}{}", weather.temperature, temp_unit);
if self.config.use_colors {
temperature = colored_text(temperature, color_theme.temperature);
Expand All @@ -130,9 +130,9 @@ impl WeatherFormatter {

if self.config.show_city_name {
let location = if self.config.use_colors {
colored_text(&weather.location_name, color_theme.location)
colored_text(&weather.location.name, color_theme.location)
} else {
weather.location_name.clone()
weather.location.name.clone()
};
format!("{location}: {value}")
} else {
Expand All @@ -148,10 +148,11 @@ impl WeatherFormatter {
self.config.language,
);
let (temp_unit, wind_unit, precip_unit) = unit_strings(self.config.units, lang);
let is_day = weather.is_day.unwrap_or(true);
let icon = if colors {
weather.icon.colored_icon()
weather.icon.colored_icon(is_day)
} else {
weather.icon.icon()
weather.icon.icon(is_day)
};
let color_theme = &self.config.color_theme;

Expand All @@ -161,7 +162,7 @@ impl WeatherFormatter {
output.push(make_line(
icon[0],
"Location",
&weather.location_name,
&weather.location.name,
color_theme.location,
&self.config,
));
Expand Down Expand Up @@ -251,7 +252,7 @@ impl WeatherFormatter {
mod tests {
use super::*;
use crate::config::Config;
use crate::models::{Language, TextMode, Units, WeatherConditionIcon};
use crate::models::{Language, Location, TextMode, Units, WeatherConditionIcon};

fn sample_weather() -> Weather {
Weather {
Expand All @@ -264,9 +265,10 @@ mod tests {
wind_speed: 5.0,
wind_direction: 90,
uv_index: None,
is_day: Some(true),
description: "Partly cloudy".to_string(),
icon: WeatherConditionIcon::PartlyCloudy,
location_name: "Test City".to_string(),
location: Location::new("Test City".to_string(), 0.0, 0.0),
}
}

Expand Down Expand Up @@ -762,4 +764,40 @@ mod tests {
lines[1]
);
}

#[test]
fn test_night_clear_uses_moon_emoji() {
let mut weather = sample_weather();
weather.icon = WeatherConditionIcon::Clear;
weather.is_day = Some(false);
let mut config = Config::default();
config.set_format(FormatterConfig {
text_mode: TextMode::OneLine,
..Default::default()
});
let formatter = WeatherFormatter::new(&config);
let line = formatter.format_one_line(&weather);
assert!(
line.contains("🌙"),
"expected moon emoji in night clear output, got '{line}'"
);
}

#[test]
fn test_day_clear_uses_sun_emoji() {
let mut weather = sample_weather();
weather.icon = WeatherConditionIcon::Clear;
weather.is_day = Some(true);
let mut config = Config::default();
config.set_format(FormatterConfig {
text_mode: TextMode::OneLine,
..Default::default()
});
let formatter = WeatherFormatter::new(&config);
let line = formatter.format_one_line(&weather);
assert!(
line.contains("☀"),
"expected sun emoji in day clear output, got '{line}'"
);
}
}
124 changes: 114 additions & 10 deletions src/display/icons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ use crate::models::WeatherConditionIcon;
pub type Icon = [&'static str; 7];

impl WeatherConditionIcon {
pub fn icon(self) -> Icon {
#[allow(clippy::too_many_lines)]
pub fn icon(self, is_day: bool) -> Icon {
match self {
Self::Unknown => [
" ",
Expand All @@ -14,7 +15,7 @@ impl WeatherConditionIcon {
" • ",
" ",
],
Self::Clear => [
Self::Clear if is_day => [
" ",
" \\ / ",
" .-. ",
Expand All @@ -23,7 +24,16 @@ impl WeatherConditionIcon {
" / \\ ",
" ",
],
Self::PartlyCloudy => [
Self::Clear => [
" . * ",
" .-. ",
" . ( ) ",
" `-’ ",
" * . ",
" ",
" ",
],
Self::PartlyCloudy if is_day => [
" ",
" \\ / ",
" _ /\"\".-. ",
Expand All @@ -32,6 +42,15 @@ impl WeatherConditionIcon {
" ",
" ",
],
Self::PartlyCloudy => [
" ",
" . * ",
" _ .-. ",
" ( ). ",
" *(___(__) ",
" ",
" ",
],
Self::Cloudy => [
" ",
" ",
Expand Down Expand Up @@ -98,7 +117,8 @@ impl WeatherConditionIcon {
}
}

pub fn colored_icon(self) -> Icon {
#[allow(clippy::too_many_lines)]
pub fn colored_icon(self, is_day: bool) -> Icon {
match self {
Self::Unknown => [
" ",
Expand All @@ -109,7 +129,7 @@ impl WeatherConditionIcon {
" • ",
" ",
],
Self::Clear => [
Self::Clear if is_day => [
" ",
"\x1b[38;5;226m \\ / \x1b[0m",
"\x1b[38;5;226m .-. \x1b[0m",
Expand All @@ -118,7 +138,16 @@ impl WeatherConditionIcon {
"\x1b[38;5;226m / \\ \x1b[0m",
" ",
],
Self::PartlyCloudy => [
Self::Clear => [
"\x1b[38;5;111;1m . * \x1b[0m",
"\x1b[38;5;230;1m .-. \x1b[0m",
"\x1b[38;5;111;1m . \x1b[38;5;230;1m( ) \x1b[0m",
"\x1b[38;5;230;1m `-’ \x1b[0m",
"\x1b[38;5;111;1m * . \x1b[0m",
" ",
" ",
],
Self::PartlyCloudy if is_day => [
" ",
"\x1b[38;5;226m \\ /\x1b[0m ",
"\x1b[38;5;226m _ /\"\"\x1b[38;5;250m.-. \x1b[0m",
Expand All @@ -127,6 +156,15 @@ impl WeatherConditionIcon {
" ",
" ",
],
Self::PartlyCloudy => [
" ",
"\x1b[38;5;111;1m . * \x1b[0m",
"\x1b[38;5;230;1m \x1b[38;5;250m.-. \x1b[0m",
"\x1b[38;5;250m ( ). \x1b[0m",
"\x1b[38;5;111;1m *\x1b[38;5;250m(___(__) \x1b[0m",
" ",
" ",
],
Self::Cloudy => [
" ",
" ",
Expand Down Expand Up @@ -193,12 +231,13 @@ impl WeatherConditionIcon {
}
}

pub fn emoji(self) -> &'static str {
pub fn emoji(self, is_day: bool) -> &'static str {
match self {
WeatherConditionIcon::Unknown => "❓",
WeatherConditionIcon::Clear => "☀️ ",
WeatherConditionIcon::PartlyCloudy => "⛅️",
WeatherConditionIcon::Cloudy => "☁️ ",
WeatherConditionIcon::Clear if is_day => "☀️ ",
WeatherConditionIcon::Clear => "🌙",
WeatherConditionIcon::PartlyCloudy if is_day => "⛅️",
WeatherConditionIcon::PartlyCloudy | WeatherConditionIcon::Cloudy => "☁️ ",
WeatherConditionIcon::LightShowers => "🌦️ ",
WeatherConditionIcon::HeavyShowers => "🌧️ ",
WeatherConditionIcon::LightSnow => "🌨️ ",
Expand All @@ -208,3 +247,68 @@ impl WeatherConditionIcon {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn clear_day_and_night_differ() {
assert_ne!(
WeatherConditionIcon::Clear.icon(true),
WeatherConditionIcon::Clear.icon(false)
);
assert_ne!(
WeatherConditionIcon::Clear.colored_icon(true),
WeatherConditionIcon::Clear.colored_icon(false)
);
assert_ne!(
WeatherConditionIcon::Clear.emoji(true),
WeatherConditionIcon::Clear.emoji(false)
);
}

#[test]
fn partly_cloudy_day_and_night_differ() {
assert_ne!(
WeatherConditionIcon::PartlyCloudy.icon(true),
WeatherConditionIcon::PartlyCloudy.icon(false)
);
assert_ne!(
WeatherConditionIcon::PartlyCloudy.emoji(true),
WeatherConditionIcon::PartlyCloudy.emoji(false)
);
}

#[test]
fn cloudy_ignores_is_day() {
assert_eq!(
WeatherConditionIcon::Cloudy.icon(true),
WeatherConditionIcon::Cloudy.icon(false)
);
assert_eq!(
WeatherConditionIcon::Cloudy.colored_icon(true),
WeatherConditionIcon::Cloudy.colored_icon(false)
);
assert_eq!(
WeatherConditionIcon::Cloudy.emoji(true),
WeatherConditionIcon::Cloudy.emoji(false)
);
}

#[test]
fn other_conditions_ignore_is_day() {
for variant in [
WeatherConditionIcon::Unknown,
WeatherConditionIcon::LightShowers,
WeatherConditionIcon::HeavyShowers,
WeatherConditionIcon::LightSnow,
WeatherConditionIcon::HeavySnow,
WeatherConditionIcon::Thunderstorm,
WeatherConditionIcon::Fog,
] {
assert_eq!(variant.icon(true), variant.icon(false), "{variant:?}");
assert_eq!(variant.emoji(true), variant.emoji(false), "{variant:?}");
}
}
}
13 changes: 12 additions & 1 deletion src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,10 @@ pub struct Weather {
pub wind_speed: f64,
pub wind_direction: u16,
pub uv_index: Option<f64>,
pub is_day: Option<bool>,
pub description: String,
pub icon: WeatherConditionIcon,
pub location_name: String,
pub location: Location,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand All @@ -96,6 +97,16 @@ pub struct Location {
pub longitude: f64,
}

impl Location {
pub fn new(name: String, latitude: f64, longitude: f64) -> Self {
Self {
name,
latitude,
longitude,
}
}
}

#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, ValueEnum, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Language {
Expand Down
Loading