parlov-core 0.7.0

Shared types, error types, and oracle class definitions for parlov.
Documentation
//! Response classification for Phase 2 harvest admission gating.
//!
//! Maps an HTTP `(StatusCode, &HeaderMap)` pair to one of the eight discrete signal families
//! the harvest model is built around. Use `ResponseClass::classify` — never match on raw status
//! codes at call sites.

use http::{header, HeaderMap, StatusCode};

/// Discrete signal families used to gate harvest admission in Phase 2.
///
/// Always construct via [`ResponseClass::classify`] — never match on raw status codes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResponseClass {
    /// 206 Partial Content — range request satisfied.
    PartialContent,
    /// 304 Not Modified — conditional request matched; no body returned.
    NotModified,
    /// 416 Range Not Satisfiable — range request out of bounds.
    RangeNotSatisfiable,
    /// 401 Unauthorized or 407 Proxy Authentication Required.
    AuthChallenge,
    /// 429 Too Many Requests or 503 Service Unavailable.
    RateLimited,
    /// 4xx with `Content-Type: application/problem+json` or `application/json`.
    StructuredError,
    /// 3xx with a `Location` header present.
    Redirect,
    /// 2xx excluding 206.
    Success,
    /// Anything not covered by the above variants.
    Other,
}

impl ResponseClass {
    /// Classifies an HTTP response into one of the eight signal families.
    ///
    /// Top-to-bottom; first match wins. Precedence: 206 before `Success`, 304 before `Redirect`,
    /// 401/429 before `StructuredError`.
    #[must_use]
    pub fn classify(status: StatusCode, headers: &HeaderMap) -> Self {
        if status == StatusCode::PARTIAL_CONTENT {
            return Self::PartialContent;
        }
        if status == StatusCode::NOT_MODIFIED {
            return Self::NotModified;
        }
        if status == StatusCode::RANGE_NOT_SATISFIABLE {
            return Self::RangeNotSatisfiable;
        }
        if status == StatusCode::UNAUTHORIZED || status == StatusCode::PROXY_AUTHENTICATION_REQUIRED
        {
            return Self::AuthChallenge;
        }
        if status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE {
            return Self::RateLimited;
        }
        if status.is_client_error() && has_json_content_type(headers) {
            return Self::StructuredError;
        }
        if status.is_redirection() && headers.contains_key(header::LOCATION) {
            return Self::Redirect;
        }
        if status.is_success() {
            return Self::Success;
        }
        Self::Other
    }
}

