1use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11
12use parking_lot::RwLock;
13use serde::{Deserialize, Serialize};
14use tokio::sync::mpsc;
15
16use super::ToolErrorCode;
17use crate::error::NikaError;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
27pub enum PermissionMode {
28 Deny,
30
31 #[default]
36 Plan,
37
38 AcceptEdits,
42
43 YoloMode,
47}
48
49impl PermissionMode {
50 pub fn allows(&self, operation: ToolOperation) -> bool {
52 match self {
53 PermissionMode::Deny => false,
54 PermissionMode::Plan => false, PermissionMode::AcceptEdits => matches!(operation, ToolOperation::Edit),
56 PermissionMode::YoloMode => true,
57 }
58 }
59
60 pub fn display_name(&self) -> &'static str {
62 match self {
63 PermissionMode::Deny => "Deny",
64 PermissionMode::Plan => "Plan",
65 PermissionMode::AcceptEdits => "AcceptEdits",
66 PermissionMode::YoloMode => "YoloMode (Yolo)",
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum ToolOperation {
74 Read,
76 Write,
78 Edit,
80 Search,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
90pub enum ToolEvent {
91 FileRead {
93 path: String,
94 lines: usize,
95 truncated: bool,
96 },
97
98 FileWritten { path: String, bytes: usize },
100
101 FileEdited {
103 path: String,
104 replacements: usize,
105 diff_preview: String,
106 },
107
108 GlobSearch {
110 pattern: String,
111 matches: usize,
112 base_path: String,
113 },
114
115 GrepSearch {
117 pattern: String,
118 files_searched: usize,
119 matches: usize,
120 },
121
122 PermissionRequest {
124 operation: String,
125 path: String,
126 details: String,
127 },
128
129 PermissionGranted { operation: String, path: String },
131
132 PermissionDeniedByUser { operation: String, path: String },
134}
135
136pub struct ToolContext {
144 working_dir: PathBuf,
148
149 read_files: RwLock<HashSet<PathBuf>>,
153
154 permission_mode: RwLock<PermissionMode>,
156
157 event_tx: Option<mpsc::Sender<ToolEvent>>,
159}
160
161impl ToolContext {
162 pub fn new(working_dir: PathBuf, permission_mode: PermissionMode) -> Self {
169 let working_dir = working_dir.canonicalize().unwrap_or(working_dir);
172 Self {
173 working_dir,
174 read_files: RwLock::new(HashSet::new()),
175 permission_mode: RwLock::new(permission_mode),
176 event_tx: None,
177 }
178 }
179
180 pub fn with_events(mut self, tx: mpsc::Sender<ToolEvent>) -> Self {
182 self.event_tx = Some(tx);
183 self
184 }
185
186 pub fn working_dir(&self) -> &Path {
188 &self.working_dir
189 }
190
191 pub fn permission_mode(&self) -> PermissionMode {
193 *self.permission_mode.read()
194 }
195
196 pub fn set_permission_mode(&self, mode: PermissionMode) {
198 *self.permission_mode.write() = mode;
199 }
200
201 pub fn validate_path(&self, file_path: &str) -> Result<PathBuf, NikaError> {
210 let raw_path = PathBuf::from(file_path);
211
212 let path = if raw_path.is_absolute() {
214 raw_path
215 } else {
216 self.working_dir.join(&raw_path)
217 };
218
219 let normalized = if path.exists() {
223 path.canonicalize().unwrap_or(path)
224 } else {
225 self.canonicalize_with_ancestors(&path)
229 };
230
231 if !normalized.starts_with(&self.working_dir) {
233 return Err(NikaError::ToolError {
234 code: ToolErrorCode::PathOutOfBounds.code(),
235 message: format!(
236 "Path '{}' is outside working directory '{}'",
237 file_path,
238 self.working_dir.display()
239 ),
240 });
241 }
242
243 Ok(normalized)
244 }
245
246 fn normalize_path(&self, path: &Path) -> PathBuf {
248 let mut components = Vec::new();
249
250 for component in path.components() {
251 match component {
252 std::path::Component::ParentDir => {
253 components.pop();
254 }
255 std::path::Component::CurDir => {}
256 _ => components.push(component),
257 }
258 }
259
260 components.iter().collect()
261 }
262
263 fn canonicalize_with_ancestors(&self, path: &Path) -> PathBuf {
272 let mut ancestors: Vec<&std::ffi::OsStr> = Vec::new();
273 let mut current = path;
274
275 while !current.exists() {
277 if let Some(file_name) = current.file_name() {
278 ancestors.push(file_name);
279 }
280 if let Some(parent) = current.parent() {
281 current = parent;
282 } else {
283 return self.normalize_path(path);
285 }
286 }
287
288 let canonical_base = current
290 .canonicalize()
291 .unwrap_or_else(|_| current.to_path_buf());
292
293 let mut result = canonical_base;
295 for component in ancestors.into_iter().rev() {
296 result = result.join(component);
297 }
298
299 result
300 }
301
302 pub fn check_permission(&self, operation: ToolOperation) -> Result<(), NikaError> {
304 let mode = self.permission_mode();
305
306 if mode.allows(operation) {
307 return Ok(());
308 }
309
310 Err(NikaError::ToolError {
313 code: ToolErrorCode::PermissionDenied.code(),
314 message: format!(
315 "Permission denied: {:?} not allowed in {} mode",
316 operation,
317 mode.display_name()
318 ),
319 })
320 }
321
322 pub fn mark_as_read(&self, path: &Path) {
324 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
326 self.read_files.write().insert(canonical);
327 }
328
329 pub fn was_read(&self, path: &Path) -> bool {
331 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
333 self.read_files.read().contains(&canonical)
334 }
335
336 pub fn validate_read_before_edit(&self, path: &Path) -> Result<(), NikaError> {
338 if !self.was_read(path) {
339 return Err(NikaError::ToolError {
340 code: ToolErrorCode::MustReadFirst.code(),
341 message: format!(
342 "Must read file before editing: {}. Use the Read tool first.",
343 path.display()
344 ),
345 });
346 }
347 Ok(())
348 }
349
350 pub async fn emit(&self, event: ToolEvent) {
352 if let Some(ref tx) = self.event_tx {
353 if let Err(e) = tx.send(event).await {
354 tracing::debug!(error = %e, "Tool event channel closed, event dropped");
355 }
356 }
357 }
358
359 pub fn clear_read_tracking(&self) {
361 self.read_files.write().clear();
362 }
363}
364
365impl std::fmt::Debug for ToolContext {
366 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367 f.debug_struct("ToolContext")
368 .field("working_dir", &self.working_dir)
369 .field("permission_mode", &self.permission_mode())
370 .field("read_files_count", &self.read_files.read().len())
371 .finish()
372 }
373}
374
375#[cfg(test)]
387pub mod testing {
388 use super::*;
389 use std::path::PathBuf;
390 use std::sync::Arc;
391 use tempfile::TempDir;
392
393 pub async fn setup_test() -> (TempDir, Arc<ToolContext>) {
410 let temp_dir = TempDir::new().expect("Failed to create temp dir");
411 let ctx = Arc::new(ToolContext::new(
412 temp_dir.path().to_path_buf(),
413 PermissionMode::YoloMode,
414 ));
415 (temp_dir, ctx)
416 }
417
418 pub async fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
429 let path = dir.path().join(name);
430 tokio::fs::write(&path, content)
431 .await
432 .expect("Failed to write test file");
433 path
434 }
435
436 pub async fn create_test_tree(dir: &TempDir, files: &[(&str, &str)]) {
452 for (name, content) in files {
453 let path = dir.path().join(name);
454 if let Some(parent) = path.parent() {
455 tokio::fs::create_dir_all(parent)
456 .await
457 .expect("Failed to create directories");
458 }
459 tokio::fs::write(&path, content)
460 .await
461 .expect("Failed to write test file");
462 }
463 }
464}
465
466#[cfg(test)]
471mod tests {
472 use super::*;
473 use std::env;
474 use std::sync::Arc;
475
476 fn test_context() -> Arc<ToolContext> {
477 let working_dir = env::current_dir().unwrap();
478 Arc::new(ToolContext::new(working_dir, PermissionMode::YoloMode))
479 }
480
481 #[test]
482 fn test_permission_mode_allows() {
483 assert!(!PermissionMode::Deny.allows(ToolOperation::Read));
484 assert!(!PermissionMode::Plan.allows(ToolOperation::Edit));
485 assert!(PermissionMode::AcceptEdits.allows(ToolOperation::Edit));
486 assert!(!PermissionMode::AcceptEdits.allows(ToolOperation::Write));
487 assert!(PermissionMode::YoloMode.allows(ToolOperation::Write));
488 }
489
490 #[test]
491 fn test_validate_path_relative_resolved() {
492 let ctx = test_context();
493 let result = ctx.validate_path("src/main.rs");
494 assert!(
496 result.is_ok(),
497 "relative path should resolve: {:?}",
498 result.err()
499 );
500 let resolved = result.unwrap();
501 assert!(resolved.is_absolute(), "resolved path must be absolute");
502 assert!(resolved.ends_with("src/main.rs"));
503 }
504
505 #[test]
506 fn test_validate_path_within_working_dir() {
507 let ctx = test_context();
508 let working_dir = ctx.working_dir().to_string_lossy();
509 let valid_path = format!("{}/src/main.rs", working_dir);
510
511 let result = ctx.validate_path(&valid_path);
512 assert!(result.is_ok());
513 }
514
515 #[test]
516 fn test_validate_path_outside_working_dir() {
517 let ctx = test_context();
518 let result = ctx.validate_path("/etc/passwd");
519 assert!(result.is_err());
520 assert!(result.unwrap_err().to_string().contains("outside"));
521 }
522
523 #[test]
524 fn test_read_tracking() {
525 let ctx = test_context();
526 let path = PathBuf::from("/test/file.rs");
527
528 assert!(!ctx.was_read(&path));
529 ctx.mark_as_read(&path);
530 assert!(ctx.was_read(&path));
531
532 ctx.clear_read_tracking();
533 assert!(!ctx.was_read(&path));
534 }
535
536 #[test]
537 fn test_validate_read_before_edit() {
538 let ctx = test_context();
539 let path = PathBuf::from("/test/file.rs");
540
541 let result = ctx.validate_read_before_edit(&path);
543 assert!(result.is_err());
544
545 ctx.mark_as_read(&path);
547 let result = ctx.validate_read_before_edit(&path);
548 assert!(result.is_ok());
549 }
550
551 #[test]
552 fn test_permission_mode_change() {
553 let ctx = test_context();
554
555 assert_eq!(ctx.permission_mode(), PermissionMode::YoloMode);
556
557 ctx.set_permission_mode(PermissionMode::Plan);
558 assert_eq!(ctx.permission_mode(), PermissionMode::Plan);
559 }
560
561 #[test]
562 fn test_check_permission_deny_mode() {
563 let working_dir = env::current_dir().unwrap();
564 let ctx = ToolContext::new(working_dir, PermissionMode::Deny);
565
566 let result = ctx.check_permission(ToolOperation::Read);
567 assert!(result.is_err());
568 }
569
570 #[test]
571 fn test_check_permission_accept_all() {
572 let ctx = test_context();
573
574 assert!(ctx.check_permission(ToolOperation::Read).is_ok());
575 assert!(ctx.check_permission(ToolOperation::Write).is_ok());
576 assert!(ctx.check_permission(ToolOperation::Edit).is_ok());
577 assert!(ctx.check_permission(ToolOperation::Search).is_ok());
578 }
579}