understand server 404s
All checks were successful
CI / Clippy (push) Successful in 1m38s
CI / Build (release) (push) Successful in 2m40s
CI / Build (debug) (push) Successful in 3m39s
CI / Notify (push) Successful in 3s

This commit is contained in:
Emil Lerch 2026-10-26 11:47:17 -07:00
parent dc5a562349
commit 1e38f203f0
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 66 additions and 32 deletions

View file

@ -547,7 +547,7 @@ async fn fetch_weather(units: Option<Units>) -> Result<(String, String), String>
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let text = response.text().await.map_err(|e| e.to_string())?; let text = response_text(response).await?;
let text = text.trim(); let text = text.trim();
// Detect captive portal: the weather API returns plain text, not HTML. // Detect captive portal: the weather API returns plain text, not HTML.
@ -555,14 +555,36 @@ async fn fetch_weather(units: Option<Units>) -> Result<(String, String), String>
return Err("Captive portal detected: received HTML instead of weather data".to_string()); return Err("Captive portal detected: received HTML instead of weather data".to_string());
} }
// Format: "location|icon|temp" parse_panel_weather(text)
}
/// Parse the panel's `location|icon|temp` response into
/// `(location, "icon|temp")`.
///
/// Anything else is an error rather than something to display: showing an
/// unexpected body verbatim in the panel would also skip the retry path.
fn parse_panel_weather(text: &str) -> Result<(String, String), String> {
let parts: Vec<&str> = text.splitn(3, '|').collect(); let parts: Vec<&str> = text.splitn(3, '|').collect();
if parts.len() == 3 { if let [location, icon, temp] = parts.as_slice() {
let location = parts[0].to_string(); Ok(((*location).to_string(), format!("{icon}|{temp}")))
let weather = format!("{}|{}", parts[1], parts[2]);
Ok((location, weather))
} else { } else {
Ok((String::new(), text.to_string())) Err(format!("Unexpected weather response format: {text:?}"))
}
}
/// Read the response body, failing on non-success HTTP status.
///
/// wttr.in reports failures such as "Location not found (location query
/// resolution failure)" with a 404 and a plain-text body. Without this check
/// that body would be treated as weather data: shown in the panel or cached
/// as the popup forecast, and never retried.
async fn response_text(response: reqwest::Response) -> Result<String, String> {
let status = response.status();
let text = response.text().await.map_err(|e| e.to_string())?;
if status.is_success() {
Ok(text)
} else {
Err(format!("HTTP {status}: {}", text.trim()))
} }
} }
@ -593,7 +615,7 @@ async fn fetch_full_weather(units: Option<Units>) -> Result<String, String> {
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let text = response.text().await.map_err(|e| e.to_string())?; let text = response_text(response).await?;
let text = text.trim(); let text = text.trim();
if looks_like_captive_portal(text) { if looks_like_captive_portal(text) {
@ -677,3 +699,26 @@ fn pad_to_forecast_height(text: &str) -> String {
} }
padded padded
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_panel_weather_splits_fields() {
assert_eq!(
parse_panel_weather("San Francisco, California, United States|☀️|+59.9°F"),
Ok((
"San Francisco, California, United States".to_string(),
"☀️|+59.9°F".to_string()
))
);
}
#[test]
fn parse_panel_weather_rejects_error_body() {
let err = parse_panel_weather("Location not found (location query resolution failure)")
.unwrap_err();
assert!(err.contains("Location not found"), "{err}");
}
}

View file

@ -46,31 +46,21 @@ const USCS_LOCALES: &[&str] = &["en_US", "en_LR", "en_BS", "en_BZ", "en_KY", "en
/// client's geolocation. /// client's geolocation.
#[must_use] #[must_use]
pub fn resolve_units() -> Option<Units> { pub fn resolve_units() -> Option<Units> {
if let Some(u) = units_from_env() { units_from_env().or_else(units_from_locale)
return u;
}
units_from_locale()
} }
/// Parse `COSMIC_WEATHER_UNITS`. /// Parse `COSMIC_WEATHER_UNITS`.
/// ///
/// Returns: /// Returns `Some(units)` only for an explicit unit choice. An unset, empty,
/// - `Some(Some(units))` — explicit override. /// `auto`, or unrecognized value returns `None` so the caller falls through
/// - `Some(None)` — explicit `auto` or empty; caller should still return /// to locale detection.
/// `None` overall (skip locale detection? No — we want `auto` to mean fn units_from_env() -> Option<Units> {
/// "fall through to locale", so this returns `None` to signal fall-through).
/// - `None` — env var unset; fall through.
///
/// To keep the control flow simple we collapse all fall-through cases to
/// a single `None` return and only return `Some(Some(_))` for an explicit
/// unit choice.
fn units_from_env() -> Option<Option<Units>> {
let raw = std::env::var("COSMIC_WEATHER_UNITS").ok()?; let raw = std::env::var("COSMIC_WEATHER_UNITS").ok()?;
let trimmed = raw.trim(); let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") { if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") {
return None; return None;
} }
let parsed = match trimmed { match trimmed {
"u" | "U" => Some(Units::Uscs), "u" | "U" => Some(Units::Uscs),
"m" => Some(Units::Metric), "m" => Some(Units::Metric),
"M" => Some(Units::MetricMs), "M" => Some(Units::MetricMs),
@ -79,26 +69,25 @@ fn units_from_env() -> Option<Option<Units>> {
"COSMIC_WEATHER_UNITS={other:?} is not recognized; \ "COSMIC_WEATHER_UNITS={other:?} is not recognized; \
expected one of: u, m, M, auto" expected one of: u, m, M, auto"
); );
return None; None
} }
}; }
Some(parsed)
} }
/// Inspect measurement-related locale env vars and map to `Units`. /// Inspect measurement-related locale env vars and map to `Units`.
fn units_from_locale() -> Option<Units> { fn units_from_locale() -> Option<Units> {
for var in ["LC_MEASUREMENT", "LC_ALL", "LANG"] { for var in ["LC_MEASUREMENT", "LC_ALL", "LANG"] {
if let Ok(val) = std::env::var(var) { if let Ok(val) = std::env::var(var)
if let Some(units) = classify_locale(&val) { && let Some(units) = classify_locale(&val)
return Some(units); {
} return Some(units);
} }
} }
None None
} }
/// Strip codeset (`.UTF-8`) and modifier (`@euro`) and check the /// Strip codeset (`.UTF-8`) and modifier (`@euro`) and check the
/// language_TERRITORY prefix against the USCS list. /// `language_TERRITORY` prefix against the USCS list.
fn classify_locale(value: &str) -> Option<Units> { fn classify_locale(value: &str) -> Option<Units> {
let trimmed = value.trim(); let trimmed = value.trim();
if trimmed.is_empty() || trimmed == "C" || trimmed == "POSIX" { if trimmed.is_empty() || trimmed == "C" || trimmed == "POSIX" {