use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use serde_json::{Value, json};
pub const TRUSTY_MEMORY_URL_ENV: &str = "TRUSTY_MEMORY_URL";
const MEMORY_APP_NAME: &str = "trusty-memory";
const UNREACHABLE_PLACEHOLDER: &str = "http://127.0.0.1:1";
pub fn resolve_memory_base_url() -> Result<String> {
if let Ok(url) = std::env::var(TRUSTY_MEMORY_URL_ENV) {
let trimmed = url.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
}
match crate::daemon_addr::read_daemon_addr(MEMORY_APP_NAME)? {
Some(addr) => Ok(format!("http://{addr}")),
None => Err(anyhow!(
"trusty-memory daemon address not discovered (is the daemon running?)"
)),
}
}
pub fn resolve_memory_base_url_or_unreachable() -> String {
resolve_memory_base_url().unwrap_or_else(|e| {
eprintln!("trusty-memory: {e}");
UNREACHABLE_PLACEHOLDER.to_string()
})
}
pub async fn call_memory_tool(method: &str, params: Value) -> Result<Value> {
let base = resolve_memory_base_url()?;
call_memory_tool_at(&base, method, params).await
}
pub async fn call_memory_tool_at(base_url: &str, method: &str, params: Value) -> Result<Value> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.context("build reqwest client")?;
let body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
});
let url = format!("{}/rpc", base_url.trim_end_matches('/'));
let resp = client
.post(&url)
.json(&body)
.send()
.await
.with_context(|| format!("POST {url}"))?;
if !resp.status().is_success() {
return Err(anyhow!(
"trusty-memory returned {} for {method}",
resp.status()
));
}
let envelope: Value = resp
.json()
.await
.with_context(|| format!("parse {method} JSON-RPC response"))?;
if let Some(err) = envelope.get("error").filter(|e| !e.is_null()) {
return Err(anyhow!("{method} RPC error: {err}"));
}
Ok(envelope.get("result").cloned().unwrap_or(Value::Null))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data_dir::ENV_LOCK;
#[test]
fn resolve_memory_base_url_prefers_env_override() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var(TRUSTY_MEMORY_URL_ENV, "http://example.test:1234");
}
let resolved = resolve_memory_base_url();
unsafe {
std::env::remove_var(TRUSTY_MEMORY_URL_ENV);
}
assert_eq!(resolved.unwrap(), "http://example.test:1234");
}
#[test]
fn resolve_memory_base_url_errs_when_undiscovered() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::remove_var(TRUSTY_MEMORY_URL_ENV);
}
let tmp = std::env::temp_dir().join(format!(
"trusty-common-memory-rpc-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&tmp).unwrap();
unsafe {
std::env::set_var(crate::data_dir::DATA_DIR_OVERRIDE_ENV, &tmp);
}
let resolved = resolve_memory_base_url();
unsafe {
std::env::remove_var(crate::data_dir::DATA_DIR_OVERRIDE_ENV);
}
assert!(resolved.is_err(), "expected Err when undiscovered");
}
#[test]
fn resolve_memory_base_url_or_unreachable_falls_back() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::remove_var(TRUSTY_MEMORY_URL_ENV);
}
let tmp = std::env::temp_dir().join(format!(
"trusty-common-memory-rpc-test-fallback-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&tmp).unwrap();
unsafe {
std::env::set_var(crate::data_dir::DATA_DIR_OVERRIDE_ENV, &tmp);
}
let resolved = resolve_memory_base_url_or_unreachable();
unsafe {
std::env::remove_var(crate::data_dir::DATA_DIR_OVERRIDE_ENV);
}
assert_eq!(resolved, UNREACHABLE_PLACEHOLDER);
}
async fn spawn_rpc_mock(body: &'static str) -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
if let Ok((mut sock, _)) = listener.accept().await {
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf).await;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.shutdown().await;
}
});
format!("http://{addr}")
}
#[tokio::test]
async fn call_memory_tool_at_returns_result_on_success() {
let base =
spawn_rpc_mock(r#"{"jsonrpc":"2.0","id":1,"result":{"palace":"p","drawers":[]}}"#)
.await;
let result = call_memory_tool_at(&base, "memory_list", json!({"palace": "p"}))
.await
.unwrap();
assert_eq!(result["palace"], "p");
}
#[tokio::test]
async fn call_memory_tool_at_rejects_rpc_error() {
let base = spawn_rpc_mock(
r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"missing 'palace'"}}"#,
)
.await;
let result = call_memory_tool_at(&base, "memory_list", json!({})).await;
assert!(result.is_err());
}
#[tokio::test]
#[ignore]
async fn call_memory_tool_at_unreachable_daemon() {
let result = call_memory_tool_at(
"http://127.0.0.1:1",
"memory_list",
json!({ "palace": "test-palace", "limit": 5 }),
)
.await;
assert!(result.is_err());
}
}