parlov-core 0.7.0

Shared types, error types, and oracle class definitions for parlov.
Documentation
use bytes::Bytes;
use http::{HeaderMap, StatusCode};
use serde::{Deserialize, Serialize};

use crate::{ProbeDefinition, ResponseClass, ResponseSurface, Technique};

/// Request and response paired so analyzers always have the full context of what was sent.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeExchange {
    /// The request that was executed.
    pub request: ProbeDefinition,
    /// The response surface that came back.
    pub response: ResponseSurface,
}

/// Baseline and probe exchange pairs with technique context for differential analysis.
#[derive(Debug, Clone)]
pub struct DifferentialSet {
    /// Known-valid / control exchanges — establishes the expected response surface.
    pub baseline: Vec<ProbeExchange>,
    /// Suspect exchanges — compared against baseline to detect oracle signals.
    pub probe: Vec<ProbeExchange>,
    /// Optional canonical (unmutated) exchange for control-integrity verification.
    ///
    /// Populated by route-mutating strategies (`case_normalize`, `trailing_slash`) via the
    /// runner's third dispatch. Consumed by `control_integrity` to detect when a path mutation
    /// destroyed routing — canonical 2xx + mutated baseline non-2xx means the mutation broke the
    /// route, and any resulting Contradictory is invalid.
    pub canonical: Option<ProbeExchange>,
    /// Technique that generated these probes — provides attribution and calibration context.
    pub technique: Technique,
}

impl DifferentialSet {
    /// Constructs a `DifferentialSet` with `canonical = None` (the common case).
    ///
    /// Only `case_normalize` and `trailing_slash` set `canonical` after construction.
    #[must_use]
    pub fn new(
        baseline: Vec<ProbeExchange>,
        probe: Vec<ProbeExchange>,
        technique: Technique,
    ) -> Self {
        Self {
            baseline,
            probe,
            canonical: None,
            technique,
        }
    }

    /// Returns `(status, headers)` from the first baseline exchange suitable for harvesting.
    ///
    /// Priority: 2xx first, then 3xx+Location, then 4xx with JSON content type
    /// (`StructuredError` — needed by C5 problem-details producer).
    #[must_use]
    pub fn first_harvest_exchange(&self) -> Option<(StatusCode, HeaderMap)> {
        self.first_harvest_exchange_with_body()
            .map(|(s, h, _)| (s, h))
    }

    /// Returns `(status, headers, body)` from the first baseline exchange suitable for harvesting.
    ///
    /// Priority order:
    /// 1. First 2xx — carries `ETag`, `Last-Modified`, `Content-Type`, and body for chain producers.
    /// 2. First 3xx with a `Location` header — for redirect-diff chaining.
    /// 3. First baseline exchange classified as `AuthChallenge`, `RateLimited`, or
    ///    `StructuredError` — admits 401/407 (C8 WWW-Authenticate) and 4xx+JSON (C5 body).
    #[must_use]
    pub fn first_harvest_exchange_with_body(&self) -> Option<(StatusCode, HeaderMap, Bytes)> {
        if let Some(ex) = self
            .baseline
            .iter()
            .find(|ex| ex.response.status.is_success())
        {
            return Some((
                ex.response.status,
                ex.response.headers.clone(),
                ex.response.body.clone(),
            ));
        }
        if let Some(ex) = self.baseline.iter().find(|ex| {
            ex.response.status.is_redirection()
                && ex.response.headers.contains_key(http::header::LOCATION)
        }) {
            return Some((
                ex.response.status,
                ex.response.headers.clone(),
                ex.response.body.clone(),
            ));
        }
        self.baseline
            .iter()
            .find(|ex| {
                matches!(
                    ResponseClass::classify(ex.response.status, &ex.response.headers),
                    ResponseClass::AuthChallenge
                        | ResponseClass::RateLimited
                        | ResponseClass::StructuredError
                )
            })
            .map(|ex| {
                (
                    ex.response.status,
                    ex.response.headers.clone(),
                    ex.response.body.clone(),
                )
            })
    }
}

#[cfg(test)]
mod tests {
    use bytes::Bytes;
    use http::{header, HeaderMap, HeaderValue, StatusCode};

    use super::*;
    use crate::{
        always_applicable, NormativeStrength, OracleClass, ProbeDefinition, ResponseSurface,
        SignalSurface, Vector,
    };

