use super::{ClassificationKey, ContextManager, SupersedenceRules};
use crate::context::HookContext;
use crate::error::{HookError, HookResult};
use crate::hook::{Hook, ToolCallDecision};
use crate::message::Message;
use crate::tool::ToolDefinition;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
pub const FILE_CONTEXT_KEY: &str = "file_context_manager";
static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Clone)]
pub struct FileContextHook {
manager: Arc<RwLock<ContextManager>>,
auto_optimize: bool,
expose_manager: bool,
}
impl FileContextHook {
pub fn new() -> Self {
Self {
manager: Arc::new(RwLock::new(ContextManager::new())),
auto_optimize: true,
expose_manager: true,
}
}
pub fn with_rules(rules: SupersedenceRules) -> Self {
Self {
manager: Arc::new(RwLock::new(ContextManager::with_rules(rules))),
auto_optimize: true,
expose_manager: true,
}
}
pub fn without_auto_optimize(mut self) -> Self {
self.auto_optimize = false;
self
}
pub fn without_expose_manager(mut self) -> Self {
self.expose_manager = false;
self
}
pub fn compute_optimization(&self) -> ComputedOptimization {
let manager = self.manager.read().unwrap();
let result = manager.compute_optimized_order();
ComputedOptimization {
keep: result.keep.clone(),
drop: result.drop.clone(),
move_to_end: result.move_to_end.clone(),
classified_messages: manager
.classifier()
.classifications()
.values()
.map(|c| c.message_id.clone())
.collect(),
}
}
pub fn files_needing_read(&self) -> Vec<std::path::PathBuf> {
let manager = self.manager.read().unwrap();
manager.files_needing_read()
}
pub fn clear(&self) {
let mut manager = self.manager.write().unwrap();
manager.clear();
}
pub fn manager(&self) -> Arc<RwLock<ContextManager>> {
Arc::clone(&self.manager)
}
fn extract_tool_calls(message: &Message) -> Vec<(String, String, serde_json::Value)> {
let tool_calls = message.tool_calls();
tool_calls
.into_iter()
.filter_map(|tc| {
let args: serde_json::Value = serde_json::from_str(&tc.function.arguments).ok()?;
Some((tc.id.clone(), tc.function.name.clone(), args))
})
.collect()
}
fn generate_unique_id() -> u64 {
UNIQUE_COUNTER.fetch_add(1, Ordering::SeqCst)
}
}
impl Default for FileContextHook {
fn default() -> Self {
Self::new()
}
}
impl Hook for FileContextHook {
fn name(&self) -> &str {
"FileContextHook"
}
async fn pre_completion(
&self,
message: Message,
history: &[Message],
ctx: &mut HookContext,
) -> HookResult<Message> {
if self.expose_manager && !ctx.contains(FILE_CONTEXT_KEY) {
ctx.set(FILE_CONTEXT_KEY, true)
.map_err(|e| HookError::HookFailed {
hook_name: self.name().to_string(),
stage: crate::error::HookStage::PreCompletion,
message: format!("Failed to store context key: {}", e),
source: None,
})?;
}
for hist_msg in history {
if let Some(msg_id) = hist_msg.id() {
let tool_calls = Self::extract_tool_calls(hist_msg);
for (call_id, tool_name, args) in tool_calls {
let mut manager = self.manager.write().unwrap();
let key = ClassificationKey::new(msg_id, &call_id);
if manager
.classifier()
.get_classification_by_key(&key)
.is_none()
{
manager
.classifier_mut()
.classify_tool_call(&tool_name, &args, msg_id, &call_id);
}
}
}
}
Ok(message)
}
async fn post_completion_message(
&self,
message: Message,
_ctx: &mut HookContext,
) -> HookResult<Message> {
if let Some(msg_id) = message.id() {
let tool_calls = Self::extract_tool_calls(&message);
let mut manager = self.manager.write().unwrap();
for (call_id, tool_name, args) in tool_calls {
manager
.classifier_mut()
.classify_tool_call(&tool_name, &args, msg_id, &call_id);
}
}
Ok(message)
}
async fn pre_tool_call(
&self,
tool_name: &str,
args: serde_json::Value,
ctx: &mut HookContext,
) -> HookResult<ToolCallDecision> {
let unique_id = Self::generate_unique_id();
let tracking_key = format!("tool_call_tracking_{}", unique_id);
let call_info = serde_json::json!({
"tool_name": tool_name,
"args": args.clone(),
"unique_id": unique_id
});
ctx.set(&tracking_key, call_info).ok();
ctx.set(&format!("latest_tool_call_{}", ctx.request_id), unique_id)
.ok();
Ok(ToolCallDecision::Proceed(args))
}
async fn post_tool_call(
&self,
tool_name: &str,
result: String,
ctx: &mut HookContext,
) -> HookResult<String> {
let unique_id: u64 = ctx
.get(&format!("latest_tool_call_{}", ctx.request_id))
.and_then(|r| r.ok())
.unwrap_or_else(Self::generate_unique_id);
let call_id = format!("call_{}_{}", ctx.request_id, unique_id);
let msg_id = format!("result_{}_{}", ctx.request_id, unique_id);
let mut manager = self.manager.write().unwrap();
manager
.classifier_mut()
.classify_tool_result(tool_name, &result, &msg_id, &call_id);
Ok(result)
}
async fn filter_tools(
&self,
tools: Vec<ToolDefinition>,
_ctx: &mut HookContext,
) -> HookResult<Vec<ToolDefinition>> {
Ok(tools)
}
async fn on_assistant_message(
&self,
message: &Message,
_ctx: &mut HookContext,
) -> HookResult<()> {
if let Some(msg_id) = message.id() {
let manager = self.manager.read().unwrap();
let classifications = manager.classifier().get_classifications_for_message(msg_id);
for classification in classifications {
tracing::debug!(
message_id = %msg_id,
tool_call_id = %classification.tool_call_id,
operation = ?classification.operation,
file_path = ?classification.file_path,
"File operation classified"
);
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct ComputedOptimization {
pub keep: Vec<ClassificationKey>,
pub drop: Vec<ClassificationKey>,
pub move_to_end: Vec<ClassificationKey>,
pub classified_messages: std::collections::HashSet<String>,
}
impl ComputedOptimization {
pub fn is_classified(&self, message_id: &str) -> bool {
self.classified_messages.contains(message_id)
}
pub fn should_include_key(&self, key: &ClassificationKey) -> bool {
self.keep.contains(key) || self.move_to_end.contains(key)
}
pub fn should_drop_key(&self, key: &ClassificationKey) -> bool {
self.drop.contains(key)
}
pub fn should_drop(&self, message_id: &str) -> bool {
if !self.is_classified(message_id) {
return false;
}
let keys_for_message: Vec<_> = self
.drop
.iter()
.chain(self.keep.iter())
.chain(self.move_to_end.iter())
.filter(|k| k.message_id == message_id)
.collect();
!keys_for_message.is_empty() && keys_for_message.iter().all(|k| self.drop.contains(k))
}
pub fn should_include(&self, message_id: &str) -> bool {
if !self.is_classified(message_id) {
return false;
}
self.keep
.iter()
.chain(self.move_to_end.iter())
.any(|k| k.message_id == message_id)
}
}
pub trait FileContextExt {
fn file_context_optimization(&self, hook: &FileContextHook) -> ComputedOptimization;
fn should_include_message(&self, hook: &FileContextHook, message_id: &str) -> bool;
}
impl FileContextExt for HookContext {
fn file_context_optimization(&self, hook: &FileContextHook) -> ComputedOptimization {
hook.compute_optimization()
}
fn should_include_message(&self, hook: &FileContextHook, message_id: &str) -> bool {
let result = hook.compute_optimization();
if !result.is_classified(message_id) {
return true;
}
result.should_include(message_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_context_hook_creation() {
let hook = FileContextHook::new();
assert!(hook.auto_optimize);
assert!(hook.expose_manager);
}
#[test]
fn test_file_context_hook_with_rules() {
let rules = SupersedenceRules {
preserve_edit_history: true,
..Default::default()
};
let hook = FileContextHook::with_rules(rules);
assert!(hook.manager.read().unwrap().rules().preserve_edit_history);
}
#[test]
fn test_file_context_hook_clear() {
let hook = FileContextHook::new();
{
let mut manager = hook.manager.write().unwrap();
manager.classifier_mut().classify_tool_call(
"read",
&serde_json::json!({"file_path": "/test.rs"}),
"msg-1",
"call-1",
);
}
assert!(!hook
.manager
.read()
.unwrap()
.classifier()
.classifications()
.is_empty());
hook.clear();
assert!(hook
.manager
.read()
.unwrap()
.classifier()
.classifications()
.is_empty());
}
#[tokio::test]
async fn test_file_context_hook_tool_call_classification() {
let hook = FileContextHook::new();
let args = serde_json::json!({"file_path": "/src/main.rs"});
let mut ctx = HookContext::new("test-request");
let decision = hook.pre_tool_call("read", args, &mut ctx).await.unwrap();
assert!(decision.should_proceed());
let result = hook
.post_tool_call(
"read",
r#"{"file_path":"/src/main.rs"}"#.to_string(),
&mut ctx,
)
.await
.unwrap();
assert!(!result.is_empty());
let optimization = hook.compute_optimization();
assert!(!optimization.keep.is_empty() || !optimization.move_to_end.is_empty());
}
#[tokio::test]
async fn test_multiple_tool_calls_unique_ids() {
let hook = FileContextHook::new();
let mut ctx = HookContext::new("test-request");
let args1 = serde_json::json!({"file_path": "/src/file1.rs"});
hook.pre_tool_call("read", args1, &mut ctx).await.unwrap();
hook.post_tool_call(
"read",
r#"{"file_path":"/src/file1.rs"}"#.to_string(),
&mut ctx,
)
.await
.unwrap();
let args2 = serde_json::json!({"file_path": "/src/file2.rs"});
hook.pre_tool_call("read", args2, &mut ctx).await.unwrap();
hook.post_tool_call(
"read",
r#"{"file_path":"/src/file2.rs"}"#.to_string(),
&mut ctx,
)
.await
.unwrap();
let manager = hook.manager.read().unwrap();
let classifications: Vec<_> = manager.classifier().classifications().values().collect();
assert_eq!(classifications.len(), 2);
let call_ids: std::collections::HashSet<_> =
classifications.iter().map(|c| &c.tool_call_id).collect();
assert_eq!(call_ids.len(), 2, "Tool calls should have unique IDs");
}
#[test]
fn test_computed_optimization_unclassified() {
let opt = ComputedOptimization {
keep: vec![],
drop: vec![],
move_to_end: vec![],
classified_messages: std::collections::HashSet::new(),
};
assert!(!opt.should_drop("unclassified-msg"));
assert!(!opt.is_classified("unclassified-msg"));
}
}