use std::collections::HashMap;
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use futures_util::Stream;
use reqwest::Method;
use serde::Deserialize;
use serde_json::Value;
use crate::conclusion::ConclusionScope;
use crate::dialectic_stream::DialecticStream;
use crate::error::{HonchoError, Result};
use crate::http::client::HttpClient;
use crate::http::routes;
use crate::http::sse::parse_sse_stream;
use crate::types::dialectic::RepresentationResponse;
use crate::types::dialectic::{DialecticOptions, ReasoningLevel, validate_dialectic_query};
use crate::types::message::{MessageCreate, MessageResponse, MessageSearchOptions};
use crate::types::pagination::{self, Page};
use crate::types::peer::Peer as PeerResponse;
use crate::types::peer::{PeerCardResponse, PeerCardSet, PeerConfig, PeerContext};
use crate::types::session::{SessionListOptions, SessionResponse, validate_search_params};
pub(crate) struct PeerInner {
http: HttpClient,
workspace_id: String,
id: String,
cache: RwLock<PeerCacheState>,
}
#[derive(Default)]
struct PeerCacheState {
metadata: Option<HashMap<String, Value>>,
configuration: Option<PeerConfig>,
}
#[derive(Clone)]
pub struct Peer {
inner: Arc<PeerInner>,
}
#[derive(Deserialize)]
struct ChatResponse {
#[serde(default)]
content: Option<String>,
}
impl std::fmt::Debug for Peer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Peer")
.field("id", &self.inner.id)
.field("workspace_id", &self.inner.workspace_id)
.finish()
}
}
impl Peer {
pub(crate) fn from_parts(
http: HttpClient,
workspace_id: String,
resp: PeerResponse,
) -> Result<Self> {
let configuration = map_to_peer_config(&resp.configuration)?;
Ok(Self {
inner: Arc::new(PeerInner {
http,
workspace_id,
id: resp.id,
cache: RwLock::new(PeerCacheState {
metadata: Some(resp.metadata),
configuration: Some(configuration),
}),
}),
})
}
pub(crate) fn from_response(honcho: &crate::Honcho, resp: PeerResponse) -> Result<Self> {
Self::from_parts(
honcho.http().clone(),
honcho.workspace_id().to_owned(),
resp,
)
}
fn read_cache(&self) -> std::sync::RwLockReadGuard<'_, PeerCacheState> {
self.inner
.cache
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn store(&self, metadata: Option<HashMap<String, Value>>, configuration: Option<PeerConfig>) {
let mut cache = self
.inner
.cache
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(metadata) = metadata {
cache.metadata = Some(metadata);
}
if let Some(configuration) = configuration {
cache.configuration = Some(configuration);
}
}
#[must_use]
pub fn id(&self) -> &str {
&self.inner.id
}
#[must_use]
pub fn metadata(&self) -> Option<HashMap<String, Value>> {
self.read_cache().metadata.clone()
}
#[must_use]
pub fn configuration(&self) -> Option<PeerConfig> {
self.read_cache().configuration.clone()
}
pub async fn refresh(&self) -> Result<()> {
self.fetch_and_update_cache().await?;
Ok(())
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(peer_id = self.inner.id.as_str())))]
pub async fn get_metadata(&self) -> Result<HashMap<String, Value>> {
let resp = self.fetch_and_update_cache().await?;
Ok(resp.metadata)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self, metadata), fields(peer_id = self.inner.id.as_str())))]
pub async fn set_metadata(&self, metadata: HashMap<String, Value>) -> Result<()> {
self.put_metadata(metadata).await
}
async fn put_metadata(&self, metadata: HashMap<String, Value>) -> Result<()> {
let body = crate::types::peer::PeerMetadataSet { metadata };
let resp: PeerResponse = self
.inner
.http
.put(
&routes::peer(&self.inner.workspace_id, &self.inner.id)?,
Some(&body),
&[],
)
.await?;
self.store(Some(resp.metadata), None);
Ok(())
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(peer_id = self.inner.id.as_str())))]
pub async fn get_configuration(&self) -> Result<PeerConfig> {
let resp = self.fetch_and_update_cache().await?;
map_to_peer_config(&resp.configuration)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self, config), fields(peer_id = self.inner.id.as_str())))]
pub async fn set_configuration(&self, config: &PeerConfig) -> Result<()> {
let body = crate::types::peer::PeerConfigurationSet {
configuration: config.clone(),
};
let resp: PeerResponse = self
.inner
.http
.put(
&routes::peer(&self.inner.workspace_id, &self.inner.id)?,
Some(&body),
&[],
)
.await?;
self.store(None, Some(map_to_peer_config(&resp.configuration)?));
Ok(())
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(peer_id = self.inner.id.as_str())))]
pub async fn get_configuration_raw(&self) -> Result<HashMap<String, Value>> {
let resp = self.fetch_and_update_cache().await?;
Ok(resp.configuration)
}
async fn fetch_and_update_cache(&self) -> Result<PeerResponse> {
let mut body_map = serde_json::Map::new();
body_map.insert("id".into(), Value::String(self.inner.id.clone()));
let body = Value::Object(body_map);
let resp: PeerResponse = self
.inner
.http
.post(&routes::peers(&self.inner.workspace_id)?, Some(&body), &[])
.await?;
let configuration = map_to_peer_config(&resp.configuration)?;
self.store(Some(resp.metadata.clone()), Some(configuration));
Ok(resp)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self, config), fields(peer_id = self.inner.id.as_str())))]
pub async fn set_configuration_raw(&self, config: HashMap<String, Value>) -> Result<()> {
let body = serde_json::json!({"configuration": config});
let resp: PeerResponse = self
.inner
.http
.put(
&routes::peer(&self.inner.workspace_id, &self.inner.id)?,
Some(&body),
&[],
)
.await?;
self.store(None, Some(map_to_peer_config(&resp.configuration)?));
Ok(())
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self, metadata), fields(peer_id = self.inner.id.as_str())))]
pub async fn update(&self, metadata: HashMap<String, Value>) -> Result<()> {
self.put_metadata(metadata).await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(peer_id = self.inner.id.as_str())))]
pub async fn chat(&self, query: &str) -> Result<Option<String>> {
let options = DialecticOptions {
query: query.to_owned(),
session_id: None,
target: None,
stream: false,
reasoning_level: ReasoningLevel::default(),
};
self.chat_with_options(&options).await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self, options), fields(peer_id = self.inner.id.as_str())))]
pub async fn chat_with_options(&self, options: &DialecticOptions) -> Result<Option<String>> {
options.validate()?;
let resp: ChatResponse = self
.inner
.http
.post(
&routes::peer_chat(&self.inner.workspace_id, &self.inner.id)?,
Some(options),
&[],
)
.await?;
match resp.content {
Some(c) if !c.is_empty() => Ok(Some(c)),
_ => Ok(None),
}
}
#[must_use]
pub fn chat_stream(&self, query: impl Into<String>) -> ChatStreamBuilder {
ChatStreamBuilder {
http: self.inner.http.clone(),
workspace_id: self.inner.workspace_id.clone(),
peer_id: self.inner.id.clone(),
query: query.into(),
target: None,
session_id: None,
reasoning_level: None,
}
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(peer_id = self.inner.id.as_str())))]
pub async fn representation(&self) -> Result<String> {
let route = routes::peer_representation(&self.inner.workspace_id, &self.inner.id)?;
let body = crate::types::peer::PeerRepresentationGet {
session_id: None,
target: None,
search_query: None,
search_top_k: None,
search_max_distance: None,
include_most_frequent: None,
max_conclusions: None,
};
let resp: RepresentationResponse = self.inner.http.post(&route, Some(&body), &[]).await?;
Ok(resp.representation)
}
#[must_use]
pub fn representation_builder(&self) -> RepresentationBuilder {
RepresentationBuilder {
http: self.inner.http.clone(),
workspace_id: self.inner.workspace_id.clone(),
peer_id: self.inner.id.clone(),
session_id: None,
target: None,
search_query: None,
search_top_k: None,
search_max_distance: None,
include_most_frequent: None,
max_conclusions: None,
}
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(peer_id = self.inner.id.as_str())))]
pub async fn context(&self) -> Result<PeerContext> {
self.context_builder().send().await
}
#[must_use]
pub fn context_builder(&self) -> ContextBuilder {
ContextBuilder {
http: self.inner.http.clone(),
workspace_id: self.inner.workspace_id.clone(),
peer_id: self.inner.id.clone(),
target: None,
summary: None,
limit_to_session: None,
max_conclusions: None,
search_query: None,
search_top_k: None,
search_max_distance: None,
include_most_frequent: None,
}
}
#[deprecated(since = "0.1.1", note = "use `Peer::context_builder()` instead")]
#[allow(deprecated)]
pub async fn context_with_target(&self, target: &str) -> Result<PeerContext> {
let opts = crate::types::peer::PeerContextOptions::builder()
.target(target)
.build();
self.context_with_options(&opts).await
}
#[deprecated(since = "0.1.1", note = "use `Peer::context_builder()` instead")]
#[allow(deprecated)]
pub async fn context_with_options(
&self,
options: &crate::types::peer::PeerContextOptions,
) -> Result<PeerContext> {
let mut builder = self.context_builder();
if let Some(ref v) = options.target {
builder = builder.target(v.clone());
}
if let Some(ref v) = options.search_query {
builder = builder.search_query(v.clone());
}
if let Some(v) = options.search_top_k {
builder = builder.search_top_k(v);
}
if let Some(v) = options.search_max_distance {
builder = builder.search_max_distance(v);
}
if let Some(v) = options.include_most_frequent {
builder = builder.include_most_frequent(v);
}
if let Some(v) = options.max_conclusions {
builder = builder.max_conclusions(v);
}
builder.send().await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(peer_id = self.inner.id.as_str())))]
pub async fn sessions(&self) -> Result<Page<SessionResponse>> {
let route = routes::peer_sessions_list(&self.inner.workspace_id, &self.inner.id)?;
pagination::paginate_post(&self.inner.http, &route, None, 1, 50, false).await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self, options), fields(peer_id = self.inner.id.as_str())))]
pub async fn sessions_with_options(
&self,
options: &SessionListOptions,
) -> Result<Page<SessionResponse>> {
let route = routes::peer_sessions_list(&self.inner.workspace_id, &self.inner.id)?;
let body = options
.filters
.as_ref()
.map(|f| crate::types::session::SessionGet {
filters: Some(f.clone()),
});
let body_val = body
.as_ref()
.map(|b| {
serde_json::to_value(b).map_err(|e| HonchoError::Serialization {
path: "SessionGet".into(),
source: e,
})
})
.transpose()?;
pagination::paginate_post(
&self.inner.http,
&route,
body_val.as_ref(),
options.page,
options.size,
options.reverse,
)
.await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(peer_id = self.inner.id.as_str())))]
pub async fn search(&self, query: &str) -> Result<Vec<crate::Message>> {
self.search_with_options(&MessageSearchOptions {
query: query.to_string(),
filters: None,
limit: 10,
})
.await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self, options), fields(peer_id = self.inner.id.as_str())))]
pub async fn search_with_options(
&self,
options: &MessageSearchOptions,
) -> Result<Vec<crate::Message>> {
if options.query.is_empty() {
return Err(HonchoError::Validation(
"query must not be empty".to_string(),
));
}
let responses: Vec<MessageResponse> = self
.inner
.http
.post(
&routes::peer_search(&self.inner.workspace_id, &self.inner.id)?,
Some(options),
&[],
)
.await?;
Ok(responses
.into_iter()
.map(crate::Message::from_raw)
.collect())
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(peer_id = self.inner.id.as_str())))]
pub async fn get_card(&self) -> Result<Option<Vec<String>>> {
let resp: PeerCardResponse = self
.inner
.http
.get(
&routes::peer_card(&self.inner.workspace_id, &self.inner.id)?,
&[],
)
.await?;
Ok(resp.peer_card)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(peer_id = self.inner.id.as_str())))]
pub async fn get_card_with_target(&self, target: &str) -> Result<Option<Vec<String>>> {
let resp: PeerCardResponse = self
.inner
.http
.get(
&routes::peer_card(&self.inner.workspace_id, &self.inner.id)?,
&[("target", target)],
)
.await?;
Ok(resp.peer_card)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self, card), fields(peer_id = self.inner.id.as_str())))]
pub async fn set_card(&self, card: Vec<String>) -> Result<Option<Vec<String>>> {
let body = PeerCardSet::builder().peer_card(card).build();
let resp: PeerCardResponse = self
.inner
.http
.put(
&routes::peer_card(&self.inner.workspace_id, &self.inner.id)?,
Some(&body),
&[],
)
.await?;
Ok(resp.peer_card)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self, card), fields(peer_id = self.inner.id.as_str())))]
pub async fn set_card_with_target(
&self,
card: Vec<String>,
target: &str,
) -> Result<Option<Vec<String>>> {
let body = PeerCardSet::builder().peer_card(card).build();
let resp: PeerCardResponse = self
.inner
.http
.put(
&routes::peer_card(&self.inner.workspace_id, &self.inner.id)?,
Some(&body),
&[("target", target)],
)
.await?;
Ok(resp.peer_card)
}
#[must_use]
pub fn conclusions(&self) -> ConclusionScope {
ConclusionScope::new(
self.inner.http.clone(),
self.inner.workspace_id.clone(),
self.id().to_owned(),
self.id().to_owned(),
)
}
#[must_use]
pub fn conclusions_of(&self, target: impl Into<String>) -> ConclusionScope {
ConclusionScope::new(
self.inner.http.clone(),
self.inner.workspace_id.clone(),
self.id().to_owned(),
target.into(),
)
}
#[must_use]
pub fn message(&self, content: impl Into<String>) -> MessageBuilder {
MessageBuilder {
peer_id: self.inner.id.clone(),
content: content.into(),
metadata: None,
configuration: None,
created_at: None,
}
}
}
pub struct ChatStreamBuilder {
http: HttpClient,
workspace_id: String,
peer_id: String,
query: String,
target: Option<String>,
session_id: Option<String>,
reasoning_level: Option<ReasoningLevel>,
}
impl ChatStreamBuilder {
#[must_use]
pub fn target(mut self, target: impl Into<String>) -> Self {
self.target = Some(target.into());
self
}
#[must_use]
pub fn session(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
#[must_use]
pub fn reasoning_level(mut self, level: ReasoningLevel) -> Self {
self.reasoning_level = Some(level);
self
}
#[allow(clippy::type_complexity)]
pub async fn send(
self,
) -> Result<DialecticStream<Pin<Box<dyn Stream<Item = Result<String>> + Send>>>> {
validate_dialectic_query(&self.query)?;
let opts = DialecticOptions::builder()
.query(self.query)
.stream(true)
.maybe_target(self.target)
.maybe_session_id(self.session_id)
.reasoning_level(self.reasoning_level.unwrap_or_default())
.build();
let route = routes::peer_chat(&self.workspace_id, &self.peer_id)?;
let response = self
.http
.request_streaming(
Method::POST,
&route,
Some(
&serde_json::to_value(&opts).map_err(|e| HonchoError::Serialization {
path: "DialecticOptions".into(),
source: e,
})?,
),
&[],
)
.await?;
Ok(DialecticStream::new(Box::pin(parse_sse_stream(
response.bytes_stream(),
))))
}
}
pub struct RepresentationBuilder {
http: HttpClient,
workspace_id: String,
peer_id: String,
session_id: Option<String>,
target: Option<String>,
search_query: Option<String>,
search_top_k: Option<u32>,
search_max_distance: Option<f64>,
include_most_frequent: Option<bool>,
max_conclusions: Option<u32>,
}
impl RepresentationBuilder {
#[must_use]
pub fn session_id(mut self, val: impl Into<String>) -> Self {
self.session_id = Some(val.into());
self
}
#[must_use]
pub fn target(mut self, val: impl Into<String>) -> Self {
self.target = Some(val.into());
self
}
#[must_use]
pub fn search_query(mut self, val: impl Into<String>) -> Self {
self.search_query = Some(val.into());
self
}
#[must_use]
pub fn search_top_k(mut self, val: u32) -> Self {
self.search_top_k = Some(val);
self
}
#[must_use]
pub fn search_max_distance(mut self, val: f64) -> Self {
self.search_max_distance = Some(val);
self
}
#[must_use]
pub fn include_most_frequent(mut self, val: bool) -> Self {
self.include_most_frequent = Some(val);
self
}
#[must_use]
pub fn max_conclusions(mut self, val: u32) -> Self {
self.max_conclusions = Some(val);
self
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(peer_id = self.peer_id.as_str())))]
pub async fn send(self) -> Result<String> {
validate_search_params(
self.search_top_k,
self.search_max_distance,
self.max_conclusions,
)?;
let params = crate::types::peer::PeerRepresentationGet {
session_id: self.session_id,
target: self.target,
search_query: self.search_query,
search_top_k: self.search_top_k,
search_max_distance: self.search_max_distance,
include_most_frequent: self.include_most_frequent,
max_conclusions: self.max_conclusions,
};
let route = routes::peer_representation(&self.workspace_id, &self.peer_id)?;
let resp: RepresentationResponse = self.http.post(&route, Some(¶ms), &[]).await?;
Ok(resp.representation)
}
}
pub struct ContextBuilder {
http: HttpClient,
workspace_id: String,
peer_id: String,
target: Option<String>,
summary: Option<bool>,
limit_to_session: Option<bool>,
max_conclusions: Option<u32>,
search_query: Option<String>,
search_top_k: Option<u32>,
search_max_distance: Option<f64>,
include_most_frequent: Option<bool>,
}
impl ContextBuilder {
#[must_use]
pub fn target(mut self, val: impl Into<String>) -> Self {
self.target = Some(val.into());
self
}
#[must_use]
pub fn summary(mut self, val: bool) -> Self {
self.summary = Some(val);
self
}
#[must_use]
pub fn limit_to_session(mut self, val: bool) -> Self {
self.limit_to_session = Some(val);
self
}
#[must_use]
pub fn max_conclusions(mut self, val: u32) -> Self {
self.max_conclusions = Some(val);
self
}
#[must_use]
pub fn search_query(mut self, val: impl Into<String>) -> Self {
self.search_query = Some(val.into());
self
}
#[must_use]
pub fn search_top_k(mut self, val: u32) -> Self {
self.search_top_k = Some(val);
self
}
#[must_use]
pub fn search_max_distance(mut self, val: f64) -> Self {
self.search_max_distance = Some(val);
self
}
#[must_use]
pub fn include_most_frequent(mut self, val: bool) -> Self {
self.include_most_frequent = Some(val);
self
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(peer_id = self.peer_id.as_str())))]
pub async fn send(self) -> Result<PeerContext> {
validate_search_params(
self.search_top_k,
self.search_max_distance,
self.max_conclusions,
)?;
macro_rules! push_param {
($params:expr, $key:expr, $val:expr) => {
if let Some(v) = $val {
$params.push(($key, v.to_string()));
}
};
}
let route = routes::peer_context(&self.workspace_id, &self.peer_id)?;
let mut params: Vec<(&str, String)> = Vec::new();
if let Some(v) = self.target {
params.push(("target", v));
}
push_param!(params, "summary", self.summary);
push_param!(params, "limit_to_session", self.limit_to_session);
if let Some(v) = self.search_query {
params.push(("search_query", v));
}
push_param!(params, "search_top_k", self.search_top_k);
push_param!(params, "search_max_distance", self.search_max_distance);
push_param!(params, "include_most_frequent", self.include_most_frequent);
push_param!(params, "max_conclusions", self.max_conclusions);
let refs: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect();
self.http.get(&route, &refs).await
}
}
const MAX_MESSAGE_CONTENT_LENGTH: usize = 25_000;
pub struct MessageBuilder {
peer_id: String,
content: String,
metadata: Option<HashMap<String, Value>>,
configuration: Option<crate::types::message::MessageConfiguration>,
created_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl MessageBuilder {
#[must_use]
pub fn metadata(mut self, val: HashMap<String, Value>) -> Self {
self.metadata = Some(val);
self
}
#[must_use]
pub fn configuration(mut self, val: crate::types::message::MessageConfiguration) -> Self {
self.configuration = Some(val);
self
}
#[must_use]
pub fn created_at(mut self, val: chrono::DateTime<chrono::Utc>) -> Self {
self.created_at = Some(val);
self
}
pub fn build(self) -> Result<MessageCreate> {
if self.content.trim().is_empty() {
return Err(HonchoError::Validation("content must not be empty".into()));
}
let char_count = self.content.chars().count();
if char_count > MAX_MESSAGE_CONTENT_LENGTH {
return Err(HonchoError::Validation(format!(
"content must be at most {MAX_MESSAGE_CONTENT_LENGTH} characters, got {char_count}"
)));
}
Ok(MessageCreate {
peer_id: self.peer_id,
content: self.content,
metadata: self.metadata,
configuration: self.configuration,
created_at: self.created_at,
})
}
}
fn map_to_peer_config(map: &HashMap<String, Value>) -> Result<PeerConfig> {
let field_bool = |key: &str| -> Result<Option<bool>> {
match map.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(other) => Err(HonchoError::Configuration(format!(
"peer configuration field `{key}` must be a boolean, got {other}"
))),
}
};
Ok(PeerConfig {
observe_me: field_bool("observe_me")?,
observe_others: field_bool("observe_others")?,
})
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unnecessary_wraps,
clippy::needless_pass_by_value,
clippy::unused_async
)]
mod tests {
use super::*;
use std::pin::Pin;
#[allow(dead_code)]
fn assert_send_static<T: Send + 'static>(_: &T) {}
#[test]
fn chat_stream_return_type_is_send_static() {
fn _assertion(
stream: DialecticStream<
Pin<Box<dyn futures_util::Stream<Item = Result<String>> + Send>>,
>,
) {
assert_send_static(&stream);
}
}
fn peer_with_base_url(base_url: &str) -> Peer {
let http = HttpClient::from_params(
HttpClient::builder()
.base_url(base_url)
.max_retries(0)
.build(),
)
.unwrap();
let resp = PeerResponse {
id: "p".into(),
workspace_id: "ws".into(),
created_at: chrono::Utc::now(),
metadata: HashMap::new(),
configuration: HashMap::new(),
};
Peer::from_parts(http, "ws".into(), resp).unwrap()
}
fn message_builder(content: &str) -> MessageBuilder {
MessageBuilder {
peer_id: "p".into(),
content: content.to_string(),
metadata: None,
configuration: None,
created_at: None,
}
}
#[test]
fn build_allows_multibyte_content_up_to_char_limit() {
let content = "ą".repeat(20_000);
assert!(content.len() > MAX_MESSAGE_CONTENT_LENGTH);
assert!(content.chars().count() <= MAX_MESSAGE_CONTENT_LENGTH);
assert!(message_builder(&content).build().is_ok());
}
#[test]
fn build_accepts_exactly_char_limit() {
let content = "ä".repeat(MAX_MESSAGE_CONTENT_LENGTH);
assert!(message_builder(&content).build().is_ok());
}
#[test]
fn build_rejects_content_over_char_limit() {
let content = "a".repeat(MAX_MESSAGE_CONTENT_LENGTH + 1);
let err = message_builder(&content).build().unwrap_err();
assert!(matches!(err, HonchoError::Validation(_)));
}
#[test]
fn cache_snapshot_is_consistent_under_concurrency() {
use std::sync::Arc as StdArc;
use std::thread;
let peer = StdArc::new(peer_with_base_url("http://localhost:1"));
let writer = {
let peer = peer.clone();
thread::spawn(move || {
for i in 0..20_000u64 {
let flag = i % 2 == 0;
let mut meta = HashMap::new();
meta.insert("flag".to_string(), Value::Bool(flag));
let config = PeerConfig {
observe_me: Some(flag),
observe_others: None,
};
peer.store(Some(meta), Some(config));
}
})
};
for _ in 0..20_000 {
let snap = peer.read_cache();
let meta_flag = snap
.metadata
.as_ref()
.and_then(|m| m.get("flag"))
.and_then(Value::as_bool);
let cfg_flag = snap.configuration.as_ref().and_then(|c| c.observe_me);
drop(snap);
if let (Some(m), Some(c)) = (meta_flag, cfg_flag) {
assert_eq!(m, c, "torn snapshot: metadata flag != configuration flag");
}
}
writer.join().unwrap();
}
#[tokio::test]
async fn get_configuration_returns_own_fetched_value_under_concurrency() {
use std::sync::Arc as StdArc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws/peers"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "p",
"workspace_id": "ws",
"created_at": "2024-01-01T00:00:00Z",
"metadata": {},
"configuration": {"observe_me": true}
})))
.mount(&server)
.await;
let peer = StdArc::new(peer_with_base_url(&server.uri()));
let stop = StdArc::new(AtomicBool::new(false));
let writer = {
let peer = peer.clone();
let stop = stop.clone();
thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
peer.store(
None,
Some(PeerConfig {
observe_me: Some(false),
observe_others: None,
}),
);
}
})
};
for _ in 0..200 {
let config = peer.get_configuration().await.unwrap();
assert_eq!(
config.observe_me,
Some(true),
"get_configuration returned a concurrently-stored value, not its own fetched response"
);
}
stop.store(true, Ordering::Relaxed);
writer.join().unwrap();
}
#[tokio::test]
async fn update_performs_full_put_replace() {
use wiremock::matchers::{body_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let peer = peer_with_base_url(&server.uri());
let mut meta = HashMap::new();
meta.insert("status".to_string(), Value::String("active".into()));
Mock::given(method("PUT"))
.and(path("/v3/workspaces/ws/peers/p"))
.and(body_json(
serde_json::json!({"metadata": {"status": "active"}}),
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "p",
"workspace_id": "ws",
"created_at": "2024-01-01T00:00:00Z",
"metadata": {"status": "active"},
"configuration": {}
})))
.mount(&server)
.await;
peer.update(meta).await.unwrap();
let cached = peer.metadata().unwrap();
assert_eq!(cached.get("status"), Some(&Value::String("active".into())));
}
}