ferrin-spec 0.2.0

Ferrin provider specification: model traits, prompt and content types, stream parts, shared errors.
Documentation
use pretty_assertions::assert_eq;
use serde_json::json;

use ferrin_spec::Headers;

#[test]
fn merge_overrides_existing_values() {
    let mut base = Headers::new()
        .with("x-a", "1")
        .with("x-b", "base")
        .with("authorization", "Bearer secret");
    let overlay = Headers::new().with("x-b", "overlay").with("x-c", "3");

    base.merge(&overlay);

    assert_eq!(base.get_str("x-a"), Some("1"));
    assert_eq!(base.get_str("x-b"), Some("overlay"));
    assert_eq!(base.get_str("x-c"), Some("3"));
    assert_eq!(base.len(), 4);
}

#[test]
fn merge_replaces_repeated_values_as_a_group() {
    let mut base = Headers::new();
    base.as_map_mut()
        .append("x-multi", http::HeaderValue::from_static("old"));
    let mut overlay = Headers::new();
    overlay
        .as_map_mut()
        .append("x-multi", http::HeaderValue::from_static("a"));
    overlay
        .as_map_mut()
        .append("x-multi", http::HeaderValue::from_static("b"));

    base.merge(&overlay);

    let values: Vec<&str> = base
        .as_map()
        .get_all("x-multi")
        .iter()
        .map(|v| v.to_str().unwrap())
        .collect();
    assert_eq!(values, vec!["a", "b"]);
}

#[test]
fn merge_pairs_skips_none_and_invalid() {
    let mut headers = Headers::new();
    headers.merge_pairs([
        ("x-a", Some("1")),
        ("x-b", None),
        ("bad header", Some("x")),
        ("x-c", Some("bad\nvalue")),
    ]);
    assert_eq!(headers.len(), 1);
    assert_eq!(headers.get_str("x-a"), Some("1"));
}

#[test]
fn user_agent_suffix_appends_or_creates() {
    let headers = Headers::new().with_user_agent_suffix(["ferrin/0.1.0", "", "openai/0.1.0"]);
    assert_eq!(
        headers.get_str("user-agent"),
        Some("ferrin/0.1.0 openai/0.1.0")
    );

    let headers = Headers::new()
        .with("user-agent", "my-app/2.0")
        .with_user_agent_suffix(["ferrin/0.1.0"]);
    assert_eq!(
        headers.get_str("user-agent"),
        Some("my-app/2.0 ferrin/0.1.0")
    );
}

#[test]
fn debug_and_serialize_mask_sensitive_values() {
    let headers = Headers::new()
        .with("authorization", "Bearer secret")
        .with("x-api-key", "sk-123")
        .with("content-type", "application/json");

    let debug = format!("{headers:?}");
    assert!(!debug.contains("secret"));
    assert!(!debug.contains("sk-123"));
    assert!(debug.contains("application/json"));

    let value = serde_json::to_value(&headers).unwrap();
    assert_eq!(
        value,
        json!({
            "authorization": "***",
            "x-api-key": "***",
            "content-type": "application/json",
        })
    );

    let masked = headers.masked();
    assert_eq!(masked.get_str("authorization"), Some("***"));
    assert_eq!(masked.get_str("content-type"), Some("application/json"));
}

#[test]
fn deserializes_from_object() {
    let headers: Headers =
        serde_json::from_value(json!({ "x-a": "1", "Content-Type": "text/plain" })).unwrap();
    assert_eq!(headers.get_str("x-a"), Some("1"));
    assert_eq!(headers.get_str("content-type"), Some("text/plain"));

    let err = serde_json::from_value::<Headers>(json!({ "bad header": "1" })).unwrap_err();
    assert!(err.to_string().contains("invalid header"));
}

#[test]
fn insert_rejects_invalid_names_and_values() {
    let mut headers = Headers::new();
    assert!(headers.insert("ok", "value").is_ok());
    assert!(headers.insert("not ok", "value").is_err());
    assert!(headers.insert("ok", "line\nbreak").is_err());
    assert!(headers.insert("x-utf8", "héllo").is_ok());
    assert_eq!(headers.get_str("x-utf8"), None);
    assert_eq!(
        headers
            .iter_str()
            .find(|(name, _)| *name == "x-utf8")
            .map(|(_, v)| v),
        Some("héllo".to_owned())
    );
}