parlov-elicit 0.5.0

Elicitation engine: strategy selection and probe plan generation for parlov.
Documentation
use super::*;
use bytes::Bytes;
use http::{HeaderMap, HeaderValue};

use crate::test_utils::minimal_ctx;

// --- Stub producer that admits Redirect and emits a Location ---
struct LocationProducer;
impl Producer for LocationProducer {
    fn admits(&self, class: ResponseClass) -> bool {
        matches!(class, ResponseClass::Redirect)
    }

    fn extract(&self, _class: ResponseClass, headers: &HeaderMap) -> Option<ProducerOutput> {
        let loc = headers.get(http::header::LOCATION)?.to_str().ok()?;
        Some(ProducerOutput::Location(loc.to_owned()))
    }
}

// --- Stub consumer that needs Location ---
struct LocationConsumer;
impl Consumer for LocationConsumer {
    fn needs(&self) -> ProducerOutputKind {
        ProducerOutputKind::Location
    }

    fn generate(&self, _ctx: &ScanContext, _output: &ProducerOutput) -> Vec<ProbeSpec> {
        // ProbeSpec construction is tested in B1; this stub just proves callability.
        vec![]
    }
}

// --- Stub producer that always returns None ---
struct NullProducer;
impl Producer for NullProducer {
    fn admits(&self, _class: ResponseClass) -> bool {
        true
    }

    fn extract(&self, _class: ResponseClass, _headers: &HeaderMap) -> Option<ProducerOutput> {
        None
    }
}

fn redirect_exchange() -> (ResponseClass, HeaderMap, Bytes) {
    let mut headers = HeaderMap::new();
    headers.insert(
        http::header::LOCATION,
        HeaderValue::from_static("https://example.com/new"),
    );
    (ResponseClass::Redirect, headers, Bytes::new())
}

#[test]
fn registry_extend_merges_edges() {
    let mut reg_a = ChainRegistry::new();
    reg_a.register(Arc::new(LocationProducer), Arc::new(LocationConsumer));

    let mut reg_b = ChainRegistry::new();
    reg_b.register(Arc::new(LocationProducer), Arc::new(LocationConsumer));

    reg_a.extend(reg_b);
    assert_eq!(reg_a.len(), 2, "extend must merge both registries");
}

#[test]
fn producer_is_object_safe() {
    let _: Arc<dyn Producer> = Arc::new(LocationProducer);
}

#[test]
fn consumer_is_object_safe() {
    let _: Arc<dyn Consumer> = Arc::new(LocationConsumer);
}

#[test]
fn registry_starts_empty() {
    assert!(ChainRegistry::new().is_empty());
    assert_eq!(ChainRegistry::new().len(), 0);
}

#[test]
fn registry_register_increments_len() {
    let mut reg = ChainRegistry::new();
    reg.register(Arc::new(LocationProducer), Arc::new(LocationConsumer));
    assert_eq!(reg.len(), 1);
    assert!(!reg.is_empty());
}

#[test]
fn dag_plan_empty_when_no_exchanges() {
    let mut reg = ChainRegistry::new();
    reg.register(Arc::new(LocationProducer), Arc::new(LocationConsumer));
    let specs = generate_dag_chained_plan(&minimal_ctx(), &[], &reg);
    assert!(specs.is_empty());
}

#[test]
fn dag_plan_empty_when_registry_is_empty() {
    let reg = ChainRegistry::new();
    let specs = generate_dag_chained_plan(&minimal_ctx(), &[redirect_exchange()], &reg);
    assert!(specs.is_empty());
}

#[test]
fn dag_plan_empty_when_producer_does_not_admit_exchange_class() {
    let mut reg = ChainRegistry::new();
    reg.register(Arc::new(LocationProducer), Arc::new(LocationConsumer));
    // Success class, not Redirect — LocationProducer does not admit it
    let exchanges = vec![(ResponseClass::Success, HeaderMap::new(), Bytes::new())];
    let specs = generate_dag_chained_plan(&minimal_ctx(), &exchanges, &reg);
    assert!(specs.is_empty());
}

