gcal-fetcher 0.1.0

Fetch events from the Google Calendar JSON API looking a given number of days ahead.
Documentation
/// Enum for different potential errors.
#[non_exhaustive]
pub enum FetchError {
    Http(ureq::Error),
    Io(std::io::Error),
    Parse(serde_json::Error),
}

impl std::fmt::Display for FetchError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            FetchError::Http(e)  => write!(f, "HTTP error: {}", redact_key(&e.to_string())),
            FetchError::Io(e)    => write!(f, "IO error: {}", e),
            FetchError::Parse(e) => write!(f, "Parse error: {}", e),
        }
    }
}

impl std::fmt::Debug for FetchError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            // Redact the API key that ureq embeds in the URL inside its Debug output.
            FetchError::Http(e) => f
                .debug_tuple("Http")
                .field(&redact_key(&format!("{e:?}")))
                .finish(),
            FetchError::Io(e)    => f.debug_tuple("Io").field(e).finish(),
            FetchError::Parse(e) => f.debug_tuple("Parse").field(e).finish(),
        }
    }
}

impl std::error::Error for FetchError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            // Do not expose the raw ureq error – it contains the URL with the API key.
            FetchError::Http(_)  => None,
            FetchError::Io(e)    => Some(e),
            FetchError::Parse(e) => Some(e),
        }
    }
}

/// Replace the value of the `key=` query parameter with `REDACTED` so that
/// API keys are not accidentally leaked in log output or error messages.
fn redact_key(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut rest = s;
    while let Some(pos) = rest.find("key=") {
        out.push_str(&rest[..pos + 4]); // include "key="
        rest = &rest[pos + 4..];
        // skip until the next '&', whitespace, or end-of-string
        let end = rest
            .find(|c: char| c == '&' || c.is_ascii_whitespace())
            .unwrap_or(rest.len());
        out.push_str("REDACTED");
        rest = &rest[end..];
    }
    out.push_str(rest);
    out
}

impl From<ureq::Error> for FetchError {
    fn from(e: ureq::Error) -> Self { FetchError::Http(e) }
}

impl From<std::io::Error> for FetchError {
    fn from(e: std::io::Error) -> Self { FetchError::Io(e) }
}

impl From<serde_json::Error> for FetchError {
    fn from(e: serde_json::Error) -> Self { FetchError::Parse(e) }
}

#[cfg(test)]
mod tests {
    use super::redact_key;
    mod redact_key {
        use super::redact_key;
        
        #[test]
        fn redacts_key_in_url() {
            let raw = "https://googleapis.com/calendar/v3/calendars/foo/events?key=SUPER_SECRET&timeMin=now";
            assert_eq!(
                redact_key(raw),
                "https://googleapis.com/calendar/v3/calendars/foo/events?key=REDACTED&timeMin=now"
            );
        }

        #[test]
        fn redacts_key_at_end_of_url() {
            let raw = "error calling https://example.com?key=SUPER_SECRET";
            assert_eq!(redact_key(raw), "error calling https://example.com?key=REDACTED");
        }

        #[test]
        fn leaves_strings_without_key_alone() {
            let raw = "some error without any api key parameter";
            assert_eq!(redact_key(raw), raw);
        }

        #[test]
        fn debug_redacts_key_in_http_error() {
            // Construct a FetchError::Http from a hand-built ureq::Error and confirm
            // that formatting it with {:?} does not expose the raw API key.
            // We test via the Display path which exercises redact_key directly,
            // since we cannot easily construct a ureq::Error containing a live URL.
            // Instead verify that redact_key is applied by round-tripping a string
            // that matches what ureq would embed.
            let raw = "Transport { url: \"https://googleapis.com?key=SUPER_SECRET\", .. }";
            let redacted = redact_key(raw);
            assert!(!redacted.contains("SUPER_SECRET"));
            assert!(redacted.contains("REDACTED"));
        }
    }
}