Skip to main content

nika_engine/tools/
mod.rs

1//! File Tools Module - Claude Code-like filesystem operations
2//!
3//! Provides 5 tools for filesystem interaction:
4//! - [`ReadTool`] - Read files with line numbers
5//! - [`WriteTool`] - Create new files
6//! - [`EditTool`] - Modify existing files (requires read-before-edit)
7//! - [`GlobTool`] - Find files by pattern
8//! - [`GrepTool`] - Search file contents with regex
9//!
10//! # Permission Model
11//!
12//! Inspired by Gemini CLI's Yolo Mode and Claude Code's permission levels:
13//!
14//! | Mode | Behavior |
15//! |------|----------|
16//! | `Deny` | All operations denied |
17//! | `Plan` | Ask before each operation |
18//! | `AcceptEdits` | Auto-approve edits, ask for others |
19//! | `YoloMode` | Auto-approve all (Yolo mode) |
20//!
21//! # Security
22//!
23//! All paths are validated to be:
24//! - Absolute paths only
25//! - Within the working directory (security boundary)
26//!
27//! # Example
28//!
29//! ```rust,no_run
30//! use nika::tools::{ToolContext, ReadTool, PermissionMode};
31//! use std::path::PathBuf;
32//! use std::sync::Arc;
33//!
34//! #[tokio::main]
35//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
36//!     let ctx = Arc::new(ToolContext::new(
37//!         PathBuf::from("/path/to/project"),
38//!         PermissionMode::YoloMode,
39//!     ));
40//!
41//!     let read_tool = ReadTool::new(ctx);
42//!     let result = read_tool.execute(nika::tools::ReadParams {
43//!         file_path: "/path/to/project/src/main.rs".to_string(),
44//!         offset: None,
45//!         limit: None,
46//!     }).await?;
47//!
48//!     println!("{}", result.content);
49//!     Ok(())
50//! }
51//! ```
52
53mod context;
54mod edit;
55mod glob;
56mod grep;
57mod read;
58mod rig_adapter;
59mod submit_tool;
60mod write;
61
62pub use context::{PermissionMode, ToolContext, ToolEvent, ToolOperation};
63pub use edit::{EditParams, EditResult, EditTool};
64pub use glob::{GlobParams, GlobResult, GlobTool};
65pub use grep::{GrepOutputMode, GrepParams, GrepResult, GrepTool};
66pub use read::{ReadParams, ReadResult, ReadTool};
67pub use rig_adapter::{create_rig_file_tools, RigFileTool};
68pub use submit_tool::{DynamicSubmitTool, ToolDefinition};
69pub use write::{WriteParams, WriteResult, WriteTool};
70
71use crate::error::NikaError;
72use async_trait::async_trait;
73use serde::{Deserialize, Serialize};
74use serde_json::Value;
75
76// ═══════════════════════════════════════════════════════════════════════════
77// TOOL TRAIT
78// ═══════════════════════════════════════════════════════════════════════════
79
80/// Trait for file tools that can be called by agents
81///
82/// Implements the rig::ToolDyn pattern for integration with RigAgentLoop.
83#[async_trait]
84pub trait FileTool: Send + Sync {
85    /// Tool name (e.g., "read", "edit", "glob")
86    fn name(&self) -> &'static str;
87
88    /// Tool description for LLM
89    fn description(&self) -> &'static str;
90
91    /// JSON Schema for parameters
92    fn parameters_schema(&self) -> Value;
93
94    /// Execute the tool with JSON parameters
95    async fn call(&self, params: Value) -> Result<ToolOutput, NikaError>;
96}
97
98/// Output from a tool execution
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ToolOutput {
101    /// Text content of the result
102    pub content: String,
103    /// Whether this is an error response
104    pub is_error: bool,
105    /// Optional structured data
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub data: Option<Value>,
108}
109
110impl ToolOutput {
111    /// Create a successful output
112    pub fn success(content: impl Into<String>) -> Self {
113        Self {
114            content: content.into(),
115            is_error: false,
116            data: None,
117        }
118    }
119
120    /// Create a successful output with data
121    pub fn success_with_data(content: impl Into<String>, data: Value) -> Self {
122        Self {
123            content: content.into(),
124            is_error: false,
125            data: Some(data),
126        }
127    }
128
129    /// Create an error output
130    pub fn error(content: impl Into<String>) -> Self {
131        Self {
132            content: content.into(),
133            is_error: true,
134            data: None,
135        }
136    }
137}
138
139// ═══════════════════════════════════════════════════════════════════════════
140// TOOL ERROR CODES
141// ═══════════════════════════════════════════════════════════════════════════
142
143/// Tool-specific error codes (NIKA-200 range)
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum ToolErrorCode {
146    /// NIKA-200: Read operation failed
147    ReadFailed = 200,
148    /// NIKA-201: Write operation failed
149    WriteFailed = 201,
150    /// NIKA-202: Edit operation failed
151    EditFailed = 202,
152    /// NIKA-203: Must read file before editing
153    MustReadFirst = 203,
154    /// NIKA-204: Path is outside working directory
155    PathOutOfBounds = 204,
156    /// NIKA-205: Permission denied for this operation
157    PermissionDenied = 205,
158    /// NIKA-206: Invalid glob pattern
159    InvalidGlobPattern = 206,
160    /// NIKA-207: Invalid regex pattern
161    InvalidRegex = 207,
162    /// NIKA-208: File not found
163    FileNotFound = 208,
164    /// NIKA-209: old_string not unique in file
165    OldStringNotUnique = 209,
166    /// NIKA-215: File already exists (for write)
167    FileAlreadyExists = 215,
168    /// NIKA-211: Path must be absolute
169    RelativePath = 211,
170}
171
172impl ToolErrorCode {
173    /// Get the error code string
174    pub fn code(&self) -> String {
175        format!("NIKA-{}", *self as u16)
176    }
177}
178
179// ═══════════════════════════════════════════════════════════════════════════
180// TESTS
181// ═══════════════════════════════════════════════════════════════════════════
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_tool_output_success() {
189        let output = ToolOutput::success("File read successfully");
190        assert!(!output.is_error);
191        assert_eq!(output.content, "File read successfully");
192        assert!(output.data.is_none());
193    }
194
195    #[test]
196    fn test_tool_output_error() {
197        let output = ToolOutput::error("File not found");
198        assert!(output.is_error);
199        assert_eq!(output.content, "File not found");
200    }
201
202    #[test]
203    fn test_tool_error_codes() {
204        assert_eq!(ToolErrorCode::ReadFailed.code(), "NIKA-200");
205        assert_eq!(ToolErrorCode::MustReadFirst.code(), "NIKA-203");
206        assert_eq!(ToolErrorCode::PathOutOfBounds.code(), "NIKA-204");
207    }
208}