    fn technique() -> Technique {
        Technique {
            id: "test",
            name: "Test",
            oracle_class: OracleClass::Existence,
            vector: Vector::StatusCodeDiff,
            strength: NormativeStrength::Must,
            normalization_weight: None,
            inverted_signal_weight: None,
            method_relevant: false,
            parser_relevant: false,
            applicability: always_applicable,
            contradiction_surface: SignalSurface::Status,
        }
    }

    fn make_exchange(status: StatusCode, headers: HeaderMap) -> ProbeExchange {
        ProbeExchange {
            request: ProbeDefinition {
                url: "https://example.com/r/1".into(),
                method: http::Method::GET,
                headers: HeaderMap::new(),
                body: None,
            },
            response: ResponseSurface {
                status,
                headers,
                body: Bytes::new(),
                timing_ns: 0,
            },
        }
    }

    fn location_headers() -> HeaderMap {
        let mut h = HeaderMap::new();
        h.insert(
            header::LOCATION,
            HeaderValue::from_static("https://example.com/r/2"),
        );
        h
    }

    #[test]
    fn empty_baseline_returns_none() {
        let ds = DifferentialSet {
            baseline: vec![],
            probe: vec![],
            canonical: None,
            technique: technique(),
        };
        assert!(ds.first_harvest_exchange().is_none());
    }

    #[test]
    fn single_200_returns_its_status_and_headers() {
        let mut headers = HeaderMap::new();
        headers.insert(
            header::CONTENT_TYPE,
            HeaderValue::from_static("application/json"),
        );
        let ds = DifferentialSet {
            baseline: vec![make_exchange(StatusCode::OK, headers.clone())],
            probe: vec![],
            canonical: None,
            technique: technique(),
        };
        let (status, h) = ds.first_harvest_exchange().expect("should return Some");
        assert_eq!(status, StatusCode::OK);
        assert_eq!(
            h.get(header::CONTENT_TYPE),
            headers.get(header::CONTENT_TYPE)
        );
    }

    #[test]
    fn single_301_with_location_returns_it_when_no_2xx() {
        let ds = DifferentialSet {
            baseline: vec![make_exchange(
                StatusCode::MOVED_PERMANENTLY,
                location_headers(),
            )],
            probe: vec![],
            canonical: None,
            technique: technique(),
        };
        let (status, h) = ds.first_harvest_exchange().expect("should return Some");
        assert_eq!(status, StatusCode::MOVED_PERMANENTLY);
        assert!(h.contains_key(header::LOCATION));
    }

    #[test]
    fn single_301_without_location_returns_none() {
        let ds = DifferentialSet {
            baseline: vec![make_exchange(
                StatusCode::MOVED_PERMANENTLY,
                HeaderMap::new(),
            )],
            probe: vec![],
            canonical: None,
            technique: technique(),
        };
        assert!(ds.first_harvest_exchange().is_none());
    }

    #[test]
    fn mixed_301_with_location_then_200_returns_200() {
        let ds = DifferentialSet {
            baseline: vec![
                make_exchange(StatusCode::MOVED_PERMANENTLY, location_headers()),
                make_exchange(StatusCode::OK, HeaderMap::new()),
            ],
            probe: vec![],
            canonical: None,
            technique: technique(),
        };
        let (status, _) = ds.first_harvest_exchange().expect("should return Some");
        assert_eq!(status, StatusCode::OK);
    }

    #[test]
    fn mixed_200_then_301_with_location_returns_200() {
        let ds = DifferentialSet {
            baseline: vec![
                make_exchange(StatusCode::OK, HeaderMap::new()),
                make_exchange(StatusCode::MOVED_PERMANENTLY, location_headers()),
            ],
            probe: vec![],
            canonical: None,
            technique: technique(),
        };
        let (status, _) = ds.first_harvest_exchange().expect("should return Some");
        assert_eq!(status, StatusCode::OK);
    }

    #[test]
    fn multiple_2xx_returns_first() {
        let mut h1 = HeaderMap::new();
        h1.insert(header::ETAG, HeaderValue::from_static("\"abc\""));
        let ds = DifferentialSet {
            baseline: vec![
                make_exchange(StatusCode::OK, h1.clone()),
                make_exchange(StatusCode::CREATED, HeaderMap::new()),
            ],
            probe: vec![],
            canonical: None,
            technique: technique(),
        };
        let (status, h) = ds.first_harvest_exchange().expect("should return Some");
        assert_eq!(status, StatusCode::OK);
        assert_eq!(h.get(header::ETAG), h1.get(header::ETAG));
    }
}