openmeteo-rs 1.0.0

Rust client for the Open-Meteo weather API.
Documentation
use std::borrow::Cow;

/// Replaces Open-Meteo's bare lowercase `nan` JSON tokens with `null`.
///
/// Open-Meteo's Swift server can emit lowercase `nan` for non-finite floats.
/// `serde_json` correctly rejects those tokens because they are not valid JSON,
/// so endpoint decoders that can receive them opt into this sanitizer before
/// deserializing.
///
/// This intentionally handles lowercase `nan`, which is the spelling observed
/// from Open-Meteo, not every non-standard JSON spelling of non-finite floats.
pub(crate) fn sanitize_open_meteo_floats(bytes: &[u8]) -> Cow<'_, [u8]> {
    let mut output = None::<Vec<u8>>;
    let mut index = 0;
    let mut in_string = false;
    let mut escaped = false;

    while index < bytes.len() {
        let byte = bytes[index];

        if in_string {
            push_if_replacing(&mut output, byte);
            if escaped {
                escaped = false;
            } else if byte == b'\\' {
                escaped = true;
            } else if byte == b'"' {
                in_string = false;
            }
            index += 1;
            continue;
        }

        if byte == b'"' {
            in_string = true;
            push_if_replacing(&mut output, byte);
            index += 1;
            continue;
        }

        if bytes[index..].starts_with(b"nan") && is_json_token_boundary(bytes.get(index + 3)) {
            output
                .get_or_insert_with(|| {
                    let mut output = Vec::with_capacity(bytes.len());
                    output.extend_from_slice(&bytes[..index]);
                    output
                })
                .extend_from_slice(b"null");
            index += 3;
            continue;
        }

        push_if_replacing(&mut output, byte);
        index += 1;
    }

    match output {
        Some(output) => Cow::Owned(output),
        None => Cow::Borrowed(bytes),
    }
}

fn push_if_replacing(output: &mut Option<Vec<u8>>, byte: u8) {
    if let Some(output) = output {
        output.push(byte);
    }
}

fn is_json_token_boundary(byte: Option<&u8>) -> bool {
    matches!(
        byte,
        None | Some(b' ' | b'\n' | b'\r' | b'\t' | b',' | b']' | b'}')
    )
}

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

    #[test]
    fn replaces_bare_nan_tokens_with_null() {
        let sanitized = sanitize_open_meteo_floats(br#"{"elevation":[0.0,nan,0.0]}"#);
        assert_eq!(sanitized.as_ref(), br#"{"elevation":[0.0,null,0.0]}"#);
    }

    #[test]
    fn does_not_replace_nan_inside_json_strings() {
        let sanitized = sanitize_open_meteo_floats(br#"{"note":"nan","elevation":[nan]}"#);
        assert_eq!(sanitized.as_ref(), br#"{"note":"nan","elevation":[null]}"#);
    }

    #[test]
    fn borrows_original_bytes_when_no_nan_token_is_present() {
        let input = br#"{"elevation":[38.0,409.0]}"#;
        assert!(matches!(
            sanitize_open_meteo_floats(input),
            Cow::Borrowed(_)
        ));
    }
}