use std::collections::HashSet;
use std::path::{Path, PathBuf};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use super::ToolErrorCode;
use crate::error::NikaError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum PermissionMode {
Deny,
#[default]
Plan,
AcceptEdits,
YoloMode,
}
impl PermissionMode {
pub fn allows(&self, operation: ToolOperation) -> bool {
match self {
PermissionMode::Deny => false,
PermissionMode::Plan => false, PermissionMode::AcceptEdits => matches!(operation, ToolOperation::Edit),
PermissionMode::YoloMode => true,
}
}
pub fn display_name(&self) -> &'static str {
match self {
PermissionMode::Deny => "Deny",
PermissionMode::Plan => "Plan",
PermissionMode::AcceptEdits => "AcceptEdits",
PermissionMode::YoloMode => "YoloMode (Yolo)",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolOperation {
Read,
Write,
Edit,
Search,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ToolEvent {
FileRead {
path: String,
lines: usize,
truncated: bool,
},
FileWritten { path: String, bytes: usize },
FileEdited {
path: String,
replacements: usize,
diff_preview: String,
},
GlobSearch {
pattern: String,
matches: usize,
base_path: String,
},
GrepSearch {
pattern: String,
files_searched: usize,
matches: usize,
},
PermissionRequest {
operation: String,
path: String,
details: String,
},
PermissionGranted { operation: String, path: String },
PermissionDeniedByUser { operation: String, path: String },
}
pub struct ToolContext {
working_dir: PathBuf,
read_files: RwLock<HashSet<PathBuf>>,
permission_mode: RwLock<PermissionMode>,
event_tx: Option<mpsc::Sender<ToolEvent>>,
}
impl ToolContext {
pub fn new(working_dir: PathBuf, permission_mode: PermissionMode) -> Self {
let working_dir = working_dir.canonicalize().unwrap_or(working_dir);
Self {
working_dir,
read_files: RwLock::new(HashSet::new()),
permission_mode: RwLock::new(permission_mode),
event_tx: None,
}
}
pub fn with_events(mut self, tx: mpsc::Sender<ToolEvent>) -> Self {
self.event_tx = Some(tx);
self
}
pub fn working_dir(&self) -> &Path {
&self.working_dir
}
pub fn permission_mode(&self) -> PermissionMode {
*self.permission_mode.read()
}
pub fn set_permission_mode(&self, mode: PermissionMode) {
*self.permission_mode.write() = mode;
}
pub fn validate_path(&self, file_path: &str) -> Result<PathBuf, NikaError> {
let raw_path = PathBuf::from(file_path);
let path = if raw_path.is_absolute() {
raw_path
} else {
self.working_dir.join(&raw_path)
};
let normalized = if path.exists() {
path.canonicalize().unwrap_or(path)
} else {
self.canonicalize_with_ancestors(&path)
};
if !normalized.starts_with(&self.working_dir) {
return Err(NikaError::ToolError {
code: ToolErrorCode::PathOutOfBounds.code(),
message: format!(
"Path '{}' is outside working directory '{}'",
file_path,
self.working_dir.display()
),
});
}
Ok(normalized)
}
fn normalize_path(&self, path: &Path) -> PathBuf {
let mut components = Vec::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
components.pop();
}
std::path::Component::CurDir => {}
_ => components.push(component),
}
}
components.iter().collect()
}
fn canonicalize_with_ancestors(&self, path: &Path) -> PathBuf {
let mut ancestors: Vec<&std::ffi::OsStr> = Vec::new();
let mut current = path;
while !current.exists() {
if let Some(file_name) = current.file_name() {
ancestors.push(file_name);
}
if let Some(parent) = current.parent() {
current = parent;
} else {
return self.normalize_path(path);
}
}
let canonical_base = current
.canonicalize()
.unwrap_or_else(|_| current.to_path_buf());
let mut result = canonical_base;
for component in ancestors.into_iter().rev() {
result = result.join(component);
}
result
}
pub fn check_permission(&self, operation: ToolOperation) -> Result<(), NikaError> {
let mode = self.permission_mode();
if mode.allows(operation) {
return Ok(());
}
Err(NikaError::ToolError {
code: ToolErrorCode::PermissionDenied.code(),
message: format!(
"Permission denied: {:?} not allowed in {} mode",
operation,
mode.display_name()
),
})
}
pub fn mark_as_read(&self, path: &Path) {
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
self.read_files.write().insert(canonical);
}
pub fn was_read(&self, path: &Path) -> bool {
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
self.read_files.read().contains(&canonical)
}
pub fn validate_read_before_edit(&self, path: &Path) -> Result<(), NikaError> {
if !self.was_read(path) {
return Err(NikaError::ToolError {
code: ToolErrorCode::MustReadFirst.code(),
message: format!(
"Must read file before editing: {}. Use the Read tool first.",
path.display()
),
});
}
Ok(())
}
pub async fn emit(&self, event: ToolEvent) {
if let Some(ref tx) = self.event_tx {
let _ = tx.send(event).await;
}
}
pub fn clear_read_tracking(&self) {
self.read_files.write().clear();
}
}
impl std::fmt::Debug for ToolContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ToolContext")
.field("working_dir", &self.working_dir)
.field("permission_mode", &self.permission_mode())
.field("read_files_count", &self.read_files.read().len())
.finish()
}
}
#[cfg(test)]
pub mod testing {
use super::*;
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::TempDir;
pub async fn setup_test() -> (TempDir, Arc<ToolContext>) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let ctx = Arc::new(ToolContext::new(
temp_dir.path().to_path_buf(),
PermissionMode::YoloMode,
));
(temp_dir, ctx)
}
pub async fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
let path = dir.path().join(name);
tokio::fs::write(&path, content)
.await
.expect("Failed to write test file");
path
}
pub async fn create_test_tree(dir: &TempDir, files: &[(&str, &str)]) {
for (name, content) in files {
let path = dir.path().join(name);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.expect("Failed to create directories");
}
tokio::fs::write(&path, content)
.await
.expect("Failed to write test file");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::sync::Arc;
fn test_context() -> Arc<ToolContext> {
let working_dir = env::current_dir().unwrap();
Arc::new(ToolContext::new(working_dir, PermissionMode::YoloMode))
}
#[test]
fn test_permission_mode_allows() {
assert!(!PermissionMode::Deny.allows(ToolOperation::Read));
assert!(!PermissionMode::Plan.allows(ToolOperation::Edit));
assert!(PermissionMode::AcceptEdits.allows(ToolOperation::Edit));
assert!(!PermissionMode::AcceptEdits.allows(ToolOperation::Write));
assert!(PermissionMode::YoloMode.allows(ToolOperation::Write));
}
#[test]
fn test_validate_path_relative_resolved() {
let ctx = test_context();
let result = ctx.validate_path("src/main.rs");
assert!(
result.is_ok(),
"relative path should resolve: {:?}",
result.err()
);
let resolved = result.unwrap();
assert!(resolved.is_absolute(), "resolved path must be absolute");
assert!(resolved.ends_with("src/main.rs"));
}
#[test]
fn test_validate_path_within_working_dir() {
let ctx = test_context();
let working_dir = ctx.working_dir().to_string_lossy();
let valid_path = format!("{}/src/main.rs", working_dir);
let result = ctx.validate_path(&valid_path);
assert!(result.is_ok());
}
#[test]
fn test_validate_path_outside_working_dir() {
let ctx = test_context();
let result = ctx.validate_path("/etc/passwd");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("outside"));
}
#[test]
fn test_read_tracking() {
let ctx = test_context();
let path = PathBuf::from("/test/file.rs");
assert!(!ctx.was_read(&path));
ctx.mark_as_read(&path);
assert!(ctx.was_read(&path));
ctx.clear_read_tracking();
assert!(!ctx.was_read(&path));
}
#[test]
fn test_validate_read_before_edit() {
let ctx = test_context();
let path = PathBuf::from("/test/file.rs");
let result = ctx.validate_read_before_edit(&path);
assert!(result.is_err());
ctx.mark_as_read(&path);
let result = ctx.validate_read_before_edit(&path);
assert!(result.is_ok());
}
#[test]
fn test_permission_mode_change() {
let ctx = test_context();
assert_eq!(ctx.permission_mode(), PermissionMode::YoloMode);
ctx.set_permission_mode(PermissionMode::Plan);
assert_eq!(ctx.permission_mode(), PermissionMode::Plan);
}
#[test]
fn test_check_permission_deny_mode() {
let working_dir = env::current_dir().unwrap();
let ctx = ToolContext::new(working_dir, PermissionMode::Deny);
let result = ctx.check_permission(ToolOperation::Read);
assert!(result.is_err());
}
#[test]
fn test_check_permission_accept_all() {
let ctx = test_context();
assert!(ctx.check_permission(ToolOperation::Read).is_ok());
assert!(ctx.check_permission(ToolOperation::Write).is_ok());
assert!(ctx.check_permission(ToolOperation::Edit).is_ok());
assert!(ctx.check_permission(ToolOperation::Search).is_ok());
}
}