#[test]
fn dag_plan_empty_when_producer_admits_but_extract_returns_none() {
    let mut reg = ChainRegistry::new();
    reg.register(Arc::new(NullProducer), Arc::new(LocationConsumer));
    let specs = generate_dag_chained_plan(&minimal_ctx(), &[redirect_exchange()], &reg);
    assert!(specs.is_empty());
}

// --- chain provenance attachment ---

use crate::types::{ProbePair, ProbeSpec, RiskLevel, StrategyMetadata};
use parlov_core::{
    always_applicable, NormativeStrength, OracleClass, ProbeDefinition, SignalSurface, Technique,
    Vector,
};

fn make_pair_spec() -> ProbeSpec {
    let def = ProbeDefinition {
        url: "https://example.com/x".to_owned(),
        method: http::Method::GET,
        headers: HeaderMap::new(),
        body: None,
    };
    ProbeSpec::Pair(ProbePair {
        baseline: def.clone(),
        probe: def,
        canonical_baseline: None,
        metadata: StrategyMetadata {
            strategy_id: "stub",
            strategy_name: "stub",
            risk: RiskLevel::Safe,
        },
        technique: Technique {
            id: "stub",
            name: "stub",
            oracle_class: OracleClass::Existence,
            vector: Vector::StatusCodeDiff,
            strength: NormativeStrength::Should,
            normalization_weight: None,
            inverted_signal_weight: None,
            method_relevant: false,
            parser_relevant: false,
            applicability: always_applicable,
            contradiction_surface: SignalSurface::Status,
        },
        chain_provenance: None,
    })
}

struct PairConsumer;
impl Consumer for PairConsumer {
    fn needs(&self) -> ProducerOutputKind {
        ProducerOutputKind::Location
    }
    fn generate(&self, _ctx: &ScanContext, _output: &ProducerOutput) -> Vec<ProbeSpec> {
        vec![make_pair_spec()]
    }
}

#[test]
fn dag_attaches_chain_provenance_to_generated_specs() {
    let mut reg = ChainRegistry::new();
    reg.register(Arc::new(LocationProducer), Arc::new(PairConsumer));
    let specs = generate_dag_chained_plan(&minimal_ctx(), &[redirect_exchange()], &reg);
    assert_eq!(specs.len(), 1, "expected one spec from PairConsumer");
    let prov = specs[0]
        .chain_provenance()
        .expect("chain_provenance must be attached");
    assert_eq!(prov.producer_kind, "Location");
    assert_eq!(prov.producer_value, "https://example.com/new");
}

#[test]
fn phase1_specs_have_no_chain_provenance() {
    // A spec built directly (i.e. by Phase 1 strategies) carries no provenance.
    let spec = make_pair_spec();
    assert!(spec.chain_provenance().is_none());
}

// --- kind_string / value_string coverage ---

#[test]
fn kind_string_for_etag_returns_etag() {
    let out = ProducerOutput::Etag("W/\"abc\"".to_owned(), EtagStrength::Weak);
    assert_eq!(out.kind_string(), "Etag");
}

#[test]
fn kind_string_for_problem_details_returns_problem_details() {
    let out = ProducerOutput::ProblemDetails {
        required_fields: vec!["email".to_owned()],
        error_type: Some("about:blank".to_owned()),
    };
    assert_eq!(out.kind_string(), "ProblemDetails");
}

#[test]
fn kind_string_for_auth_challenge_returns_auth_challenge() {
    let out = ProducerOutput::AuthChallenge {
        scheme: "Bearer".to_owned(),
        realm: None,
        scope: None,
    };
    assert_eq!(out.kind_string(), "AuthChallenge");
}

#[test]
fn value_string_for_etag_returns_quoted_value() {
    let out = ProducerOutput::Etag("W/\"abc\"".to_owned(), EtagStrength::Weak);
    assert_eq!(out.value_string(), "W/\"abc\"");
}

#[test]
fn value_string_for_last_modified_returns_date_string() {
    let out = ProducerOutput::LastModified("Tue, 15 Nov 1994 12:45:26 GMT".to_owned());
    assert_eq!(out.value_string(), "Tue, 15 Nov 1994 12:45:26 GMT");
}

