#![allow(clippy::unwrap_used, clippy::expect_used)]
#![allow(unsafe_code)]
use std::collections::BTreeMap;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use difflore_cli::hook_runtime;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use sqlx::sqlite::SqliteConnectOptions;
#[derive(Serialize, Deserialize)]
struct CacheFile {
version: u32,
entries: BTreeMap<String, CacheEntry>,
}
#[derive(Serialize, Deserialize)]
struct CacheEntry {
ts_ms: i64,
rules_injected: usize,
}
fn seed_skip_cache(home: &Path, file_path: &str) {
let project_root = difflore_core::db::current_project_root();
let project_hash = difflore_core::db::project_hash_from_root(&project_root);
let normalized = file_path.trim().replace('\\', "/");
let key = format!("{project_hash}:post-edit:{normalized}");
let ts_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64);
let mut entries = BTreeMap::new();
entries.insert(
key,
CacheEntry {
ts_ms,
rules_injected: 3,
},
);
let cache = CacheFile {
version: 1,
entries,
};
std::fs::create_dir_all(home).expect("create home");
let payload = serde_json::to_string_pretty(&cache).expect("serialize cache");
std::fs::write(home.join("hook-cache.json"), payload).expect("write hook-cache");
}
fn post_tool_use_payload(file_path: &str) -> String {
serde_json::json!({
"session_id": "test-session",
"cwd": ".",
"hook_event_name": "PostToolUse",
"tool_name": "Edit",
"tool_input": {
"file_path": file_path,
"old_string": "let x = 1;\n",
"new_string": "let x = 2;\n"
},
"tool_response": {}
})
.to_string()
}
async fn count_cloud_outbox_rows(data_db: &Path) -> i64 {
if !data_db.exists() {
return 0;
}
let opts = SqliteConnectOptions::new()
.filename(data_db)
.create_if_missing(false);
let pool: SqlitePool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect_with(opts)
.await
.expect("open data.db");
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM cloud_outbox")
.fetch_one(&pool)
.await
.unwrap_or(0);
pool.close().await;
count
}
#[tokio::test]
async fn post_tool_use_dedup_skip_short_circuits_before_db_and_outbox() {
let home = tempfile::tempdir().expect("temp DIFFLORE_HOME");
unsafe {
std::env::set_var("DIFFLORE_HOME", home.path());
}
unsafe {
std::env::remove_var("DIFFLORE_CAPTURE");
}
let skip_file = "src/example_skip.rs";
seed_skip_cache(home.path(), skip_file);
let raw = post_tool_use_payload(skip_file);
let out = hook_runtime::output_for_raw("claude-code", &raw, false)
.await
.expect("hook output");
let json: serde_json::Value = serde_json::from_str(&out).expect("parseable output");
assert_eq!(
json.get("continue").and_then(serde_json::Value::as_bool),
Some(true),
"skip path must return a continue:true noop, got: {json}"
);
let data_db = home.path().join("data.db");
let obs_db = home.path().join("observations_outbox.db");
assert!(
!data_db.exists(),
"skip path must not init data.db (was {})",
data_db.display()
);
assert!(
!obs_db.exists(),
"skip path must not open observations_outbox.db (was {})",
obs_db.display()
);
let rows_after_skip = count_cloud_outbox_rows(&data_db).await;
assert_eq!(
rows_after_skip, 0,
"skip path must not enqueue any cloud_outbox row, found {rows_after_skip}"
);
let proceed_file = "src/example_proceed.rs";
let raw = post_tool_use_payload(proceed_file);
let out = hook_runtime::output_for_raw("claude-code", &raw, false)
.await
.expect("hook output");
let _: serde_json::Value = serde_json::from_str(&out).expect("parseable output");
assert!(
data_db.exists(),
"non-skip path must init data.db (was {})",
data_db.display()
);
let rows_after_proceed = count_cloud_outbox_rows(&data_db).await;
assert!(
rows_after_proceed >= 1,
"non-skip path must enqueue at least one cloud_outbox row, found {rows_after_proceed}"
);
unsafe {
std::env::remove_var("DIFFLORE_HOME");
}
drop(home);
}