/// Returns `true` if `Content-Type` contains `application/problem+json` or `application/json`.
///
/// A missing or non-UTF-8 value returns `false`.
fn has_json_content_type(headers: &HeaderMap) -> bool {
    headers
        .get(header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .is_some_and(|ct| {
            ct.contains("application/problem+json") || ct.contains("application/json")
        })
}

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

    fn empty_headers() -> HeaderMap {
        HeaderMap::new()
    }

    fn headers_with_content_type(ct: &str) -> HeaderMap {
        let mut map = HeaderMap::new();
        map.insert(
            header::CONTENT_TYPE,
            HeaderValue::from_str(ct).expect("valid header value"),
        );
        map
    }

    fn headers_with_location(url: &str) -> HeaderMap {
        let mut map = HeaderMap::new();
        map.insert(
            header::LOCATION,
            HeaderValue::from_str(url).expect("valid header value"),
        );
        map
    }

    // --- Basic variant coverage ---

    #[test]
    fn partial_content_206() {
        assert_eq!(
            ResponseClass::classify(StatusCode::PARTIAL_CONTENT, &empty_headers()),
            ResponseClass::PartialContent
        );
    }

    #[test]
    fn not_modified_304() {
        assert_eq!(
            ResponseClass::classify(StatusCode::NOT_MODIFIED, &empty_headers()),
            ResponseClass::NotModified
        );
    }

    #[test]
    fn range_not_satisfiable_416() {
        assert_eq!(
            ResponseClass::classify(StatusCode::RANGE_NOT_SATISFIABLE, &empty_headers()),
            ResponseClass::RangeNotSatisfiable
        );
    }

    #[test]
    fn auth_challenge_401() {
        assert_eq!(
            ResponseClass::classify(StatusCode::UNAUTHORIZED, &empty_headers()),
            ResponseClass::AuthChallenge
        );
    }

    #[test]
    fn auth_challenge_407() {
        assert_eq!(
            ResponseClass::classify(StatusCode::PROXY_AUTHENTICATION_REQUIRED, &empty_headers()),
            ResponseClass::AuthChallenge
        );
    }

    #[test]
    fn rate_limited_429() {
        assert_eq!(
            ResponseClass::classify(StatusCode::TOO_MANY_REQUESTS, &empty_headers()),
            ResponseClass::RateLimited
        );
    }

    #[test]
    fn rate_limited_503() {
        assert_eq!(
            ResponseClass::classify(StatusCode::SERVICE_UNAVAILABLE, &empty_headers()),
            ResponseClass::RateLimited
        );
    }

    #[test]
    fn structured_error_problem_json() {
        assert_eq!(
            ResponseClass::classify(
                StatusCode::BAD_REQUEST,
                &headers_with_content_type("application/problem+json")
            ),
            ResponseClass::StructuredError
        );
    }

    #[test]
    fn structured_error_application_json() {
        assert_eq!(
            ResponseClass::classify(
                StatusCode::UNPROCESSABLE_ENTITY,
                &headers_with_content_type("application/json")
            ),
            ResponseClass::StructuredError
        );
    }

    #[test]
    fn redirect_301_with_location() {
        assert_eq!(
            ResponseClass::classify(
                StatusCode::MOVED_PERMANENTLY,
                &headers_with_location("https://example.com/new")
            ),
            ResponseClass::Redirect
        );
    }

    #[test]
    fn success_200() {
        assert_eq!(
            ResponseClass::classify(StatusCode::OK, &empty_headers()),
            ResponseClass::Success
        );
    }

    #[test]
    fn success_201() {
        assert_eq!(
            ResponseClass::classify(StatusCode::CREATED, &empty_headers()),
            ResponseClass::Success
        );
    }

    #[test]
    fn other_500() {
        assert_eq!(
            ResponseClass::classify(StatusCode::INTERNAL_SERVER_ERROR, &empty_headers()),
            ResponseClass::Other
        );
    }

    #[test]
    fn other_100() {
        assert_eq!(
            ResponseClass::classify(StatusCode::CONTINUE, &empty_headers()),
            ResponseClass::Other
        );
    }

    // --- Precedence cases ---

    #[test]
    fn precedence_206_not_success() {
        // 206 is a 2xx — must match PartialContent before Success
        assert_eq!(
            ResponseClass::classify(StatusCode::PARTIAL_CONTENT, &empty_headers()),
            ResponseClass::PartialContent
        );
    }

    #[test]
    fn precedence_304_not_redirect_even_with_location() {
        // 304 is a 3xx — must match NotModified before Redirect even when Location is set
        assert_eq!(
            ResponseClass::classify(
                StatusCode::NOT_MODIFIED,
                &headers_with_location("https://example.com/new")
            ),
            ResponseClass::NotModified
        );
    }

    #[test]
    fn precedence_401_not_structured_error_with_json_content_type() {
        // 401 is a 4xx — must match AuthChallenge before StructuredError
        assert_eq!(
            ResponseClass::classify(
                StatusCode::UNAUTHORIZED,
                &headers_with_content_type("application/json")
            ),
            ResponseClass::AuthChallenge
        );
    }

    #[test]
    fn precedence_429_not_structured_error_with_problem_json() {
        // 429 is a 4xx — must match RateLimited before StructuredError
        assert_eq!(
            ResponseClass::classify(
                StatusCode::TOO_MANY_REQUESTS,
                &headers_with_content_type("application/problem+json")
            ),
            ResponseClass::RateLimited
        );
    }

    #[test]
    fn other_4xx_without_json_content_type() {
        // 400 with no Content-Type is not StructuredError
        assert_eq!(
            ResponseClass::classify(StatusCode::BAD_REQUEST, &empty_headers()),
            ResponseClass::Other
        );
    }

    #[test]
    fn other_3xx_without_location() {
        // 301 with no Location header is not Redirect
        assert_eq!(
            ResponseClass::classify(StatusCode::MOVED_PERMANENTLY, &empty_headers()),
            ResponseClass::Other
        );
    }
}