Skip to main content

nika_engine/runtime/builtin/
file_adapter.rs

1//! File Adapter - Bridges FileTool to BuiltinTool for nika:* integration
2//!
3//! This module adapts the file tools from `src/tools/` (ReadTool, WriteTool, etc.)
4//! to work with the BuiltinToolRouter as `nika:read`, `nika:write`, etc.
5
6use super::BuiltinTool;
7use crate::error::NikaError;
8use crate::tools::{EditTool, FileTool, GlobTool, GrepTool, ReadTool, ToolContext, WriteTool};
9use std::future::Future;
10use std::pin::Pin;
11use std::sync::Arc;
12
13/// Adapter that bridges a FileTool to BuiltinTool trait.
14///
15/// This allows file tools to be registered in the BuiltinToolRouter
16/// and called via `nika:read`, `nika:write`, etc.
17pub struct FileToolAdapter<T: FileTool + Send + Sync + 'static> {
18    tool: Arc<T>,
19}
20
21impl<T: FileTool + Send + Sync + 'static> FileToolAdapter<T> {
22    /// Create a new adapter wrapping a FileTool.
23    pub fn new(tool: T) -> Self {
24        Self {
25            tool: Arc::new(tool),
26        }
27    }
28
29    /// Create from an existing Arc.
30    pub fn from_arc(tool: Arc<T>) -> Self {
31        Self { tool }
32    }
33}
34
35impl<T: FileTool + Send + Sync + 'static> BuiltinTool for FileToolAdapter<T> {
36    fn name(&self) -> &'static str {
37        self.tool.name()
38    }
39
40    fn description(&self) -> &'static str {
41        self.tool.description()
42    }
43
44    fn parameters_schema(&self) -> serde_json::Value {
45        self.tool.parameters_schema()
46    }
47
48    fn call<'a>(
49        &'a self,
50        args: String,
51    ) -> Pin<Box<dyn Future<Output = Result<String, NikaError>> + Send + 'a>> {
52        let tool = Arc::clone(&self.tool);
53
54        Box::pin(async move {
55            // Parse JSON string to Value
56            let params: serde_json::Value =
57                serde_json::from_str(&args).map_err(|e| NikaError::BuiltinToolError {
58                    tool: tool.name().into(),
59                    reason: format!("Invalid JSON parameters: {e}"),
60                })?;
61
62            // Call the underlying FileTool
63            let output = tool.call(params).await?;
64
65            // Convert ToolOutput to JSON string
66            if output.is_error {
67                Err(NikaError::BuiltinToolError {
68                    tool: tool.name().into(),
69                    reason: output.content,
70                })
71            } else {
72                // Return content with optional data
73                if let Some(data) = output.data {
74                    serde_json::to_string(&serde_json::json!({
75                        "content": output.content,
76                        "data": data
77                    }))
78                    .map_err(|e| NikaError::BuiltinToolError {
79                        tool: tool.name().into(),
80                        reason: format!("Failed to serialize result: {e}"),
81                    })
82                } else {
83                    Ok(output.content)
84                }
85            }
86        })
87    }
88}
89
90// ═══════════════════════════════════════════════════════════════════════════
91// CONVENIENCE CONSTRUCTORS
92// ═══════════════════════════════════════════════════════════════════════════
93
94/// Create all 5 file tool adapters for the BuiltinToolRouter.
95///
96/// Returns adapters for: read, write, edit, glob, grep
97pub fn create_file_tool_adapters(ctx: Arc<ToolContext>) -> Vec<Box<dyn BuiltinTool>> {
98    vec![
99        Box::new(FileToolAdapter::new(ReadTool::new(Arc::clone(&ctx)))),
100        Box::new(FileToolAdapter::new(WriteTool::new(Arc::clone(&ctx)))),
101        Box::new(FileToolAdapter::new(EditTool::new(Arc::clone(&ctx)))),
102        Box::new(FileToolAdapter::new(GlobTool::new(Arc::clone(&ctx)))),
103        Box::new(FileToolAdapter::new(GrepTool::new(ctx))),
104    ]
105}
106
107// ═══════════════════════════════════════════════════════════════════════════
108// TESTS
109// ═══════════════════════════════════════════════════════════════════════════
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::tools::PermissionMode;
115    use tempfile::TempDir;
116
117    async fn setup_test() -> (TempDir, Arc<ToolContext>) {
118        let temp_dir = TempDir::new().unwrap();
119        let ctx = Arc::new(ToolContext::new(
120            temp_dir.path().to_path_buf(),
121            PermissionMode::YoloMode,
122        ));
123        (temp_dir, ctx)
124    }
125
126    #[tokio::test]
127    async fn test_read_adapter_name() {
128        let (_temp, ctx) = setup_test().await;
129        let adapter = FileToolAdapter::new(ReadTool::new(ctx));
130        assert_eq!(adapter.name(), "read");
131    }
132
133    #[tokio::test]
134    async fn test_write_adapter_name() {
135        let (_temp, ctx) = setup_test().await;
136        let adapter = FileToolAdapter::new(WriteTool::new(ctx));
137        assert_eq!(adapter.name(), "write");
138    }
139
140    #[tokio::test]
141    async fn test_edit_adapter_name() {
142        let (_temp, ctx) = setup_test().await;
143        let adapter = FileToolAdapter::new(EditTool::new(ctx));
144        assert_eq!(adapter.name(), "edit");
145    }
146
147    #[tokio::test]
148    async fn test_glob_adapter_name() {
149        let (_temp, ctx) = setup_test().await;
150        let adapter = FileToolAdapter::new(GlobTool::new(ctx));
151        assert_eq!(adapter.name(), "glob");
152    }
153
154    #[tokio::test]
155    async fn test_grep_adapter_name() {
156        let (_temp, ctx) = setup_test().await;
157        let adapter = FileToolAdapter::new(GrepTool::new(ctx));
158        assert_eq!(adapter.name(), "grep");
159    }
160
161    #[tokio::test]
162    async fn test_create_file_tool_adapters() {
163        let (_temp, ctx) = setup_test().await;
164        let adapters = create_file_tool_adapters(ctx);
165
166        assert_eq!(adapters.len(), 5);
167
168        let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
169        assert!(names.contains(&"read"));
170        assert!(names.contains(&"write"));
171        assert!(names.contains(&"edit"));
172        assert!(names.contains(&"glob"));
173        assert!(names.contains(&"grep"));
174    }
175
176    #[tokio::test]
177    async fn test_write_then_read() {
178        let (temp_dir, ctx) = setup_test().await;
179        let file_path = temp_dir.path().join("test.txt");
180
181        // Write file
182        let write_adapter = FileToolAdapter::new(WriteTool::new(Arc::clone(&ctx)));
183        let write_args = serde_json::json!({
184            "file_path": file_path.to_string_lossy(),
185            "content": "Hello, Nika!"
186        })
187        .to_string();
188
189        let result = write_adapter.call(write_args).await;
190        assert!(result.is_ok(), "Write failed: {:?}", result);
191
192        // Read file
193        let read_adapter = FileToolAdapter::new(ReadTool::new(ctx));
194        let read_args = serde_json::json!({
195            "file_path": file_path.to_string_lossy()
196        })
197        .to_string();
198
199        let result = read_adapter.call(read_args).await;
200        assert!(result.is_ok());
201        let content = result.unwrap();
202        assert!(content.contains("Hello, Nika!"));
203    }
204
205    #[tokio::test]
206    async fn test_edit_file() {
207        let (temp_dir, ctx) = setup_test().await;
208        let file_path = temp_dir.path().join("edit-test.txt");
209
210        // Write initial file
211        std::fs::write(&file_path, "Hello World").unwrap();
212
213        // Read first (required by EditTool)
214        let read_adapter = FileToolAdapter::new(ReadTool::new(Arc::clone(&ctx)));
215        let read_args = serde_json::json!({
216            "file_path": file_path.to_string_lossy()
217        })
218        .to_string();
219        read_adapter.call(read_args).await.unwrap();
220
221        // Edit file
222        let edit_adapter = FileToolAdapter::new(EditTool::new(ctx));
223        let edit_args = serde_json::json!({
224            "file_path": file_path.to_string_lossy(),
225            "old_string": "World",
226            "new_string": "Nika"
227        })
228        .to_string();
229
230        let result = edit_adapter.call(edit_args).await;
231        assert!(result.is_ok(), "Edit failed: {:?}", result);
232
233        // Verify
234        let content = std::fs::read_to_string(&file_path).unwrap();
235        assert_eq!(content, "Hello Nika");
236    }
237
238    #[tokio::test]
239    async fn test_glob_find_files() {
240        let (temp_dir, ctx) = setup_test().await;
241
242        // Create test files
243        std::fs::write(temp_dir.path().join("file1.txt"), "content1").unwrap();
244        std::fs::write(temp_dir.path().join("file2.txt"), "content2").unwrap();
245        std::fs::write(temp_dir.path().join("other.md"), "markdown").unwrap();
246
247        let glob_adapter = FileToolAdapter::new(GlobTool::new(ctx));
248        let glob_args = serde_json::json!({
249            "pattern": "*.txt",
250            "path": temp_dir.path().to_string_lossy()
251        })
252        .to_string();
253
254        let result = glob_adapter.call(glob_args).await;
255        assert!(result.is_ok());
256        let output = result.unwrap();
257        assert!(output.contains("file1.txt"));
258        assert!(output.contains("file2.txt"));
259        assert!(!output.contains("other.md"));
260    }
261
262    #[tokio::test]
263    async fn test_grep_search_content() {
264        let (temp_dir, ctx) = setup_test().await;
265
266        // Create test file
267        std::fs::write(
268            temp_dir.path().join("search.txt"),
269            "Line 1: Hello\nLine 2: World\nLine 3: Hello World",
270        )
271        .unwrap();
272
273        let grep_adapter = FileToolAdapter::new(GrepTool::new(ctx));
274        let grep_args = serde_json::json!({
275            "pattern": "Hello",
276            "path": temp_dir.path().to_string_lossy()
277        })
278        .to_string();
279
280        let result = grep_adapter.call(grep_args).await;
281        assert!(result.is_ok());
282        let output = result.unwrap();
283        // Should find matches
284        assert!(output.contains("search.txt"));
285    }
286
287    #[tokio::test]
288    async fn test_invalid_json_params() {
289        let (_temp, ctx) = setup_test().await;
290        let adapter = FileToolAdapter::new(ReadTool::new(ctx));
291
292        let result = adapter.call("not valid json".to_string()).await;
293        assert!(result.is_err());
294
295        let err = result.unwrap_err();
296        assert!(err.to_string().contains("Invalid JSON parameters"));
297    }
298
299    #[tokio::test]
300    async fn test_path_outside_boundary() {
301        let (_temp, ctx) = setup_test().await;
302        let adapter = FileToolAdapter::new(ReadTool::new(ctx));
303
304        // Try to read outside working directory
305        let args = serde_json::json!({
306            "file_path": "/etc/passwd"
307        })
308        .to_string();
309
310        let result = adapter.call(args).await;
311        assert!(result.is_err());
312    }
313
314    // =========================================================================
315    // Additional Edge Case Tests
316    // =========================================================================
317
318    #[tokio::test]
319    async fn test_read_nonexistent_file() {
320        let (temp_dir, ctx) = setup_test().await;
321        let adapter = FileToolAdapter::new(ReadTool::new(ctx));
322
323        let args = serde_json::json!({
324            "file_path": temp_dir.path().join("does_not_exist.txt").to_string_lossy()
325        })
326        .to_string();
327
328        let result = adapter.call(args).await;
329        assert!(result.is_err());
330        let err = result.unwrap_err();
331        assert!(err.to_string().contains("read") || err.to_string().contains("File"));
332    }
333
334    #[tokio::test]
335    async fn test_edit_nonexistent_file() {
336        let (temp_dir, ctx) = setup_test().await;
337        let adapter = FileToolAdapter::new(EditTool::new(ctx));
338
339        let args = serde_json::json!({
340            "file_path": temp_dir.path().join("nonexistent.txt").to_string_lossy(),
341            "old_string": "foo",
342            "new_string": "bar"
343        })
344        .to_string();
345
346        let result = adapter.call(args).await;
347        // Should error because file doesn't exist or wasn't read first
348        assert!(result.is_err());
349    }
350
351    #[tokio::test]
352    async fn test_edit_old_string_not_found() {
353        let (temp_dir, ctx) = setup_test().await;
354        let file_path = temp_dir.path().join("edit-miss.txt");
355
356        // Create file with known content
357        std::fs::write(&file_path, "Hello World").unwrap();
358
359        // Read first (required by EditTool)
360        let read_adapter = FileToolAdapter::new(ReadTool::new(Arc::clone(&ctx)));
361        let read_args = serde_json::json!({
362            "file_path": file_path.to_string_lossy()
363        })
364        .to_string();
365        read_adapter.call(read_args).await.unwrap();
366
367        // Try to edit with non-matching old_string
368        let edit_adapter = FileToolAdapter::new(EditTool::new(ctx));
369        let edit_args = serde_json::json!({
370            "file_path": file_path.to_string_lossy(),
371            "old_string": "NONEXISTENT_STRING",
372            "new_string": "replaced"
373        })
374        .to_string();
375
376        let result = edit_adapter.call(edit_args).await;
377        // Should error because old_string not found
378        assert!(result.is_err());
379        let err = result.unwrap_err();
380        assert!(
381            err.to_string().contains("not found")
382                || err.to_string().contains("does not exist")
383                || err.to_string().contains("old_string")
384        );
385    }
386
387    #[tokio::test]
388    async fn test_write_missing_content_param() {
389        let (temp_dir, ctx) = setup_test().await;
390        let adapter = FileToolAdapter::new(WriteTool::new(ctx));
391
392        // Missing "content" field
393        let args = serde_json::json!({
394            "file_path": temp_dir.path().join("test.txt").to_string_lossy()
395        })
396        .to_string();
397
398        let result = adapter.call(args).await;
399        assert!(result.is_err());
400    }
401
402    #[tokio::test]
403    async fn test_glob_no_matches() {
404        let (temp_dir, ctx) = setup_test().await;
405
406        // Create only .txt files
407        std::fs::write(temp_dir.path().join("file.txt"), "content").unwrap();
408
409        let adapter = FileToolAdapter::new(GlobTool::new(ctx));
410        let args = serde_json::json!({
411            "pattern": "*.rs",  // No .rs files exist
412            "path": temp_dir.path().to_string_lossy()
413        })
414        .to_string();
415
416        let result = adapter.call(args).await;
417        // Should succeed but return empty or "no matches" message
418        assert!(result.is_ok());
419        let output = result.unwrap();
420        // Either empty result or message indicating no matches
421        assert!(
422            output.is_empty()
423                || output.contains("[]")
424                || output.contains("No files")
425                || !output.contains(".rs")
426        );
427    }
428
429    #[tokio::test]
430    async fn test_grep_no_matches() {
431        let (temp_dir, ctx) = setup_test().await;
432
433        // Create file without the search pattern
434        std::fs::write(temp_dir.path().join("search.txt"), "Hello World").unwrap();
435
436        let adapter = FileToolAdapter::new(GrepTool::new(ctx));
437        let args = serde_json::json!({
438            "pattern": "NONEXISTENT_PATTERN_12345",
439            "path": temp_dir.path().to_string_lossy()
440        })
441        .to_string();
442
443        let result = adapter.call(args).await;
444        // Should succeed but return no matches
445        assert!(result.is_ok());
446        let output = result.unwrap();
447        assert!(!output.contains("Hello"));
448    }
449
450    #[tokio::test]
451    async fn test_read_missing_file_path_param() {
452        let (_temp, ctx) = setup_test().await;
453        let adapter = FileToolAdapter::new(ReadTool::new(ctx));
454
455        // Missing file_path field
456        let args = serde_json::json!({}).to_string();
457
458        let result = adapter.call(args).await;
459        assert!(result.is_err());
460    }
461}