honcho-ai 0.1.2

Rust SDK for Honcho — AI agent memory and social cognition infrastructure
Documentation
#![allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::manual_range_contains,
    missing_docs
)]

use std::time::Duration;

use chrono::{TimeZone, Utc};
use honcho_ai::error::{HonchoError, from_response, parse_error_body, parse_retry_after};
use pretty_assertions::assert_eq;
use reqwest::header::{HeaderMap, HeaderValue};
use rstest::rstest;
use static_assertions::assert_impl_all;

#[rstest]
#[case(400, "bad_request")]
#[case(401, "authentication_error")]
#[case(403, "permission_denied")]
#[case(404, "not_found")]
#[case(409, "conflict")]
#[case(422, "unprocessable_entity")]
fn status_maps_to_variant(#[case] status: u16, #[case] expected_code: &str) {
    let status = reqwest::StatusCode::from_u16(status).unwrap();
    let headers = HeaderMap::new();
    let body = bytes::Bytes::from(r#"{"message":"test error"}"#);
    let now = Utc::now();

    let err = from_response(status, &headers, &body, now);

    assert_eq!(err.code(), expected_code);
    match status.as_u16() {
        400 => assert!(matches!(err, HonchoError::BadRequest { .. })),
        401 => assert!(matches!(err, HonchoError::Authentication { .. })),
        403 => assert!(matches!(err, HonchoError::PermissionDenied { .. })),
        404 => assert!(matches!(err, HonchoError::NotFound { .. })),
        409 => assert!(matches!(err, HonchoError::Conflict { .. })),
        422 => assert!(matches!(err, HonchoError::UnprocessableEntity { .. })),
        _ => panic!("unexpected status"),
    }
}

#[rstest]
#[case(500)]
#[case(502)]
#[case(503)]
#[case(504)]
fn server_5xx_maps_to_server_with_status(#[case] status: u16) {
    let status = reqwest::StatusCode::from_u16(status).unwrap();
    let headers = HeaderMap::new();
    let body = bytes::Bytes::from("internal server error");
    let now = Utc::now();

    let err = from_response(status, &headers, &body, now);

    assert!(matches!(
        err,
        HonchoError::Server {
            status: s,
            ..
        } if s == status.as_u16()
    ));
    assert_eq!(err.code(), "server_error");
}

#[rstest]
#[case(405)]
#[case(408)]
#[case(413)]
#[case(418)]
fn unmapped_4xx_maps_to_client_with_status(#[case] status: u16) {
    let status = reqwest::StatusCode::from_u16(status).unwrap();
    let headers = HeaderMap::new();
    let body = bytes::Bytes::from("client error");
    let now = Utc::now();

    let err = from_response(status, &headers, &body, now);

    assert!(matches!(
        err,
        HonchoError::Client {
            status: s,
            ..
        } if s == status.as_u16()
    ));
    assert_eq!(err.code(), "client_error");
}

#[test]
fn rate_limit_429_parses_retry_after_seconds() {
    let status = reqwest::StatusCode::TOO_MANY_REQUESTS;
    let mut headers = HeaderMap::new();
    headers.insert("retry-after", HeaderValue::from_static("7"));
    let body = bytes::Bytes::from(r#"{"message":"rate limited"}"#);
    let now = Utc::now();

    let err = from_response(status, &headers, &body, now);

    match err {
        HonchoError::RateLimit { retry_after, .. } => {
            assert_eq!(retry_after, Some(Duration::from_secs(7)));
        }
        _ => panic!("expected RateLimit, got {err:?}"),
    }
}

#[test]
fn rate_limit_429_parses_retry_after_http_date() {
    let status = reqwest::StatusCode::TOO_MANY_REQUESTS;
    let mut headers = HeaderMap::new();
    let now = Utc.with_ymd_and_hms(2026, 10, 21, 7, 27, 55).unwrap();
    headers.insert(
        "retry-after",
        HeaderValue::from_static("Wed, 21 Oct 2026 07:28:00 GMT"),
    );
    let body = bytes::Bytes::from(r#"{"message":"rate limited"}"#);

    let err = from_response(status, &headers, &body, now);

    match err {
        HonchoError::RateLimit {
            retry_after: Some(dur),
            ..
        } => {
            let secs = dur.as_secs_f64();
            assert!((4.9..=5.1).contains(&secs), "expected ~5s, got {secs}s");
        }
        _ => panic!("expected RateLimit with retry_after, got {err:?}"),
    }
}

#[test]
fn rate_limit_429_without_retry_after_is_none() {
    let status = reqwest::StatusCode::TOO_MANY_REQUESTS;
    let headers = HeaderMap::new();
    let body = bytes::Bytes::from(r#"{"message":"rate limited"}"#);
    let now = Utc::now();

    let err = from_response(status, &headers, &body, now);

    match err {
        HonchoError::RateLimit {
            retry_after: None, ..
        } => {}
        _ => panic!("expected RateLimit with None retry_after, got {err:?}"),
    }
}

#[test]
fn retry_after_with_garbage_returns_none() {
    let mut headers = HeaderMap::new();
    headers.insert("retry-after", HeaderValue::from_static("not-a-valid-value"));
    let now = Utc::now();

    let result = parse_retry_after(headers.get("retry-after").unwrap(), now);
    assert!(result.is_none());
}

#[test]
fn error_body_extracts_message_field_priority() {
    let (msg, _) = parse_error_body(r#"{"detail":"d","message":"m","error":"e"}"#.as_bytes());
    assert_eq!(msg, "d");

    let (msg, _) = parse_error_body(r#"{"message":"m","error":"e"}"#.as_bytes());
    assert_eq!(msg, "m");

    let (msg, _) = parse_error_body(r#"{"error":"e"}"#.as_bytes());
    assert_eq!(msg, "e");

    let (msg, _) = parse_error_body(r#""plain string""#.as_bytes());
    assert_eq!(msg, "plain string");
}

#[rstest]
#[case(400)]
#[case(401)]
#[case(404)]
#[case(500)]
fn display_includes_status_and_message(#[case] status: u16) {
    let status = reqwest::StatusCode::from_u16(status).unwrap();
    let headers = HeaderMap::new();
    let body = bytes::Bytes::from(r#"{"message":"something went wrong"}"#);
    let now = Utc::now();

    let err = from_response(status, &headers, &body, now);
    let display = format!("{err}");

    assert!(
        display.contains(&status.as_u16().to_string()),
        "display should contain status code"
    );
    assert!(
        display.contains("something went wrong"),
        "display should contain message"
    );
}

#[test]
fn error_code_is_stable_string() {
    let codes = [
        (
            "bad_request",
            HonchoError::BadRequest {
                message: String::new(),
                body: None,
            },
        ),
        (
            "authentication_error",
            HonchoError::Authentication {
                message: String::new(),
            },
        ),
        (
            "permission_denied",
            HonchoError::PermissionDenied {
                message: String::new(),
            },
        ),
        (
            "not_found",
            HonchoError::NotFound {
                message: String::new(),
            },
        ),
        (
            "conflict",
            HonchoError::Conflict {
                message: String::new(),
                body: None,
            },
        ),
        (
            "unprocessable_entity",
            HonchoError::UnprocessableEntity {
                message: String::new(),
                body: None,
            },
        ),
        (
            "rate_limit_exceeded",
            HonchoError::RateLimit {
                message: String::new(),
                retry_after: None,
            },
        ),
        (
            "server_error",
            HonchoError::Server {
                status: 500,
                message: String::new(),
            },
        ),
        (
            "client_error",
            HonchoError::Client {
                status: 405,
                message: String::new(),
            },
        ),
        (
            "timeout",
            HonchoError::Timeout {
                message: String::new(),
            },
        ),
        (
            "connection_error",
            HonchoError::Connection {
                message: String::new(),
            },
        ),
    ];

    for (expected_code, err) in codes {
        assert_eq!(err.code(), expected_code, "mismatch for {expected_code}");
    }
}

#[rstest]
#[case(
    HonchoError::RateLimit {
        message: String::new(),
        retry_after: None,
    },
    true
)]
#[case(
    HonchoError::Server {
        status: 500,
        message: String::new(),
    },
    true
)]
#[case(
    HonchoError::Server {
        status: 502,
        message: String::new(),
    },
    true
)]
#[case(
    HonchoError::Server {
        status: 503,
        message: String::new(),
    },
    true
)]
#[case(
    HonchoError::Server {
        status: 504,
        message: String::new(),
    },
    true
)]
#[case(
    HonchoError::Server {
        status: 501,
        message: String::new(),
    },
    false
)]
#[case(
    HonchoError::BadRequest {
        message: String::new(),
        body: None,
    },
    false
)]
#[case(
    HonchoError::Timeout {
        message: String::new(),
    },
    true
)]
#[case(
    HonchoError::Connection {
        message: String::new(),
    },
    true
)]
fn retryable_policy_matches_http_client(#[case] err: HonchoError, #[case] expected: bool) {
    assert_eq!(err.is_retryable(), expected);
}

use std::error::Error;

#[tokio::test]
async fn source_chain_for_transport_and_io_and_decode() {
    let transport_err: HonchoError = reqwest::Client::new()
        .get("http://0.0.0.0:1")
        .send()
        .await
        .unwrap_err()
        .into();
    assert!(transport_err.source().is_some());
    assert!(!transport_err.is_retryable());

    let json_err = serde_json::from_str::<Vec<i32>>("{}").unwrap_err();
    let decode_err = HonchoError::Decode {
        path: "root".to_string(),
        source: json_err,
    };
    assert!(decode_err.source().is_some());

    let bad_request = HonchoError::BadRequest {
        message: String::new(),
        body: None,
    };
    assert!(bad_request.source().is_none());
}

#[test]
fn error_bounds() {
    assert_impl_all!(HonchoError: Send, Sync, std::error::Error);
}