use std::io::Read;
use std::path::Path;
use std::process::ExitCode;
use std::time::Duration;
use crate::config::Config;
use crate::errors::Error;
use crate::hook::embedding::placeholder_embedding;
use crate::hook::extract::extract_candidate;
use crate::hook::payload::{HookEvent, HookPayload, parse_hook_payload};
use crate::project::detect_project_at;
use crate::sqlite::Database;
use crate::sqlite::hash::content_hash;
const HOOK_DB_BUSY_TIMEOUT: Duration = Duration::from_secs(2);
pub fn run_hook_event(
config: &Config,
event: HookEvent,
stdin_input: &str,
) -> Result<ExitCode, Error> {
let payload: HookPayload = match parse_hook_payload(event, stdin_input) {
Some(p) => p,
None => return Ok(ExitCode::SUCCESS),
};
let Some(cwd) = &payload.cwd else {
return Ok(ExitCode::SUCCESS);
};
let project_id = detect_project_at(cwd, None);
let Some(candidate) = extract_candidate(&payload.candidate_source) else {
return Ok(ExitCode::SUCCESS);
};
let db = match open_hook_db(&config.database_path) {
Some(db) => db,
None => return Ok(ExitCode::SUCCESS),
};
let hash = content_hash(&candidate);
let embedding = placeholder_embedding(&candidate);
let result = db.insert_with_hash(
&project_id,
&candidate,
&embedding,
None,
"observation",
"candidate",
&hash,
);
match result {
Ok(_) => Ok(ExitCode::SUCCESS),
Err(e) if is_unique_constraint_violation(&e) => {
Ok(ExitCode::SUCCESS)
}
Err(_) => {
Ok(ExitCode::SUCCESS)
}
}
}
fn open_hook_db(db_path: &Path) -> Option<Database> {
let db = Database::open(db_path).ok()?;
db.set_busy_timeout(HOOK_DB_BUSY_TIMEOUT).ok()?;
Some(db)
}
fn is_unique_constraint_violation(e: &crate::sqlite::Error) -> bool {
if let crate::sqlite::Error::Sqlite(msg) = e {
msg.contains("UNIQUE constraint failed")
} else {
false
}
}
pub fn read_stdin() -> String {
let mut input = String::new();
if std::io::stdin().read_to_string(&mut input).is_err() {
String::new()
} else {
input
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hook::payload::HookEvent;
use crate::sqlite::Database;
use std::sync::Mutex;
static DB_MUTEX: Mutex<()> = Mutex::new(());
fn make_config_with_tmp_db() -> (Config, tempfile::TempDir, std::path::PathBuf) {
let _guard = DB_MUTEX.lock().unwrap();
let dir = tempfile::TempDir::new().expect("temp dir");
let db_path = dir.path().join(format!("hook_{}.db", uuid::Uuid::new_v4()));
let config = Config {
database_path: db_path.clone(),
..Default::default()
};
(config, dir, db_path)
}
fn make_git_repo(tmp: &std::path::Path, name: &str, remote_url: &str) -> std::path::PathBuf {
let repo_path = tmp.join(name);
std::fs::create_dir_all(&repo_path).unwrap();
let git = |args: &[&str]| -> std::process::Output {
std::process::Command::new("git")
.args(args)
.current_dir(&repo_path)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.expect("git ran")
};
let init = git(&["init", "-q"]);
assert!(init.status.success(), "git init failed: {:?}", init);
let _ = git(&["remote", "add", "origin", remote_url]);
let _ = git(&["commit", "-q", "--allow-empty", "-m", "init"]);
repo_path
}
#[test]
fn user_prompt_submit_extracts_prompt() {
let (config, tmp, db_path) = make_config_with_tmp_db();
let repo = make_git_repo(tmp.path(), "repo-a", "git@github.com:owner/repo-a.git");
let payload = format!(
r#"{{"prompt": "use cargo fmt before committing", "cwd": "{}", "session_id": "s1"}}"#,
repo.display()
);
let exit = run_hook_event(&config, HookEvent::UserPromptSubmit, &payload)
.expect("hook should succeed");
assert_eq!(exit, ExitCode::SUCCESS);
let db = Database::open(&db_path).expect("open db");
let count: i64 = db
.conn()
.query_row(
"SELECT COUNT(*) FROM memories WHERE project_id = 'owner/repo-a'",
[],
|r| r.get(0),
)
.expect("count query");
assert_eq!(count, 1, "one row should be inserted for one candidate");
}
#[test]
fn session_start_is_no_op() {
let (config, _tmp, db_path) = make_config_with_tmp_db();
let payload = r#"{"session_id": "s1", "cwd": "/tmp"}"#;
let exit = run_hook_event(&config, HookEvent::SessionStart, payload).expect("ok");
assert_eq!(exit, ExitCode::SUCCESS);
let db = Database::open(&db_path).expect("open db");
let count: i64 = db
.conn()
.query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
.expect("count");
assert_eq!(count, 0, "SessionStart must not insert any row");
}
#[test]
fn jwt_payload_produces_zero_inserts() {
let (config, tmp, db_path) = make_config_with_tmp_db();
let repo = make_git_repo(tmp.path(), "repo-jwt", "git@github.com:owner/repo-jwt.git");
let jwt =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozj9NabvVH5hU0R3qTc";
let payload = format!(
r#"{{"prompt": "here is my token {jwt} please save it", "cwd": "{}"}}"#,
repo.display()
);
let exit = run_hook_event(&config, HookEvent::UserPromptSubmit, &payload).expect("ok");
assert_eq!(exit, ExitCode::SUCCESS);
let db = Database::open(&db_path).expect("open db");
let count: i64 = db
.conn()
.query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
.expect("count");
assert_eq!(count, 0, "JWT-bearing payload must produce zero inserts");
}
#[test]
fn malformed_stdin_exits_zero() {
let (config, _tmp, _db_path) = make_config_with_tmp_db();
let exit = run_hook_event(
&config,
HookEvent::UserPromptSubmit,
"not valid json at all",
)
.expect("malformed stdin must exit 0");
assert_eq!(exit, ExitCode::SUCCESS);
}
#[test]
fn empty_prompt_exits_zero() {
let (config, tmp, db_path) = make_config_with_tmp_db();
let repo = make_git_repo(
tmp.path(),
"repo-empty",
"git@github.com:owner/repo-empty.git",
);
let payload = format!(r#"{{"prompt": " ", "cwd": "{}"}}"#, repo.display());
let exit = run_hook_event(&config, HookEvent::UserPromptSubmit, &payload).expect("ok");
assert_eq!(exit, ExitCode::SUCCESS);
let db = Database::open(&db_path).expect("open db");
let count: i64 = db
.conn()
.query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
.expect("count");
assert_eq!(count, 0, "empty/whitespace-only prompt must not insert");
}
#[test]
fn dedup_skips_second_identical_insert() {
let (config, tmp, db_path) = make_config_with_tmp_db();
let repo = make_git_repo(
tmp.path(),
"repo-dedup",
"git@github.com:owner/repo-dedup.git",
);
let payload = format!(
r#"{{"prompt": "always use rustfmt before committing", "cwd": "{}"}}"#,
repo.display()
);
let e1 = run_hook_event(&config, HookEvent::UserPromptSubmit, &payload).expect("ok");
let e2 = run_hook_event(&config, HookEvent::UserPromptSubmit, &payload).expect("ok");
assert_eq!(e1, ExitCode::SUCCESS);
assert_eq!(e2, ExitCode::SUCCESS);
let db = Database::open(&db_path).expect("open db");
let count: i64 = db
.conn()
.query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
.expect("count");
assert_eq!(count, 1, "identical content twice must yield one row");
}
#[test]
fn project_scoping_uses_payload_cwd_not_process_cwd() {
let (config, tmp, db_path) = make_config_with_tmp_db();
let repo_a = make_git_repo(tmp.path(), "repo-a", "git@github.com:owner/repo-a.git");
let repo_b = make_git_repo(tmp.path(), "repo-b", "git@github.com:owner/repo-b.git");
let payload_a = format!(
r#"{{"prompt": "candidate from repo A", "cwd": "{}"}}"#,
repo_a.display()
);
let payload_b = format!(
r#"{{"prompt": "candidate from repo B", "cwd": "{}"}}"#,
repo_b.display()
);
let _ = run_hook_event(&config, HookEvent::UserPromptSubmit, &payload_a).expect("ok");
let _ = run_hook_event(&config, HookEvent::UserPromptSubmit, &payload_b).expect("ok");
let db = Database::open(&db_path).expect("open db");
let count_a: i64 = db
.conn()
.query_row(
"SELECT COUNT(*) FROM memories WHERE project_id = 'owner/repo-a'",
[],
|r| r.get(0),
)
.expect("count A");
let count_b: i64 = db
.conn()
.query_row(
"SELECT COUNT(*) FROM memories WHERE project_id = 'owner/repo-b'",
[],
|r| r.get(0),
)
.expect("count B");
assert_eq!(count_a, 1, "payload A should land in repo-a project");
assert_eq!(count_b, 1, "payload B should land in repo-b project");
}
#[test]
fn pre_tool_use_extracts_tool_input_object() {
let (config, tmp, db_path) = make_config_with_tmp_db();
let repo = make_git_repo(tmp.path(), "repo-pre", "git@github.com:owner/repo-pre.git");
let payload = format!(
r#"{{"tool_name": "Bash", "tool_input": {{"command": "git status"}}, "cwd": "{}"}}"#,
repo.display()
);
let exit = run_hook_event(&config, HookEvent::PreToolUse, &payload).expect("ok");
assert_eq!(exit, ExitCode::SUCCESS);
let db = Database::open(&db_path).expect("open db");
let count: i64 = db
.conn()
.query_row(
"SELECT COUNT(*) FROM memories WHERE project_id = 'owner/repo-pre'",
[],
|r| r.get(0),
)
.expect("count");
assert_eq!(
count, 1,
"PreToolUse with tool_input object should insert one row"
);
}
}