use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LookupRequest {
pub key: String,
#[serde(default = "default_true")]
pub allow_shared: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LookupResponse {
Hit {
payload: String,
tool_kind: String,
bytes: u64,
#[serde(skip_serializing_if = "Option::is_none")]
provenance: Option<Provenance>,
#[serde(default)]
from_shared: bool,
#[serde(default)]
file_roots: Vec<FileRootSpec>,
},
Miss,
Invalidated,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersistRequest {
pub key: String,
pub tool_kind: String,
pub payload: String, #[serde(default)]
pub file_roots: Vec<FileRootSpec>,
#[serde(default)]
pub upstream_keys: Vec<String>,
#[serde(default)]
pub promote_to_shared: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileRootSpec {
pub path: String,
pub expected_hash: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PersistResponse {
Stored {
promoted_to_shared: bool,
bytes_used_after: u64,
bytes_quota: Option<u64>,
},
Rejected { reason: String },
QuotaExceeded { bytes_used: u64, bytes_quota: u64 },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InvalidateUpstreamRequest {
pub key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InvalidateUpstreamResponse {
pub dropped_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatsResponse {
pub user_id: String,
pub user_bytes_used: u64,
pub user_bytes_quota: Option<u64>,
pub user_entry_count: u64,
pub shared_entry_count: u64,
pub global_bytes_used: u64,
#[serde(default)]
pub shared_bytes_used: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shared_bytes_cap: Option<u64>,
#[serde(default)]
pub shared_evictions_total: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Provenance {
pub author_user_id: String,
pub author_tool_kind: String,
pub persisted_at_unix: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorEnvelope {
pub error: ErrorBody,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorBody {
pub message: String,
pub r#type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn roundtrip<T>(v: &T) -> T
where
T: Serialize + for<'de> Deserialize<'de> + PartialEq + std::fmt::Debug,
{
let bytes = serde_json::to_vec(v).expect("serialize");
let decoded: T = serde_json::from_slice(&bytes).expect("deserialize");
assert_eq!(v, &decoded);
decoded
}
#[test]
fn lookup_request_default_allow_shared_true() {
let raw = json!({ "key": "deadbeef".repeat(8) });
let req: LookupRequest = serde_json::from_value(raw).unwrap();
assert!(req.allow_shared);
}
#[test]
fn lookup_response_hit_with_provenance_roundtrips() {
let h = LookupResponse::Hit {
payload: "aGVsbG8=".into(),
tool_kind: "read".into(),
bytes: 5,
provenance: Some(Provenance {
author_user_id: "alice".into(),
author_tool_kind: "read".into(),
persisted_at_unix: 1_700_000_000,
note: Some("first read".into()),
}),
from_shared: true,
file_roots: vec![FileRootSpec {
path: "/abs/path".into(),
expected_hash: "f".repeat(64),
}],
};
roundtrip(&h);
}
#[test]
fn lookup_response_miss_roundtrips() {
roundtrip(&LookupResponse::Miss);
}
#[test]
fn lookup_response_invalidated_roundtrips() {
roundtrip(&LookupResponse::Invalidated);
}
#[test]
fn persist_request_with_upstreams_roundtrips() {
let req = PersistRequest {
key: "a".repeat(64),
tool_kind: "llm_call".into(),
payload: "Zm9v".into(),
file_roots: vec![FileRootSpec {
path: "/abs/path".into(),
expected_hash: "b".repeat(64),
}],
upstream_keys: vec!["c".repeat(64), "d".repeat(64)],
promote_to_shared: true,
};
roundtrip(&req);
}
#[test]
fn persist_response_quota_exceeded_roundtrips() {
roundtrip(&PersistResponse::QuotaExceeded {
bytes_used: 1_000_000,
bytes_quota: 1_048_576,
});
}
#[test]
fn persist_response_rejected_roundtrips() {
roundtrip(&PersistResponse::Rejected {
reason: "file root /abs/path hash mismatch".into(),
});
}
#[test]
fn invalidate_upstream_roundtrips() {
roundtrip(&InvalidateUpstreamRequest {
key: "e".repeat(64),
});
roundtrip(&InvalidateUpstreamResponse { dropped_count: 7 });
}
#[test]
fn stats_roundtrips_with_optional_quota_unset() {
let s = StatsResponse {
user_id: "alice".into(),
user_bytes_used: 1024,
user_bytes_quota: None,
user_entry_count: 5,
shared_entry_count: 100,
global_bytes_used: 1_000_000,
shared_bytes_used: 500_000,
shared_bytes_cap: Some(2_000_000_000),
shared_evictions_total: 42,
};
roundtrip(&s);
}
#[test]
fn error_envelope_roundtrips() {
let e = ErrorEnvelope {
error: ErrorBody {
message: "unauthorized".into(),
r#type: "auth_error".into(),
details: Some(json!({ "hint": "supply Authorization: Bearer <token>" })),
},
};
roundtrip(&e);
}
#[test]
fn unknown_lookup_response_kind_errors_cleanly() {
let raw = json!({ "kind": "future_variant", "payload": "x" });
let err = serde_json::from_value::<LookupResponse>(raw).unwrap_err();
assert!(err.to_string().contains("future_variant"));
}
}