#![cfg(feature = "database")]
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use docker_wrapper::template::redis::RedisTemplate;
use docker_wrapper::testing::ContainerGuardBuilder;
use serde_json::json;
use serial_test::serial;
use tokio::sync::OnceCell;
use tower_mcp::Tool;
use redisctl_mcp::policy::{Policy, PolicyConfig, SafetyTier};
use redisctl_mcp::state::AppState;
use redisctl_mcp::tools::redis;
static REDIS_STACK_GUARD: OnceCell<RedisStackTestContext> = OnceCell::const_new();
struct RedisStackTestContext {
_guard: docker_wrapper::testing::ContainerGuard<RedisTemplate>,
port: u16,
}
unsafe impl Send for RedisStackTestContext {}
unsafe impl Sync for RedisStackTestContext {}
async fn get_redis_stack() -> anyhow::Result<&'static RedisStackTestContext> {
REDIS_STACK_GUARD
.get_or_try_init(|| async {
let reuse = std::env::var("REUSE_CONTAINERS").is_ok();
let template = RedisTemplate::new("redisctl-mcp-stack-test")
.with_redis_stack()
.port(16381);
let guard = ContainerGuardBuilder::new(template)
.stop_on_drop(!reuse)
.remove_on_drop(!reuse)
.reuse_if_running(reuse)
.keep_on_panic(true)
.capture_logs(true)
.wait_for_ready(true)
.stop_timeout(Duration::from_secs(10))
.start()
.await
.map_err(|e| anyhow::anyhow!("Failed to start container: {}", e))?;
let port = guard
.host_port(6379)
.await
.map_err(|e| anyhow::anyhow!("Failed to get port: {}", e))?;
Ok(RedisStackTestContext {
_guard: guard,
port,
})
})
.await
}
fn redis_url(port: u16) -> String {
format!("redis://localhost:{}", port)
}
fn make_state(port: u16) -> Arc<AppState> {
let policy = Arc::new(Policy::new(
PolicyConfig::default(),
HashMap::new(),
"test".to_string(),
));
Arc::new(
AppState::new(
redisctl_mcp::state::CredentialSource::Profiles(vec![]),
policy,
Some(redis_url(port)),
false,
None,
)
.unwrap(),
)
}
fn make_rw_state(port: u16) -> Arc<AppState> {
let policy = Arc::new(Policy::new(
PolicyConfig {
tier: SafetyTier::ReadWrite,
..Default::default()
},
HashMap::new(),
"test".to_string(),
));
Arc::new(
AppState::new(
redisctl_mcp::state::CredentialSource::Profiles(vec![]),
policy,
Some(redis_url(port)),
false,
None,
)
.unwrap(),
)
}
fn make_full_state(port: u16) -> Arc<AppState> {
let policy = Arc::new(Policy::new(
PolicyConfig {
tier: SafetyTier::Full,
..Default::default()
},
HashMap::new(),
"test".to_string(),
));
Arc::new(
AppState::new(
redisctl_mcp::state::CredentialSource::Profiles(vec![]),
policy,
Some(redis_url(port)),
false,
None,
)
.unwrap(),
)
}
async fn call_tool_text(tool: &Tool, input: serde_json::Value) -> String {
let result = tool.call(input).await;
result
.content
.first()
.and_then(|c: &tower_mcp::Content| c.as_text())
.unwrap_or_default()
.to_string()
}
async fn get_conn(port: u16) -> ::redis::aio::MultiplexedConnection {
let client = ::redis::Client::open(redis_url(port)).unwrap();
client.get_multiplexed_async_connection().await.unwrap()
}
async fn cleanup(conn: &mut ::redis::aio::MultiplexedConnection, prefix: &str) {
let keys: Vec<String> = ::redis::cmd("KEYS")
.arg(format!("{}*", prefix))
.query_async(conn)
.await
.unwrap_or_default();
if !keys.is_empty() {
let mut cmd = ::redis::cmd("DEL");
for k in &keys {
cmd.arg(k);
}
let _: () = cmd.query_async(conn).await.unwrap_or_default();
}
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_json_tools() {
let ctx = get_redis_stack()
.await
.expect("Failed to get Redis Stack container");
let state = make_full_state(ctx.port);
let mut conn = get_conn(ctx.port).await;
cleanup(&mut conn, "js_doc:").await;
let text = call_tool_text(
&redis::json_set(state.clone()),
json!({
"key": "js_doc:1",
"path": "$",
"value": "{\"name\":\"alice\",\"age\":30,\"scores\":[1,2,3],\"active\":true}"
}),
)
.await;
assert!(text.contains("OK"), "json_set: {}", text);
let text = call_tool_text(&redis::json_get(state.clone()), json!({"key": "js_doc:1"})).await;
assert!(text.contains("alice"), "json_get: {}", text);
let text = call_tool_text(
&redis::json_mget(state.clone()),
json!({"keys": ["js_doc:1", "js_doc:2"], "path": "$.name"}),
)
.await;
assert!(text.contains("alice"), "json_mget alice: {}", text);
assert!(text.contains("nil"), "json_mget nil: {}", text);
let text = call_tool_text(
&redis::json_type(state.clone()),
json!({"key": "js_doc:1", "path": "$"}),
)
.await;
assert!(text.contains("object"), "json_type: {}", text);
let text = call_tool_text(
&redis::json_strlen(state.clone()),
json!({"key": "js_doc:1", "path": "$.name"}),
)
.await;
assert!(text.contains("5"), "json_strlen: {}", text);
let text = call_tool_text(
&redis::json_objkeys(state.clone()),
json!({"key": "js_doc:1", "path": "$"}),
)
.await;
assert!(text.contains("name"), "json_objkeys name: {}", text);
assert!(text.contains("scores"), "json_objkeys scores: {}", text);
let text = call_tool_text(
&redis::json_objlen(state.clone()),
json!({"key": "js_doc:1", "path": "$"}),
)
.await;
assert!(text.contains("4"), "json_objlen: {}", text);
let text = call_tool_text(
&redis::json_arrlen(state.clone()),
json!({"key": "js_doc:1", "path": "$.scores"}),
)
.await;
assert!(text.contains("3"), "json_arrlen: {}", text);
let text = call_tool_text(
&redis::json_numincrby(state.clone()),
json!({"key": "js_doc:1", "path": "$.age", "value": 1.0}),
)
.await;
assert!(text.contains("31"), "json_numincrby: {}", text);
let text = call_tool_text(
&redis::json_arrappend(state.clone()),
json!({"key": "js_doc:1", "path": "$.scores", "values": ["4"]}),
)
.await;
assert!(text.contains("4"), "json_arrappend: {}", text);
let text = call_tool_text(
&redis::json_toggle(state.clone()),
json!({"key": "js_doc:1", "path": "$.active"}),
)
.await;
assert!(
text.contains("false") || text.contains("0"),
"json_toggle: {}",
text
);
let text = call_tool_text(
&redis::json_del(state.clone()),
json!({"key": "js_doc:1", "path": "$.active"}),
)
.await;
assert!(text.contains("1"), "json_del: {}", text);
let text = call_tool_text(&redis::json_get(state.clone()), json!({"key": "js_doc:1"})).await;
assert!(!text.contains("active"), "json_get after del: {}", text);
let text = call_tool_text(
&redis::json_clear(state.clone()),
json!({"key": "js_doc:1", "path": "$.scores"}),
)
.await;
assert!(text.contains("Cleared"), "json_clear: {}", text);
cleanup(&mut conn, "js_doc:").await;
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_search_tools() {
let ctx = get_redis_stack()
.await
.expect("Failed to get Redis Stack container");
let full_state = make_full_state(ctx.port);
let state = make_state(ctx.port);
let mut conn = get_conn(ctx.port).await;
cleanup(&mut conn, "ft_doc:").await;
let _: Result<(), _> = ::redis::cmd("FT.DROPINDEX")
.arg("ft_test_idx")
.query_async::<()>(&mut conn)
.await;
for (key, doc) in [
(
"ft_doc:1",
"{\"title\":\"Redis Search Guide\",\"tags\":\"redis,search\",\"score\":9.5}",
),
(
"ft_doc:2",
"{\"title\":\"Redis JSON Tutorial\",\"tags\":\"redis,json\",\"score\":8.0}",
),
(
"ft_doc:3",
"{\"title\":\"Vector Search with Redis\",\"tags\":\"redis,vector\",\"score\":9.0}",
),
] {
let _: () = ::redis::cmd("JSON.SET")
.arg(key)
.arg("$")
.arg(doc)
.query_async(&mut conn)
.await
.unwrap();
}
let text = call_tool_text(
&redis::ft_create(full_state.clone()),
json!({
"index": "ft_test_idx",
"on": "JSON",
"prefixes": ["ft_doc:"],
"schema": [
{"name": "$.title", "alias": "title", "field_type": "TEXT"},
{"name": "$.tags", "alias": "tags", "field_type": "TAG"},
{"name": "$.score", "alias": "score", "field_type": "NUMERIC", "sortable": true}
]
}),
)
.await;
assert!(
text.contains("Created") || text.contains("OK"),
"ft_create: {}",
text
);
tokio::time::sleep(Duration::from_millis(150)).await;
let text = call_tool_text(&redis::ft_list(state.clone()), json!({})).await;
assert!(text.contains("ft_test_idx"), "ft_list: {}", text);
let text = call_tool_text(
&redis::ft_info(state.clone()),
json!({"index": "ft_test_idx"}),
)
.await;
assert!(text.contains("ft_test_idx"), "ft_info: {}", text);
let text = call_tool_text(
&redis::ft_search(state.clone()),
json!({"index": "ft_test_idx", "query": "redis"}),
)
.await;
assert!(
text.contains("Total results") && !text.contains("Total results: 0"),
"ft_search redis: {}",
text
);
let text = call_tool_text(
&redis::ft_search(state.clone()),
json!({"index": "ft_test_idx", "query": "@tags:{json}"}),
)
.await;
assert!(text.contains("ft_doc:2"), "ft_search tags: {}", text);
let text = call_tool_text(
&redis::ft_aggregate(state.clone()),
json!({
"index": "ft_test_idx",
"query": "*",
"raw_args": ["GROUPBY", "0", "REDUCE", "COUNT", "0", "AS", "total"]
}),
)
.await;
assert!(
text.contains("total") || text.contains("Total results"),
"ft_aggregate: {}",
text
);
let text = call_tool_text(
&redis::ft_explain(state.clone()),
json!({"index": "ft_test_idx", "query": "redis"}),
)
.await;
assert!(!text.is_empty(), "ft_explain empty");
assert!(text.contains("redis"), "ft_explain: {}", text);
let text = call_tool_text(
&redis::ft_tagvals(state.clone()),
json!({"index": "ft_test_idx", "field": "tags"}),
)
.await;
assert!(text.contains("redis"), "ft_tagvals: {}", text);
let result = redis::ft_alter(full_state.clone())
.call(json!({
"index": "ft_test_idx",
"field": {"name": "$.score", "alias": "score2", "field_type": "NUMERIC"}
}))
.await;
assert!(!result.is_error, "ft_alter should not error: {:?}", result);
let text = call_tool_text(
&redis::ft_dropindex(full_state.clone()),
json!({"index": "ft_test_idx"}),
)
.await;
assert!(text.contains("Dropped"), "ft_dropindex: {}", text);
cleanup(&mut conn, "ft_doc:").await;
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_bulk_seed_tools() {
let ctx = get_redis_stack()
.await
.expect("Failed to get Redis Stack container");
let state = make_rw_state(ctx.port);
let mut conn = get_conn(ctx.port).await;
cleanup(&mut conn, "bulk_").await;
cleanup(&mut conn, "seed_user:").await;
let text = call_tool_text(
&redis::bulk_load(state.clone()),
json!({"commands": [
{"args": ["SET", "bulk_k1", "v1"]},
{"args": ["SET", "bulk_k2", "v2"]},
{"args": ["SET", "bulk_k3", "v3"]}
]}),
)
.await;
assert!(text.contains("3"), "bulk_load SET: {}", text);
let text = call_tool_text(&redis::get(state.clone()), json!({"key": "bulk_k1"})).await;
assert!(text.contains("v1"), "get bulk_k1: {}", text);
let text = call_tool_text(
&redis::seed(state.clone()),
json!({
"data_type": "hash",
"key_pattern": "seed_user:{i}",
"count": 5,
"field_values": [
{"name": "id", "value": "{i}"},
{"name": "username", "value": "user_{i}"}
]
}),
)
.await;
assert!(text.contains("5"), "seed: {}", text);
let text = call_tool_text(
&redis::hget(state.clone()),
json!({"key": "seed_user:1", "field": "username"}),
)
.await;
assert!(text.contains("user_1"), "hget seed_user:1: {}", text);
let text = call_tool_text(
&redis::bulk_load(state.clone()),
json!({"commands": [
{"args": ["JSON.SET", "bulk_json:1", "$", "{\"x\":1}"]},
{"args": ["JSON.SET", "bulk_json:2", "$", "{\"x\":2}"]}
]}),
)
.await;
assert!(text.contains("2"), "bulk_load JSON.SET: {}", text);
let text = call_tool_text(
&redis::json_get(state.clone()),
json!({"key": "bulk_json:1"}),
)
.await;
assert!(text.contains("1"), "json_get bulk_json:1: {}", text);
cleanup(&mut conn, "bulk_").await;
cleanup(&mut conn, "seed_user:").await;
}
#[tokio::test]
#[ignore = "Requires Docker"]
#[serial]
async fn test_alias_tools() {
let ctx = get_redis_stack()
.await
.expect("Failed to get Redis Stack container");
let state = make_rw_state(ctx.port);
let mut conn = get_conn(ctx.port).await;
cleanup(&mut conn, "alias_doc:").await;
let text = call_tool_text(&redis::alias_list(state.clone()), json!({})).await;
assert!(text.contains("No aliases"), "alias_list empty: {}", text);
let text = call_tool_text(
&redis::alias_set(state.clone()),
json!({"name": "ping-check", "commands": [{"args": ["PING"]}]}),
)
.await;
assert!(text.contains("saved"), "alias_set ping-check: {}", text);
let text = call_tool_text(&redis::alias_list(state.clone()), json!({})).await;
assert!(
text.contains("ping-check"),
"alias_list ping-check: {}",
text
);
let text = call_tool_text(
&redis::alias_run(state.clone()),
json!({"name": "ping-check"}),
)
.await;
assert!(text.contains("PONG"), "alias_run ping-check: {}", text);
let text = call_tool_text(
&redis::alias_set(state.clone()),
json!({"name": "json-roundtrip", "commands": [
{"args": ["JSON.SET", "alias_doc:1", "$", "{\"v\":42}"]},
{"args": ["JSON.GET", "alias_doc:1", "$"]}
]}),
)
.await;
assert!(
text.contains("2 command"),
"alias_set json-roundtrip: {}",
text
);
let text = call_tool_text(
&redis::alias_run(state.clone()),
json!({"name": "json-roundtrip"}),
)
.await;
assert!(text.contains("42"), "alias_run json-roundtrip: {}", text);
let text = call_tool_text(
&redis::alias_delete(state.clone()),
json!({"name": "ping-check"}),
)
.await;
assert!(
text.contains("Deleted"),
"alias_delete ping-check: {}",
text
);
let text = call_tool_text(&redis::alias_list(state.clone()), json!({})).await;
assert!(
text.contains("json-roundtrip"),
"alias_list remaining: {}",
text
);
assert!(
!text.contains("ping-check"),
"alias_list ping-check gone: {}",
text
);
let text = call_tool_text(&redis::alias_delete(state.clone()), json!({"name": "nope"})).await;
assert!(text.contains("not found"), "alias_delete nope: {}", text);
cleanup(&mut conn, "alias_doc:").await;
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_ft_profile() {
let ctx = get_redis_stack()
.await
.expect("Failed to get Redis Stack container");
let full_state = make_full_state(ctx.port);
let state = make_state(ctx.port);
let mut conn = get_conn(ctx.port).await;
cleanup(&mut conn, "ftpro_doc:").await;
let _: Result<(), _> = ::redis::cmd("FT.DROPINDEX")
.arg("ftpro_idx")
.query_async::<()>(&mut conn)
.await;
let _: () = ::redis::cmd("JSON.SET")
.arg("ftpro_doc:1")
.arg("$")
.arg("{\"title\":\"Redis Search Profiling Guide\"}")
.query_async(&mut conn)
.await
.unwrap();
let text = call_tool_text(
&redis::ft_create(full_state.clone()),
json!({
"index": "ftpro_idx",
"on": "JSON",
"prefixes": ["ftpro_doc:"],
"schema": [
{"name": "$.title", "alias": "title", "field_type": "TEXT"}
]
}),
)
.await;
assert!(
text.contains("Created") || text.contains("OK"),
"ft_create: {}",
text
);
tokio::time::sleep(Duration::from_millis(150)).await;
let text = call_tool_text(
&redis::ft_profile(state.clone()),
json!({"index": "ftpro_idx", "command": "SEARCH", "query": "redis"}),
)
.await;
assert!(
text.contains("Profile for SEARCH"),
"ft_profile header: {}",
text
);
assert!(
text.contains("[0]:"),
"ft_profile results section: {}",
text
);
assert!(
text.contains("[1]:"),
"ft_profile profile section: {}",
text
);
let _: Result<(), _> = ::redis::cmd("FT.DROPINDEX")
.arg("ftpro_idx")
.query_async::<()>(&mut conn)
.await;
cleanup(&mut conn, "ftpro_doc:").await;
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_ft_synonym_and_dict() {
let ctx = get_redis_stack()
.await
.expect("Failed to get Redis Stack container");
let full_state = make_full_state(ctx.port);
let state = make_state(ctx.port);
let mut conn = get_conn(ctx.port).await;
cleanup(&mut conn, "ftsyn_doc:").await;
let _: Result<(), _> = ::redis::cmd("FT.DROPINDEX")
.arg("ftsyn_idx")
.query_async::<()>(&mut conn)
.await;
let _: Result<i64, _> = ::redis::cmd("FT.DICTDEL")
.arg("ftsyn_dict")
.arg("foo")
.arg("bar")
.query_async(&mut conn)
.await;
let text = call_tool_text(
&redis::ft_create(full_state.clone()),
json!({
"index": "ftsyn_idx",
"on": "JSON",
"prefixes": ["ftsyn_doc:"],
"schema": [
{"name": "$.title", "alias": "title", "field_type": "TEXT"}
]
}),
)
.await;
assert!(
text.contains("Created") || text.contains("OK"),
"ft_create: {}",
text
);
let text = call_tool_text(
&redis::ft_synupdate(full_state.clone()),
json!({
"index": "ftsyn_idx",
"group_id": "speed_group",
"terms": ["fast", "quick", "speedy"]
}),
)
.await;
assert!(
text.contains("Updated synonym group"),
"ft_synupdate: {}",
text
);
let text = call_tool_text(
&redis::ft_syndump(state.clone()),
json!({"index": "ftsyn_idx"}),
)
.await;
assert!(
text.contains("speed_group") || text.contains("fast"),
"ft_syndump: {}",
text
);
let text = call_tool_text(
&redis::ft_dictadd(full_state.clone()),
json!({"dict": "ftsyn_dict", "terms": ["foo", "bar"]}),
)
.await;
assert!(text.contains("Added"), "ft_dictadd: {}", text);
let text = call_tool_text(
&redis::ft_dictdump(state.clone()),
json!({"dict": "ftsyn_dict"}),
)
.await;
assert!(text.contains("foo"), "ft_dictdump foo: {}", text);
let text = call_tool_text(
&redis::ft_dictdel(full_state.clone()),
json!({"dict": "ftsyn_dict", "terms": ["foo"]}),
)
.await;
assert!(text.contains("Removed"), "ft_dictdel: {}", text);
let text = call_tool_text(
&redis::ft_dictdump(state.clone()),
json!({"dict": "ftsyn_dict"}),
)
.await;
assert!(text.contains("bar"), "ft_dictdump bar: {}", text);
assert!(!text.contains("foo"), "ft_dictdump foo gone: {}", text);
let _: Result<i64, _> = ::redis::cmd("FT.DICTDEL")
.arg("ftsyn_dict")
.arg("bar")
.query_async(&mut conn)
.await;
let _: Result<(), _> = ::redis::cmd("FT.DROPINDEX")
.arg("ftsyn_idx")
.query_async::<()>(&mut conn)
.await;
cleanup(&mut conn, "ftsyn_doc:").await;
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_ft_alias_management() {
let ctx = get_redis_stack()
.await
.expect("Failed to get Redis Stack container");
let full_state = make_full_state(ctx.port);
let state = make_state(ctx.port);
let mut conn = get_conn(ctx.port).await;
cleanup(&mut conn, "ftalias_doc:").await;
for idx in ["ft_alias_src_idx", "ft_alias_dst_idx"] {
let _: Result<(), _> = ::redis::cmd("FT.DROPINDEX")
.arg(idx)
.query_async::<()>(&mut conn)
.await;
}
let _: Result<(), _> = ::redis::cmd("FT.ALIASDEL")
.arg("ft_alias_test")
.query_async::<()>(&mut conn)
.await;
let _: () = ::redis::cmd("JSON.SET")
.arg("ftalias_doc:1")
.arg("$")
.arg("{\"title\":\"Redis Alias Guide\"}")
.query_async(&mut conn)
.await
.unwrap();
let schema = json!([
{"name": "$.title", "alias": "title", "field_type": "TEXT"}
]);
let text = call_tool_text(
&redis::ft_create(full_state.clone()),
json!({
"index": "ft_alias_src_idx",
"on": "JSON",
"prefixes": ["ftalias_doc:"],
"schema": schema.clone()
}),
)
.await;
assert!(
text.contains("Created") || text.contains("OK"),
"ft_create src: {}",
text
);
tokio::time::sleep(Duration::from_millis(150)).await;
let text = call_tool_text(
&redis::ft_aliasadd(full_state.clone()),
json!({"alias": "ft_alias_test", "index": "ft_alias_src_idx"}),
)
.await;
assert!(text.contains("Added alias"), "ft_aliasadd: {}", text);
let text = call_tool_text(
&redis::ft_search(state.clone()),
json!({"index": "ft_alias_test", "query": "redis"}),
)
.await;
assert!(
text.contains("Total results") && !text.contains("Total results: 0"),
"ft_search via alias: {}",
text
);
let text = call_tool_text(
&redis::ft_create(full_state.clone()),
json!({
"index": "ft_alias_dst_idx",
"on": "JSON",
"prefixes": ["ftalias_doc:"],
"schema": schema
}),
)
.await;
assert!(
text.contains("Created") || text.contains("OK"),
"ft_create dst: {}",
text
);
let text = call_tool_text(
&redis::ft_aliasupdate(full_state.clone()),
json!({"alias": "ft_alias_test", "index": "ft_alias_dst_idx"}),
)
.await;
assert!(text.contains("Updated alias"), "ft_aliasupdate: {}", text);
let text = call_tool_text(
&redis::ft_aliasdel(full_state.clone()),
json!({"alias": "ft_alias_test"}),
)
.await;
assert!(text.contains("Deleted alias"), "ft_aliasdel: {}", text);
for idx in ["ft_alias_src_idx", "ft_alias_dst_idx"] {
let _: Result<(), _> = ::redis::cmd("FT.DROPINDEX")
.arg(idx)
.query_async::<()>(&mut conn)
.await;
}
cleanup(&mut conn, "ftalias_doc:").await;
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_json_array_tools() {
let ctx = get_redis_stack()
.await
.expect("Failed to get Redis Stack container");
let state = make_full_state(ctx.port);
let mut conn = get_conn(ctx.port).await;
cleanup(&mut conn, "jarr_doc:").await;
let _: () = ::redis::cmd("JSON.SET")
.arg("jarr_doc:1")
.arg("$")
.arg("{\"nums\":[10,20,30,40,50]}")
.query_async(&mut conn)
.await
.unwrap();
let text = call_tool_text(
&redis::json_arrinsert(state.clone()),
json!({"key": "jarr_doc:1", "path": "$.nums", "index": 0, "values": ["99"]}),
)
.await;
assert!(text.contains("6"), "json_arrinsert: {}", text);
let text = call_tool_text(
&redis::json_arrpop(state.clone()),
json!({"key": "jarr_doc:1", "path": ".nums"}),
)
.await;
assert!(text.contains("Popped:"), "json_arrpop: {}", text);
assert!(text.contains("50"), "json_arrpop value: {}", text);
let text = call_tool_text(
&redis::json_arrtrim(state.clone()),
json!({"key": "jarr_doc:1", "path": "$.nums", "start": 0, "stop": 1}),
)
.await;
assert!(text.contains("Trimmed array"), "json_arrtrim: {}", text);
assert!(text.contains("2"), "json_arrtrim length: {}", text);
cleanup(&mut conn, "jarr_doc:").await;
}