use super::*;
#[cfg(feature = "axum-server")]
use serde_json::json;
#[test]
fn parse_user_prompt_prefers_prompt_field() {
let json_with_prompt = serde_json::json!({
"prompt": "what is rust?",
"cwd": "/tmp/example",
})
.to_string();
assert_eq!(parse_user_prompt(&json_with_prompt), "what is rust?");
let json_without_prompt = serde_json::json!({"cwd": "/tmp/example"}).to_string();
assert_eq!(parse_user_prompt(&json_without_prompt), json_without_prompt);
assert_eq!(parse_user_prompt("plain text query"), "plain text query");
assert_eq!(parse_user_prompt(""), "");
}
#[test]
fn filter_drawers_by_deny_tags_handles_edge_cases() {
use filter::{filter_drawers_by_deny_tags, RecalledDrawer};
let make = |tags: &[&str]| RecalledDrawer {
content: "irrelevant".into(),
tags: tags.iter().map(|s| s.to_string()).collect(),
layer: Some(2),
};
let drawers = vec![make(&["claude-session"]), make(&["rust"])];
let out = filter_drawers_by_deny_tags(drawers.clone(), &[]);
assert_eq!(out.len(), 2, "empty deny list must pass everything");
let drawers = vec![make(&["Claude-Session"]), make(&["rust"])];
let out = filter_drawers_by_deny_tags(drawers, &["claude-session".to_string()]);
assert_eq!(out.len(), 1);
assert!(out[0].tags.iter().any(|t| t == "rust"));
let drawers = vec![make(&[]), make(&["user-prompt"])];
let out = filter_drawers_by_deny_tags(drawers, &["user-prompt".to_string()]);
assert_eq!(out.len(), 1, "tagless drawers must survive the filter");
assert!(out[0].tags.is_empty());
let drawers = vec![
make(&["claude-session"]),
make(&["user-prompt"]),
make(&["signal"]),
];
let out = filter_drawers_by_deny_tags(
drawers,
&["claude-session".to_string(), "user-prompt".to_string()],
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].tags, vec!["signal".to_string()]);
}
#[test]
fn select_relevant_triples_filters_by_prompt_overlap() {
use filter::{select_relevant_triples, RawTriple};
let triples = vec![
RawTriple {
subject: "tga".into(),
predicate: "is_alias_for".into(),
object: "trusty-git-analytics".into(),
},
RawTriple {
subject: "python".into(),
predicate: "is-a".into(),
object: "language".into(),
},
RawTriple {
subject: "rust".into(),
predicate: "is-a".into(),
object: "language".into(),
},
];
let chosen = select_relevant_triples(&triples, "tell me about rust integration", 5);
assert_eq!(chosen.len(), 1, "only the rust triple should match");
assert_eq!(chosen[0].subject, "rust");
let none = select_relevant_triples(&triples, "weather forecast next week", 5);
assert!(none.is_empty());
}
#[test]
fn compose_injection_truncates_at_cap() {
use filter::{RawTriple, RecalledDrawer};
use format::compose_injection;
let big_global = "## Big block\n".to_string() + &"- fact line\n".repeat(500);
let drawers: Vec<RecalledDrawer> = (0..5)
.map(|i| RecalledDrawer {
content: format!("drawer {i} content"),
tags: vec!["tag1".into()],
layer: Some(2),
})
.collect();
let triples: Vec<RawTriple> = (0..5)
.map(|i| RawTriple {
subject: format!("subject{i}"),
predicate: "p".into(),
object: "object".into(),
})
.collect();
let out = compose_injection(Some(&big_global), &drawers, &triples, Some("alpha"));
assert!(
out.len() <= INJECTION_BYTE_CAP,
"expected len <= cap; got {}",
out.len()
);
assert!(
out.ends_with('…'),
"expected `…` truncation marker; got tail: {}",
&out[out.len().saturating_sub(20)..]
);
}
#[test]
fn compose_injection_empty_inputs_yields_empty() {
use format::compose_injection;
let out = compose_injection(None, &[], &[], Some("alpha"));
assert!(out.is_empty(), "got: {out:?}");
}
#[test]
fn resolve_palace_for_log_prefers_stdin_cwd() {
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path().join("stdin-driven-project");
std::fs::create_dir_all(&project).expect("create project dir");
let payload = serde_json::json!({
"hook_event_name": "UserPromptSubmit",
"cwd": project.to_string_lossy(),
"prompt": "hello"
})
.to_string();
let expected =
crate::messaging::cwd_palace_slug_at(&project).expect("derive slug from stdin cwd");
let got = resolve_palace_for_log(&payload);
assert_eq!(
got, expected,
"stdin `cwd` must override the process cwd for the log palace slug"
);
assert!(
got.contains("stdin-driven-project"),
"expected slug derived from stdin path, got {got:?}"
);
}
#[test]
fn resolve_palace_for_log_falls_back_to_process_cwd() {
let from_empty = resolve_palace_for_log("");
let from_garbage = resolve_palace_for_log("not json at all");
assert_eq!(from_empty, from_garbage);
assert_ne!(from_empty, "<unknown>");
}
#[tokio::test]
async fn prompt_context_returns_ok_without_daemon() {
let _guard = crate::commands::env_test_lock().lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
unsafe {
std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
}
let res = handle_prompt_context().await;
unsafe {
std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
}
assert!(
res.is_ok(),
"missing daemon lockfile must degrade to Ok(()), got {res:?}"
);
}
#[cfg(feature = "axum-server")]
#[tokio::test]
async fn prompt_context_recalls_palace_drawers() {
let _guard = crate::commands::env_test_lock().lock().await;
let (state, _data_dir_tmp, _project_dir_tmp, project_dir, slug, addr_handle) =
spin_up_test_daemon_with_palace("prompt-ctx-recall-pop").await;
for (text, tags) in [
(
"Rust integration uses tokio for async tasks and serde for JSON",
vec!["rust", "tokio"],
),
(
"Python bindings ship via PyO3 with custom ABI shims",
vec!["python", "pyo3"],
),
(
"Knowledge graph stores triples in redb with valid_from intervals",
vec!["kg", "redb"],
),
] {
let tags_json: Vec<serde_json::Value> = tags.iter().map(|t| json!(t)).collect();
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": slug,
"text": text,
"room": "General",
"tags": tags_json,
}),
)
.await
.expect("memory_remember");
}
let payload = json!({
"hook_event_name": "UserPromptSubmit",
"cwd": project_dir.to_string_lossy(),
"prompt": "how does rust integration work?"
})
.to_string();
let start = std::time::Instant::now();
let body = build_injection_body(&payload).await;
let elapsed_ms = start.elapsed().as_millis();
eprintln!("prompt_context_recalls_palace_drawers latency: {elapsed_ms}ms");
assert_ne!(
body, EMPTY_PLACEHOLDER,
"populated palace must return real content, not the placeholder"
);
assert!(
body.to_lowercase().contains("rust") && body.to_lowercase().contains("integration"),
"expected rust integration drawer in injection; got:\n{body}"
);
assert!(
body.contains("Relevant memories") || body.contains("memories from palace"),
"expected a `Relevant memories` section; got:\n{body}"
);
assert!(
elapsed_ms < 5_000,
"prompt-context too slow ({elapsed_ms}ms) — investigate"
);
addr_handle.shutdown().await;
}
#[cfg(feature = "axum-server")]
#[tokio::test]
async fn prompt_context_empty_palace_falls_back_to_global() {
let _guard = crate::commands::env_test_lock().lock().await;
let (_state, _data_dir_tmp, _project_dir_tmp, project_dir, _slug, addr_handle) =
spin_up_test_daemon_with_palace("prompt-ctx-recall-empty").await;
let payload = json!({
"hook_event_name": "UserPromptSubmit",
"cwd": project_dir.to_string_lossy(),
"prompt": "no drawers exist here"
})
.to_string();
let body = build_injection_body(&payload).await;
assert_eq!(
body, EMPTY_PLACEHOLDER,
"empty palace + empty prompt-facts must fall back to the placeholder"
);
addr_handle.shutdown().await;
}
#[cfg(feature = "axum-server")]
async fn spin_up_test_daemon_with_palace(
palace_slug: &str,
) -> (
crate::AppState,
tempfile::TempDir,
tempfile::TempDir,
std::path::PathBuf,
String,
DaemonHandle,
) {
let data_tmp = tempfile::tempdir().expect("data tempdir");
let project_tmp = tempfile::tempdir().expect("project tempdir");
let project_dir = project_tmp.path().join(palace_slug);
std::fs::create_dir_all(&project_dir).expect("project dir");
unsafe {
std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, data_tmp.path());
std::env::remove_var(crate::prompt_log::ENV_ENABLED);
std::env::remove_var(crate::prompt_log::ENV_DIR);
std::env::remove_var(crate::prompt_log::ENV_HASH_PROMPTS);
std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
}
crate::project_root::write_project_pin(
&project_dir,
&crate::project_root::ProjectPin {
schema_version: crate::project_root::PIN_SCHEMA_VERSION,
palace: palace_slug.to_string(),
note: None,
},
)
.expect("write project pin for fixture");
let data_root =
trusty_common::resolve_data_dir("trusty-memory").expect("resolve data dir under override");
let state = crate::AppState::new(data_root.clone());
state.set_ready();
let _ = crate::tools::dispatch_tool(&state, "palace_create", json!({"name": palace_slug}))
.await
.expect("palace_create");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind 127.0.0.1:0");
let addr = listener.local_addr().expect("local_addr");
let state_for_server = state.clone();
let handle = tokio::spawn(async move {
let _ = crate::run_http_on(state_for_server, listener).await;
});
let addr_file = data_root.join("http_addr");
let mut attempts = 0;
while !addr_file.exists() && attempts < 500 {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
attempts += 1;
}
assert!(
addr_file.exists(),
"daemon never wrote http_addr at {} (attempts={attempts})",
addr_file.display()
);
(
state,
data_tmp,
project_tmp,
project_dir,
palace_slug.to_string(),
DaemonHandle {
addr,
join: Some(handle),
},
)
}
#[cfg(feature = "axum-server")]
struct DaemonHandle {
#[allow(dead_code)]
addr: std::net::SocketAddr,
join: Option<tokio::task::JoinHandle<()>>,
}
#[cfg(feature = "axum-server")]
impl DaemonHandle {
async fn shutdown(mut self) {
if let Some(h) = self.join.take() {
h.abort();
let _ = h.await;
}
unsafe {
std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
}
}
}
#[cfg(feature = "axum-server")]
#[tokio::test]
async fn prompt_context_recall_filters_deny_tags() {
let _guard = crate::commands::env_test_lock().lock().await;
unsafe {
std::env::remove_var(ENV_RECALL_DENY_TAGS);
}
let (state, _data_dir_tmp, _project_dir_tmp, project_dir, slug, addr_handle) =
spin_up_test_daemon_with_palace("prompt-ctx-deny-tags").await;
for (text, tags) in [
(
"user: how do I use rust async tokio runtime and serde derive macros in this project to glue an http handler to a kafka producer",
vec!["claude-session", "user-prompt", "rust"],
),
(
"user: yes please go ahead and refactor the rust async producer module, this captured prompt fragment should never be surfaced",
vec!["user-prompt", "rust"],
),
(
"Rust integration uses tokio for async tasks and serde for JSON",
vec!["rust", "tokio"],
),
] {
let tags_json: Vec<serde_json::Value> = tags.iter().map(|t| json!(t)).collect();
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": slug,
"text": text,
"room": "General",
"tags": tags_json,
}),
)
.await
.expect("memory_remember");
}
let payload = json!({
"hook_event_name": "UserPromptSubmit",
"cwd": project_dir.to_string_lossy(),
"prompt": "how does rust integration work?"
})
.to_string();
let body = build_injection_body(&payload).await;
assert!(
body.contains("tokio") && body.contains("serde"),
"signal drawer must survive deny filter; got:\n{body}"
);
assert!(
!body.contains("kafka producer"),
"claude-session-tagged drawer must be filtered out; got:\n{body}"
);
assert!(
!body.contains("captured prompt fragment"),
"user-prompt-tagged drawer must be filtered out; got:\n{body}"
);
addr_handle.shutdown().await;
}
#[cfg(feature = "axum-server")]
#[tokio::test]
async fn prompt_context_recall_env_override_extends_deny_list() {
let _guard = crate::commands::env_test_lock().lock().await;
unsafe {
std::env::set_var(ENV_RECALL_DENY_TAGS, "noise-tag");
}
let (state, _data_dir_tmp, _project_dir_tmp, project_dir, slug, addr_handle) =
spin_up_test_daemon_with_palace("prompt-ctx-env-deny").await;
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": slug,
"text": "Rust integration uses tokio and serde for the async layer",
"room": "General",
"tags": ["noise-tag", "rust"],
}),
)
.await
.expect("memory_remember");
let payload = json!({
"hook_event_name": "UserPromptSubmit",
"cwd": project_dir.to_string_lossy(),
"prompt": "how does rust integration work?"
})
.to_string();
let body = build_injection_body(&payload).await;
assert!(
!body.contains("tokio and serde"),
"noise-tag drawer must be filtered when env override targets it; got:\n{body}"
);
unsafe {
std::env::remove_var(ENV_RECALL_DENY_TAGS);
}
addr_handle.shutdown().await;
}
#[cfg(feature = "axum-server")]
#[tokio::test]
async fn prompt_context_recall_all_filtered_falls_back_to_global() {
let _guard = crate::commands::env_test_lock().lock().await;
unsafe {
std::env::remove_var(ENV_RECALL_DENY_TAGS);
}
let (state, _data_dir_tmp, _project_dir_tmp, project_dir, slug, addr_handle) =
spin_up_test_daemon_with_palace("prompt-ctx-all-filtered").await;
for (text, tags) in [
(
"user: status update on the rust async rewrite, the kafka consumer should not surface in any prompt-context injection",
vec!["claude-session", "user-prompt", "rust"],
),
(
"user: yes please continue with the rust refactor on the producer side, this prompt fragment must be filtered out of recall",
vec!["claude-session", "rust"],
),
] {
let tags_json: Vec<serde_json::Value> = tags.iter().map(|t| json!(t)).collect();
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": slug,
"text": text,
"room": "General",
"tags": tags_json,
}),
)
.await
.expect("memory_remember");
}
let payload = json!({
"hook_event_name": "UserPromptSubmit",
"cwd": project_dir.to_string_lossy(),
"prompt": "tell me about rust"
})
.to_string();
let body = build_injection_body(&payload).await;
assert!(
!body.contains("kafka consumer") && !body.contains("producer side"),
"filtered drawer content must not leak; got:\n{body}"
);
assert!(
!body.contains("Relevant memories"),
"no `Relevant memories` section should render when every drawer is filtered; got:\n{body}"
);
addr_handle.shutdown().await;
}
#[tokio::test]
async fn prompt_context_logs_attempt_without_daemon() {
let _guard = crate::commands::env_test_lock().lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
unsafe {
std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
std::env::remove_var(crate::prompt_log::ENV_ENABLED);
std::env::remove_var(crate::prompt_log::ENV_DIR);
std::env::remove_var(crate::prompt_log::ENV_HASH_PROMPTS);
}
let res = handle_prompt_context().await;
let logs_dir = trusty_common::resolve_data_dir("trusty-memory")
.expect("resolve data dir")
.join("logs");
unsafe {
std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
}
assert!(res.is_ok());
let files: Vec<_> = std::fs::read_dir(&logs_dir)
.expect("logs dir should be created")
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("enriched-prompts."))
})
.collect();
assert_eq!(
files.len(),
1,
"expected one enriched-prompts log file, got {files:?}"
);
let content = std::fs::read_to_string(&files[0]).expect("read log");
let line = content.lines().next().expect("at least one line");
let parsed: crate::prompt_log::PromptLogEntry =
serde_json::from_str(line).expect("parse JSONL");
assert_eq!(parsed.hook_type, "UserPromptSubmit");
assert_eq!(parsed.injection_kind, "prompt-context-facts");
}