use crate::cli::convert::derive_palace_name;
use crate::cli::memory::open_or_create_handle;
use crate::cli::output::OutputConfig;
use anyhow::{anyhow, Context, Result};
use clap::{Args, Subcommand};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use trusty_memory_core::retrieval::{recall_with_default_embedder, RecallResult};
use trusty_memory_core::RoomType;
#[derive(Args, Debug, Clone)]
pub struct HooksArgs {
#[command(subcommand)]
pub command: HooksSubcommand,
}
#[derive(Subcommand, Debug, Clone)]
pub enum HooksSubcommand {
Install(HooksInstallArgs),
List,
Fire(HooksFireArgs),
Status,
}
#[derive(Args, Debug, Clone)]
pub struct HooksInstallArgs {
#[arg(long)]
pub git: bool,
#[arg(long)]
pub claude_code: bool,
#[arg(long)]
pub all: bool,
}
#[derive(Args, Debug, Clone)]
pub struct HooksFireArgs {
pub event: String,
#[arg(long)]
pub palace: Option<String>,
}
pub async fn handle(args: HooksArgs, palace: &str, out: &OutputConfig) -> Result<()> {
match args.command {
HooksSubcommand::Install(opts) => handle_install(opts, out),
HooksSubcommand::List => handle_list(out),
HooksSubcommand::Fire(opts) => handle_fire(opts, palace).await,
HooksSubcommand::Status => handle_status(palace, out).await,
}
}
fn handle_install(opts: HooksInstallArgs, out: &OutputConfig) -> Result<()> {
let want_git = opts.all || opts.git;
let want_claude = opts.all || opts.claude_code;
if !want_git && !want_claude {
return Err(anyhow!(
"no hook target specified — pass --git, --claude-code, or --all"
));
}
if want_git {
install_git_hook()?;
}
if want_claude {
install_claude_hooks()?;
}
out.print_success("hooks installed");
Ok(())
}
pub fn git_hook_script_content() -> &'static str {
"#!/bin/sh\ntrusty-memory hooks fire git.post-commit\n"
}
fn install_git_hook() -> Result<()> {
let cwd = std::env::current_dir().context("get current directory")?;
let git_dir = cwd.join(".git");
if !git_dir.is_dir() {
return Err(anyhow!(
"not a git repository: {} has no .git directory",
cwd.display()
));
}
let hooks_dir = git_dir.join("hooks");
std::fs::create_dir_all(&hooks_dir).context("create .git/hooks directory")?;
let hook_path = hooks_dir.join("post-commit");
std::fs::write(&hook_path, git_hook_script_content())
.with_context(|| format!("write {}", hook_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&hook_path)
.context("read hook permissions")?
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms).context("chmod +x post-commit")?;
}
eprintln!("✓ Installed git post-commit hook → {}", hook_path.display());
Ok(())
}
fn claude_settings_path() -> Result<PathBuf> {
let home = dirs::home_dir().ok_or_else(|| anyhow!("could not resolve home directory"))?;
Ok(home.join(".claude").join("settings.json"))
}
fn install_claude_hooks() -> Result<()> {
let path = claude_settings_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).context("create ~/.claude directory")?;
}
let existing: Value = if path.exists() {
let backup = path.with_extension("json.bak");
std::fs::copy(&path, &backup)
.with_context(|| format!("backup {} to {}", path.display(), backup.display()))?;
let raw =
std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
if raw.trim().is_empty() {
json!({})
} else {
serde_json::from_str(&raw)
.with_context(|| format!("parse JSON in {}", path.display()))?
}
} else {
json!({})
};
let hooks_to_add = trusty_hook_entries();
let merged = merge_claude_settings(&existing, &hooks_to_add);
let pretty = serde_json::to_string_pretty(&merged).context("serialize merged settings")?;
std::fs::write(&path, pretty).with_context(|| format!("write {}", path.display()))?;
eprintln!("✓ Installed Claude Code hooks → {}", path.display());
Ok(())
}
fn trusty_hook_entries() -> Value {
json!({
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{"type": "command", "command": "trusty-memory hooks fire claude.stop"}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit|Bash",
"hooks": [
{"type": "command", "command": "trusty-memory hooks fire claude.post-tool-use"}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{"type": "command", "command": "trusty-memory hooks fire claude.user-prompt"}
]
}
]
}
})
}
pub fn merge_claude_settings(existing: &Value, additions: &Value) -> Value {
let mut merged = existing.clone();
if !merged.is_object() {
merged = json!({});
}
let merged_obj = merged.as_object_mut().expect("ensured object above");
let hooks_entry = merged_obj
.entry("hooks".to_string())
.or_insert_with(|| json!({}));
if !hooks_entry.is_object() {
*hooks_entry = json!({});
}
let hooks_obj = hooks_entry.as_object_mut().expect("ensured object above");
let Some(add_hooks) = additions.get("hooks").and_then(|h| h.as_object()) else {
return merged;
};
for (event, new_arr) in add_hooks {
let Some(new_entries) = new_arr.as_array() else {
continue;
};
let target = hooks_obj.entry(event.clone()).or_insert_with(|| json!([]));
if !target.is_array() {
*target = json!([]);
}
let target_arr = target.as_array_mut().expect("ensured array above");
for entry in new_entries {
if !target_arr
.iter()
.any(|existing_entry| contains_command(existing_entry, entry))
{
target_arr.push(entry.clone());
}
}
}
merged
}
fn contains_command(existing: &Value, candidate: &Value) -> bool {
let cand_cmds: Vec<&str> = candidate
.get("hooks")
.and_then(|h| h.as_array())
.map(|arr| {
arr.iter()
.filter_map(|e| e.get("command").and_then(|c| c.as_str()))
.collect()
})
.unwrap_or_default();
if cand_cmds.is_empty() {
return false;
}
let existing_cmds: Vec<&str> = existing
.get("hooks")
.and_then(|h| h.as_array())
.map(|arr| {
arr.iter()
.filter_map(|e| e.get("command").and_then(|c| c.as_str()))
.collect()
})
.unwrap_or_default();
cand_cmds.iter().all(|c| existing_cmds.contains(c))
}
fn handle_list(_out: &OutputConfig) -> Result<()> {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let git_hook_path = cwd.join(".git").join("hooks").join("post-commit");
let git_installed = git_hook_path.exists()
&& std::fs::read_to_string(&git_hook_path)
.map(|c| c.contains("trusty-memory"))
.unwrap_or(false);
println!(
"git post-commit ({}): {}",
git_hook_path.display(),
if git_installed {
"installed"
} else {
"not installed"
}
);
let claude_path = claude_settings_path()?;
let claude_installed = if claude_path.exists() {
std::fs::read_to_string(&claude_path)
.ok()
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
.map(|v| settings_has_trusty_hook(&v))
.unwrap_or(false)
} else {
false
};
println!(
"claude code ({}): {}",
claude_path.display(),
if claude_installed {
"installed"
} else {
"not installed"
}
);
Ok(())
}
fn settings_has_trusty_hook(settings: &Value) -> bool {
let Some(hooks) = settings.get("hooks").and_then(|h| h.as_object()) else {
return false;
};
for arr in hooks.values() {
let Some(arr) = arr.as_array() else { continue };
for entry in arr {
let Some(inner) = entry.get("hooks").and_then(|h| h.as_array()) else {
continue;
};
for cmd in inner {
if let Some(s) = cmd.get("command").and_then(|c| c.as_str()) {
if s.contains("trusty-memory hooks fire") {
return true;
}
}
}
}
}
false
}
async fn handle_fire(opts: HooksFireArgs, palace_arg: &str) -> Result<()> {
let palace_name = match opts.palace.as_deref() {
Some(p) => p.to_string(),
None => {
if !palace_arg.is_empty() {
palace_arg.to_string()
} else {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
derive_palace_name(&cwd)
}
}
};
match opts.event.as_str() {
"git.post-commit" => fire_git_post_commit(&palace_name).await,
"claude.stop" => fire_claude_stop(&palace_name).await,
"claude.post-tool-use" => fire_claude_post_tool_use(&palace_name).await,
"claude.user-prompt" => fire_claude_user_prompt(&palace_name).await,
other => Err(anyhow!(
"unknown hook event: {other} (expected git.post-commit | claude.stop | claude.post-tool-use | claude.user-prompt)"
)),
}
}
async fn fire_git_post_commit(palace: &str) -> Result<()> {
let cwd = std::env::current_dir().context("get current directory")?;
let toplevel = git_toplevel(&cwd).unwrap_or(cwd);
let info = match read_last_commit(&toplevel) {
Ok(info) => info,
Err(e) => {
eprintln!("trusty-memory: skipping commit ingest ({e:#})");
return Ok(());
}
};
let short = info.hash.chars().take(7).collect::<String>();
let tag_commit = format!("commit:{short}");
let handle = open_or_create_handle(palace).await?;
let existing = handle.list_drawers(None, Some(tag_commit.clone()), 1);
if !existing.is_empty() {
eprintln!("trusty-memory: commit {short} already stored, skipping");
return Ok(());
}
let diff_stat = read_diff_stat(&toplevel).unwrap_or_default();
let content = format_commit_content(&info.subject, &info.body, &diff_stat);
let tags = vec![
"source:git".to_string(),
"event:commit".to_string(),
tag_commit,
];
handle
.remember(content, RoomType::General, tags, 0.6)
.await
.context("remember git commit")?;
eprintln!("✓ Stored commit {short} → palace '{palace}'");
Ok(())
}
struct CommitInfo {
hash: String,
subject: String,
body: String,
}
fn git_toplevel(cwd: &Path) -> Result<PathBuf> {
let out = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(cwd)
.output()
.context("run git rev-parse")?;
if !out.status.success() {
return Err(anyhow!(
"git rev-parse failed: {}",
String::from_utf8_lossy(&out.stderr)
));
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
return Err(anyhow!("not a git repository"));
}
Ok(PathBuf::from(s))
}
fn read_last_commit(repo: &Path) -> Result<CommitInfo> {
let out = Command::new("git")
.args(["log", "-1", "--format=%H%n%s%n%b"])
.current_dir(repo)
.output()
.context("run git log")?;
if !out.status.success() {
return Err(anyhow!(
"git log failed: {}",
String::from_utf8_lossy(&out.stderr)
));
}
let raw = String::from_utf8_lossy(&out.stdout).to_string();
let mut lines = raw.lines();
let hash = lines.next().unwrap_or_default().to_string();
let subject = lines.next().unwrap_or_default().to_string();
let body = lines.collect::<Vec<_>>().join("\n").trim().to_string();
if hash.is_empty() {
return Err(anyhow!("empty git log output"));
}
Ok(CommitInfo {
hash,
subject,
body,
})
}
fn read_diff_stat(repo: &Path) -> Result<String> {
let out = Command::new("git")
.args(["diff", "HEAD~1", "HEAD", "--stat"])
.current_dir(repo)
.output();
if let Ok(o) = out {
if o.status.success() {
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
if !s.is_empty() {
return Ok(s);
}
}
}
let fallback = Command::new("git")
.args(["show", "--stat", "--format=", "HEAD"])
.current_dir(repo)
.output()
.context("git show --stat fallback")?;
if !fallback.status.success() {
return Err(anyhow!("git show --stat failed"));
}
Ok(String::from_utf8_lossy(&fallback.stdout).trim().to_string())
}
pub fn format_commit_content(subject: &str, body: &str, diff_stat: &str) -> String {
let mut out = format!("git commit: {subject}");
if !body.trim().is_empty() {
out.push_str("\n\n");
out.push_str(body.trim());
}
if !diff_stat.trim().is_empty() {
out.push_str("\n\nChanges: ");
out.push_str(diff_stat.trim());
}
out
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct ClaudeStopPayload {
pub session_id: Option<String>,
pub transcript_path: Option<String>,
}
pub fn parse_claude_stop_payload(json_str: &str) -> ClaudeStopPayload {
let v: Value = match serde_json::from_str(json_str) {
Ok(v) => v,
Err(_) => return ClaudeStopPayload::default(),
};
ClaudeStopPayload {
session_id: v
.get("session_id")
.and_then(|x| x.as_str())
.map(|s| s.to_string()),
transcript_path: v
.get("transcript_path")
.and_then(|x| x.as_str())
.map(|s| s.to_string()),
}
}
async fn fire_claude_stop(palace: &str) -> Result<()> {
let payload_str = read_stdin_with_timeout(Duration::from_millis(100)).await;
let payload = parse_claude_stop_payload(&payload_str);
let session_id = payload
.session_id
.clone()
.unwrap_or_else(|| "unknown".to_string());
let tail = payload
.transcript_path
.as_deref()
.and_then(|p| read_tail(Path::new(p), 2000).ok())
.unwrap_or_default();
let mut content = format!("Claude Code session ended: {session_id}");
if !tail.is_empty() {
content.push_str("\n\nSummary:\n");
content.push_str(&tail);
}
let handle = open_or_create_handle(palace).await?;
let tags = vec!["source:claude".to_string(), "event:stop".to_string()];
handle
.remember(content, RoomType::General, tags, 0.5)
.await
.context("remember claude stop")?;
eprintln!("✓ Stored claude.stop → palace '{palace}'");
Ok(())
}
fn read_tail(path: &Path, n: usize) -> Result<String> {
let raw = std::fs::read_to_string(path).context("read transcript")?;
if raw.len() <= n {
return Ok(raw);
}
let start = raw.len().saturating_sub(n);
let mut idx = start;
while idx < raw.len() && !raw.is_char_boundary(idx) {
idx += 1;
}
Ok(raw[idx..].to_string())
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct PostToolUsePayload {
pub tool_name: Option<String>,
pub file_path: Option<String>,
}
pub fn parse_post_tool_use_payload(json_str: &str) -> PostToolUsePayload {
let v: Value = match serde_json::from_str(json_str) {
Ok(v) => v,
Err(_) => return PostToolUsePayload::default(),
};
PostToolUsePayload {
tool_name: v
.get("tool_name")
.and_then(|x| x.as_str())
.map(|s| s.to_string()),
file_path: v
.get("tool_input")
.and_then(|i| i.get("file_path"))
.and_then(|x| x.as_str())
.map(|s| s.to_string()),
}
}
fn room_for_tool(tool: &str) -> RoomType {
match tool {
"Write" | "Edit" => RoomType::Backend,
_ => RoomType::General,
}
}
async fn fire_claude_post_tool_use(palace: &str) -> Result<()> {
let payload_str = read_stdin_with_timeout(Duration::from_millis(100)).await;
let payload = parse_post_tool_use_payload(&payload_str);
let tool_name = payload
.tool_name
.clone()
.unwrap_or_else(|| "unknown".to_string());
let file_path = payload.file_path.clone().unwrap_or_default();
let mut content = format!("Tool use: {tool_name}");
if !file_path.is_empty() {
content.push_str(&format!("\nFile: {file_path}"));
}
let tag_tool = format!("tool:{tool_name}");
let tags = vec![
"source:claude".to_string(),
"event:post-tool-use".to_string(),
tag_tool.clone(),
];
let handle = open_or_create_handle(palace).await?;
let recent = handle.list_drawers(None, Some(tag_tool), 5);
let now = chrono::Utc::now();
let too_recent = recent
.iter()
.any(|d| (now - d.created_at).num_seconds() < 10);
if too_recent {
return Ok(());
}
let room = room_for_tool(&tool_name);
handle
.remember(content, room, tags, 0.3)
.await
.context("remember claude post-tool-use")?;
Ok(())
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct UserPromptPayload {
pub prompt: Option<String>,
}
pub fn parse_user_prompt_payload(json_str: &str) -> UserPromptPayload {
let trimmed = json_str.trim();
if trimmed.is_empty() {
return UserPromptPayload::default();
}
if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
return UserPromptPayload {
prompt: v
.get("prompt")
.and_then(|x| x.as_str())
.map(|s| s.to_string()),
};
}
UserPromptPayload {
prompt: Some(trimmed.to_string()),
}
}
pub fn format_recall_context(results: &[RecallResult]) -> String {
if results.is_empty() {
return String::new();
}
let mut out = String::from("Relevant memories from trusty-memory:\n");
const PREVIEW_BYTE_LIMIT: usize = 400;
for r in results {
let preview_len = if r.drawer.content.len() <= PREVIEW_BYTE_LIMIT {
r.drawer.content.len()
} else {
r.drawer.content.floor_char_boundary(PREVIEW_BYTE_LIMIT)
};
let mut preview = r.drawer.content[..preview_len].to_string();
if r.drawer.content.len() > preview_len {
preview.push('…');
}
preview = preview.replace('\n', " ");
out.push_str(&format!("- (L{}, {:.2}) {}\n", r.layer, r.score, preview));
}
out
}
async fn fire_claude_user_prompt(palace: &str) -> Result<()> {
let payload_str = read_stdin_with_timeout(Duration::from_millis(200)).await;
let payload = parse_user_prompt_payload(&payload_str);
let Some(prompt) = payload.prompt.filter(|s| !s.trim().is_empty()) else {
return Ok(());
};
let handle = match open_or_create_handle(palace).await {
Ok(h) => h,
Err(_) => return Ok(()),
};
let results = match recall_with_default_embedder(&handle, &prompt, 5).await {
Ok(r) => r,
Err(_) => return Ok(()),
};
let context = format_recall_context(&results);
if context.is_empty() {
return Ok(());
}
let envelope = json!({ "context": context });
println!("{envelope}");
Ok(())
}
async fn read_stdin_with_timeout(timeout: Duration) -> String {
let read_fut = tokio::task::spawn_blocking(|| {
use std::io::Read;
let mut buf = String::new();
let _ = std::io::stdin().read_to_string(&mut buf);
buf
});
match tokio::time::timeout(timeout, read_fut).await {
Ok(Ok(s)) => s,
_ => String::new(),
}
}
async fn handle_status(palace: &str, _out: &OutputConfig) -> Result<()> {
let handle = open_or_create_handle(palace).await?;
let mut combined = handle.list_drawers(None, Some("source:git".to_string()), 50);
combined.extend(handle.list_drawers(None, Some("source:claude".to_string()), 50));
combined.sort_by_key(|b| std::cmp::Reverse(b.created_at));
combined.truncate(10);
if combined.is_empty() {
println!("no hook-sourced drawers in palace '{palace}'");
return Ok(());
}
println!("recent hook-sourced drawers in '{palace}':");
for d in combined {
let preview_len = d.content.len().min(40);
let mut preview = d.content[..preview_len].to_string();
preview = preview.replace('\n', " ");
println!(
" {} {} {}",
d.created_at.format("%Y-%m-%d %H:%M"),
d.tags.join(","),
preview
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn git_hook_script_content_shape() {
let s = git_hook_script_content();
assert!(s.starts_with("#!/bin/sh"));
assert!(s.contains("trusty-memory hooks fire git.post-commit"));
}
#[test]
fn format_commit_content_full() {
let out = format_commit_content(
"feat: add hooks",
"Implements issue #25.",
" src/main.rs | 2 +-\n 1 file changed",
);
assert!(out.starts_with("git commit: feat: add hooks"));
assert!(out.contains("Implements issue #25."));
assert!(out.contains("Changes:"));
assert!(out.contains("src/main.rs"));
}
#[test]
fn format_commit_content_subject_only() {
let out = format_commit_content("fix: bug", "", "");
assert_eq!(out, "git commit: fix: bug");
}
#[test]
fn format_commit_content_subject_and_stat_no_body() {
let out = format_commit_content("fix: bug", "", " a.rs | 1 +");
assert!(out.contains("git commit: fix: bug"));
assert!(out.contains("Changes: a.rs | 1 +"));
assert!(!out.contains("\n\n\n"));
}
#[test]
fn parse_claude_stop_payload_full() {
let json_str =
r#"{"session_id":"abc-123","transcript_path":"/tmp/t.jsonl","stop_hook_active":true}"#;
let p = parse_claude_stop_payload(json_str);
assert_eq!(p.session_id.as_deref(), Some("abc-123"));
assert_eq!(p.transcript_path.as_deref(), Some("/tmp/t.jsonl"));
}
#[test]
fn parse_claude_stop_payload_missing_fields() {
let p = parse_claude_stop_payload("{}");
assert!(p.session_id.is_none());
assert!(p.transcript_path.is_none());
}
#[test]
fn parse_claude_stop_payload_invalid_json() {
let p = parse_claude_stop_payload("not json");
assert_eq!(p, ClaudeStopPayload::default());
}
#[test]
fn parse_post_tool_use_payload_write() {
let json_str = r#"{"tool_name":"Write","tool_input":{"file_path":"/tmp/x.rs","content":"fn main(){}"},"tool_response":"ok"}"#;
let p = parse_post_tool_use_payload(json_str);
assert_eq!(p.tool_name.as_deref(), Some("Write"));
assert_eq!(p.file_path.as_deref(), Some("/tmp/x.rs"));
}
#[test]
fn parse_post_tool_use_payload_bash_no_file() {
let json_str = r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#;
let p = parse_post_tool_use_payload(json_str);
assert_eq!(p.tool_name.as_deref(), Some("Bash"));
assert!(p.file_path.is_none());
}
#[test]
fn parse_post_tool_use_payload_invalid_json() {
let p = parse_post_tool_use_payload("");
assert_eq!(p, PostToolUsePayload::default());
}
#[test]
fn merge_claude_settings_empty_existing() {
let merged = merge_claude_settings(&json!({}), &trusty_hook_entries());
assert!(merged
.get("hooks")
.and_then(|h| h.get("Stop"))
.and_then(|s| s.as_array())
.map(|a| !a.is_empty())
.unwrap_or(false));
assert!(merged
.get("hooks")
.and_then(|h| h.get("PostToolUse"))
.and_then(|s| s.as_array())
.map(|a| !a.is_empty())
.unwrap_or(false));
}
#[test]
fn merge_claude_settings_preserves_existing() {
let existing = json!({
"model": "sonnet",
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [{"type": "command", "command": "echo other"}]
}
]
}
});
let merged = merge_claude_settings(&existing, &trusty_hook_entries());
assert_eq!(merged.get("model").and_then(|v| v.as_str()), Some("sonnet"));
let stop = merged
.get("hooks")
.and_then(|h| h.get("Stop"))
.and_then(|s| s.as_array())
.expect("Stop array");
assert_eq!(stop.len(), 2, "must keep existing Stop entry and add ours");
let cmds: Vec<&str> = stop
.iter()
.filter_map(|e| e.get("hooks").and_then(|h| h.as_array()))
.flat_map(|a| a.iter())
.filter_map(|c| c.get("command").and_then(|c| c.as_str()))
.collect();
assert!(cmds
.iter()
.any(|s| s.contains("trusty-memory hooks fire claude.stop")));
assert!(cmds.contains(&"echo other"));
}
#[test]
fn merge_claude_settings_no_duplicate() {
let merged_once = merge_claude_settings(&json!({}), &trusty_hook_entries());
let merged_twice = merge_claude_settings(&merged_once, &trusty_hook_entries());
let stop = merged_twice
.get("hooks")
.and_then(|h| h.get("Stop"))
.and_then(|s| s.as_array())
.expect("Stop array");
assert_eq!(stop.len(), 1, "duplicate install must not double-add");
let post = merged_twice
.get("hooks")
.and_then(|h| h.get("PostToolUse"))
.and_then(|s| s.as_array())
.expect("PostToolUse array");
assert_eq!(post.len(), 1);
}
#[test]
fn settings_has_trusty_hook_detects_install() {
let v = trusty_hook_entries();
assert!(settings_has_trusty_hook(&v));
}
#[test]
fn settings_has_trusty_hook_negative() {
let v = json!({"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "other"}]}]}});
assert!(!settings_has_trusty_hook(&v));
}
#[test]
fn parse_user_prompt_payload_json() {
let p = parse_user_prompt_payload(r#"{"prompt":"how do I add a hook?"}"#);
assert_eq!(p.prompt.as_deref(), Some("how do I add a hook?"));
}
#[test]
fn parse_user_prompt_payload_missing_field() {
let p = parse_user_prompt_payload(r#"{"session_id":"abc"}"#);
assert!(p.prompt.is_none());
}
#[test]
fn parse_user_prompt_payload_raw_text_fallback() {
let p = parse_user_prompt_payload("plain text query");
assert_eq!(p.prompt.as_deref(), Some("plain text query"));
}
#[test]
fn parse_user_prompt_payload_empty() {
let p = parse_user_prompt_payload(" ");
assert_eq!(p, UserPromptPayload::default());
}
#[test]
fn format_recall_context_empty() {
assert_eq!(format_recall_context(&[]), "");
}
#[test]
fn format_recall_context_populated() {
use trusty_memory_core::Drawer;
use uuid::Uuid;
let now = chrono::Utc::now();
let drawer = Drawer {
id: Uuid::nil(),
room_id: Uuid::nil(),
content: "hook installation works via merge_claude_settings".to_string(),
importance: 0.5,
source_file: None,
created_at: now,
tags: vec![],
access_count: 0,
last_accessed_at: Some(now),
};
let results = vec![RecallResult {
drawer,
score: 0.82,
layer: 2,
}];
let out = format_recall_context(&results);
assert!(out.starts_with("Relevant memories"));
assert!(out.contains("L2"));
assert!(out.contains("0.82"));
assert!(out.contains("merge_claude_settings"));
}
#[test]
fn format_recall_context_multibyte() {
use trusty_memory_core::Drawer;
use uuid::Uuid;
let mut content = String::from("ab");
content.push_str(&"🎉".repeat(200));
assert!(content.len() > 400);
assert!(!content.is_char_boundary(400));
let now = chrono::Utc::now();
let drawer = Drawer {
id: Uuid::nil(),
room_id: Uuid::nil(),
content,
importance: 0.5,
source_file: None,
created_at: now,
tags: vec![],
access_count: 0,
last_accessed_at: Some(now),
};
let results = vec![RecallResult {
drawer,
score: 0.5,
layer: 2,
}];
let out = format_recall_context(&results);
assert!(std::str::from_utf8(out.as_bytes()).is_ok());
assert!(out.contains('…'));
assert!(out.starts_with("Relevant memories"));
}
#[test]
fn trusty_hook_entries_includes_user_prompt_submit() {
let v = trusty_hook_entries();
let arr = v
.get("hooks")
.and_then(|h| h.get("UserPromptSubmit"))
.and_then(|s| s.as_array())
.expect("UserPromptSubmit array");
let cmds: Vec<&str> = arr
.iter()
.filter_map(|e| e.get("hooks").and_then(|h| h.as_array()))
.flat_map(|a| a.iter())
.filter_map(|c| c.get("command").and_then(|c| c.as_str()))
.collect();
assert!(cmds
.iter()
.any(|s| s.contains("trusty-memory hooks fire claude.user-prompt")));
}
#[test]
fn room_for_tool_mapping() {
assert_eq!(room_for_tool("Write"), RoomType::Backend);
assert_eq!(room_for_tool("Edit"), RoomType::Backend);
assert_eq!(room_for_tool("Bash"), RoomType::General);
assert_eq!(room_for_tool("Unknown"), RoomType::General);
}
}