#[test]
fn value_string_for_location_returns_url_string() {
    let out = ProducerOutput::Location("https://example.com/new".to_owned());
    assert_eq!(out.value_string(), "https://example.com/new");
}

#[test]
fn value_string_for_content_range_size_returns_decimal_string() {
    let out = ProducerOutput::ContentRangeSize(12345);
    assert_eq!(out.value_string(), "12345");
}

#[test]
fn value_string_for_accept_ranges_returns_string() {
    let out = ProducerOutput::AcceptRanges("bytes".to_owned());
    assert_eq!(out.value_string(), "bytes");
}

#[test]
fn value_string_for_resource_id_returns_string() {
    let out = ProducerOutput::ResourceId("42".to_owned());
    assert_eq!(out.value_string(), "42");
}

#[test]
fn value_string_for_content_type_returns_mime() {
    let out = ProducerOutput::ContentType("application/json".to_owned());
    assert_eq!(out.value_string(), "application/json");
}

#[test]
fn value_string_for_problem_details_returns_summary() {
    let out = ProducerOutput::ProblemDetails {
        required_fields: vec!["email".to_owned(), "name".to_owned()],
        error_type: Some("https://example.com/probs/req".to_owned()),
    };
    let v = out.value_string();
    assert!(v.contains("required_fields="));
    assert!(v.contains("email"));
    assert!(v.contains("type=https://example.com/probs/req"));
}

#[test]
fn value_string_for_problem_details_renders_dash_when_type_none() {
    let out = ProducerOutput::ProblemDetails {
        required_fields: vec![],
        error_type: None,
    };
    assert!(out.value_string().contains("type=-"));
}

#[test]
fn value_string_for_auth_challenge_includes_scheme_realm_scope() {
    let out = ProducerOutput::AuthChallenge {
        scheme: "Bearer".to_owned(),
        realm: Some("api".to_owned()),
        scope: Some("read:items".to_owned()),
    };
    let v = out.value_string();
    assert!(v.contains("scheme=Bearer"), "{v}");
    assert!(v.contains("realm=api"), "{v}");
    assert!(v.contains("scope=read:items"), "{v}");
}

#[test]
fn value_string_for_auth_challenge_renders_dashes_when_optional_absent() {
    let out = ProducerOutput::AuthChallenge {
        scheme: "Basic".to_owned(),
        realm: None,
        scope: None,
    };
    let v = out.value_string();
    assert!(v.contains("realm=-"));
    assert!(v.contains("scope=-"));
}

#[test]
fn producer_output_kind_matches_variant() {
    assert_eq!(
        ProducerOutput::Location("x".to_owned()).kind(),
        ProducerOutputKind::Location
    );
    assert_eq!(
        ProducerOutput::Etag("x".to_owned(), EtagStrength::Strong).kind(),
        ProducerOutputKind::Etag
    );
    assert_eq!(
        ProducerOutput::LastModified("x".to_owned()).kind(),
        ProducerOutputKind::LastModified
    );
    assert_eq!(
        ProducerOutput::ContentRangeSize(42).kind(),
        ProducerOutputKind::ContentRangeSize
    );
    assert_eq!(
        ProducerOutput::AcceptRanges("bytes".to_owned()).kind(),
        ProducerOutputKind::AcceptRanges
    );
    assert_eq!(
        ProducerOutput::ResourceId("42".to_owned()).kind(),
        ProducerOutputKind::ResourceId
    );
    assert_eq!(
        ProducerOutput::ProblemDetails {
            required_fields: vec![],
            error_type: None
        }
        .kind(),
        ProducerOutputKind::ProblemDetails
    );
    assert_eq!(
        ProducerOutput::ContentType("application/json".to_owned()).kind(),
        ProducerOutputKind::ContentType
    );
    assert_eq!(
        ProducerOutput::AuthChallenge {
            scheme: "Bearer".to_owned(),
            realm: None,
            scope: None,
        }
        .kind(),
        ProducerOutputKind::AuthChallenge
    );
}