use serde_json::{Value, json};
use tracing::{debug, warn};
use crate::hook_run::mcp_client::McpHttpClient;
use crate::hook_run::{BundleRecallQuery, TagRecallQuery};
pub const TAG_KEYWORD_PREFIX: &str = "llmenv-tag:";
#[must_use]
pub fn tag_keyword(tag: &str) -> String {
format!("{TAG_KEYWORD_PREFIX}{tag}")
}
pub const BUNDLE_KEYWORD_PREFIX: &str = "llmenv-bundle:";
#[must_use]
pub fn bundle_keyword(bundle: &str) -> String {
format!("{BUNDLE_KEYWORD_PREFIX}{bundle}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
WakeUp,
Recall,
RecallTag(TagRecallQuery),
RecallBundle(BundleRecallQuery),
Store,
}
impl Action {
pub fn tool_name(&self) -> &'static str {
match self {
Action::WakeUp => "icm_wake_up",
Action::Recall | Action::RecallTag(_) | Action::RecallBundle(_) => "icm_memory_recall",
Action::Store => "icm_memory_store",
}
}
pub fn arguments(&self, query: &str, chunk: &str) -> Value {
match self {
Action::WakeUp => json!({}),
Action::Recall => json!({ "query": query }),
Action::RecallTag(q) => json!({
"query": q.tag,
"project": "",
"keyword": q.keyword,
}),
Action::RecallBundle(q) => json!({
"query": q.bundle,
"project": "",
"keyword": q.keyword,
}),
Action::Store => json!({ "content": chunk }),
}
}
pub async fn run(
&self,
client: &McpHttpClient,
query: &str,
chunk: &str,
) -> anyhow::Result<String> {
debug!(action = ?self, "dispatching MCP tool call");
client
.call_tool(self.tool_name(), self.arguments(query, chunk))
.await
.map_err(|e| {
warn!(action = ?self, error = %e, "MCP tool call failed");
e
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn action_tool_name_mapping() {
assert_eq!(Action::WakeUp.tool_name(), "icm_wake_up");
assert_eq!(Action::Recall.tool_name(), "icm_memory_recall");
assert_eq!(Action::Store.tool_name(), "icm_memory_store");
assert_eq!(recall_bundle("base").tool_name(), "icm_memory_recall");
}
#[test]
fn wakeup_arguments_are_empty_object() {
let args = Action::WakeUp.arguments("query text", "chunk text");
assert_eq!(args, serde_json::json!({}));
}
#[test]
fn recall_arguments_carry_query() {
let args = Action::Recall.arguments("rust, work", "chunk");
assert_eq!(args["query"], serde_json::json!("rust, work"));
}
#[test]
fn store_arguments_carry_content() {
let args = Action::Store.arguments("query", "## llmenv context\n...");
assert_eq!(args["content"], serde_json::json!("## llmenv context\n..."));
}
#[test]
fn tag_keyword_prefixes_tag() {
assert_eq!(tag_keyword("work-vpn"), "llmenv-tag:work-vpn");
assert_eq!(tag_keyword("rust"), "llmenv-tag:rust");
}
fn recall_tag(tag: &str) -> Action {
Action::RecallTag(TagRecallQuery {
tag: tag.to_string(),
keyword: tag_keyword(tag),
})
}
fn recall_bundle(bundle: &str) -> Action {
Action::RecallBundle(BundleRecallQuery {
bundle: bundle.to_string(),
keyword: bundle_keyword(bundle),
})
}
#[test]
fn recall_tag_tool_is_memory_recall() {
assert_eq!(recall_tag("work-vpn").tool_name(), "icm_memory_recall");
}
#[test]
fn bundle_keyword_prefixes_bundle() {
assert_eq!(bundle_keyword("base"), "llmenv-bundle:base");
assert_eq!(
bundle_keyword("rust-defaults"),
"llmenv-bundle:rust-defaults"
);
}
#[test]
fn recall_bundle_tool_is_memory_recall() {
assert_eq!(recall_bundle("base").tool_name(), "icm_memory_recall");
}
#[test]
fn recall_bundle_disables_project_filter() {
let args = recall_bundle("base").arguments("ignored", "ignored");
assert_eq!(
args["project"],
serde_json::json!(""),
"project must be empty to search across all projects"
);
}
#[test]
fn recall_bundle_keys_on_llmenv_bundle_keyword() {
let args = recall_bundle("base").arguments("ignored", "ignored");
assert_eq!(
args["keyword"],
serde_json::json!("llmenv-bundle:base"),
"recall must be keyed on the llmenv-bundle:<bundle> encoding"
);
assert_eq!(args["query"], serde_json::json!("base"));
}
#[test]
fn recall_tag_disables_project_filter() {
let args = recall_tag("work-vpn").arguments("ignored", "ignored");
assert_eq!(
args["project"],
serde_json::json!(""),
"project must be empty to search across all projects"
);
}
#[test]
fn recall_tag_keys_on_llmenv_tag_keyword() {
let args = recall_tag("work-vpn").arguments("ignored", "ignored");
assert_eq!(
args["keyword"],
serde_json::json!("llmenv-tag:work-vpn"),
"recall must be keyed on the llmenv-tag:<tag> encoding"
);
assert_eq!(args["query"], serde_json::json!("work-vpn"));
}
use proptest::prelude::*;
fn valid_tag() -> impl Strategy<Value = String> {
"[a-zA-Z0-9_-]{1,24}"
}
proptest! {
#[test]
fn prop_tag_keyword_is_prefix_plus_tag(tag in valid_tag()) {
let kw = tag_keyword(&tag);
prop_assert_eq!(&kw, &format!("{TAG_KEYWORD_PREFIX}{tag}"));
prop_assert!(kw.starts_with(TAG_KEYWORD_PREFIX));
prop_assert_eq!(&kw[TAG_KEYWORD_PREFIX.len()..], tag.as_str());
}
#[test]
fn prop_recall_tag_arguments_shape(tag in valid_tag()) {
let args = recall_tag(&tag).arguments("ignored", "ignored");
let obj = args.as_object().expect("arguments must be a JSON object");
prop_assert_eq!(obj.len(), 3, "exactly query/project/keyword");
prop_assert_eq!(&obj["query"], &serde_json::json!(tag));
prop_assert_eq!(&obj["project"], &serde_json::json!(""));
prop_assert_eq!(&obj["keyword"], &serde_json::json!(tag_keyword(&tag)));
}
#[test]
fn prop_bundle_keyword_is_prefix_plus_bundle(bundle in valid_tag()) {
let kw = bundle_keyword(&bundle);
prop_assert_eq!(&kw, &format!("{BUNDLE_KEYWORD_PREFIX}{bundle}"));
prop_assert!(kw.starts_with(BUNDLE_KEYWORD_PREFIX));
prop_assert_eq!(&kw[BUNDLE_KEYWORD_PREFIX.len()..], bundle.as_str());
}
#[test]
fn prop_recall_bundle_arguments_shape(bundle in valid_tag()) {
let args = recall_bundle(&bundle).arguments("ignored", "ignored");
let obj = args.as_object().expect("arguments must be a JSON object");
prop_assert_eq!(obj.len(), 3, "exactly query/project/keyword");
prop_assert_eq!(&obj["query"], &serde_json::json!(bundle));
prop_assert_eq!(&obj["project"], &serde_json::json!(""));
prop_assert_eq!(&obj["keyword"], &serde_json::json!(bundle_keyword(&bundle)));
}
}
}