use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::fs;
use super::context::{ToolContext, ToolEvent};
use super::{FileTool, ToolErrorCode, ToolOutput};
use crate::error::NikaError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadParams {
pub file_path: String,
#[serde(default)]
pub offset: Option<usize>,
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadResult {
pub content: String,
pub total_lines: usize,
pub lines_returned: usize,
pub truncated: bool,
}
pub struct ReadTool {
ctx: Arc<ToolContext>,
}
impl ReadTool {
pub const DEFAULT_LIMIT: usize = 2000;
pub const MAX_LINE_LENGTH: usize = 2000;
pub fn new(ctx: Arc<ToolContext>) -> Self {
Self { ctx }
}
pub async fn execute(&self, params: ReadParams) -> Result<ReadResult, NikaError> {
let path = self.ctx.validate_path(¶ms.file_path)?;
if self.ctx.permission_mode() == super::context::PermissionMode::Deny {
return Err(NikaError::ToolError {
code: ToolErrorCode::PermissionDenied.code(),
message: "Read operations are denied in current permission mode".to_string(),
});
}
if !path.exists() {
return Err(NikaError::ToolError {
code: ToolErrorCode::FileNotFound.code(),
message: format!("File not found: {}", params.file_path),
});
}
let content = fs::read_to_string(&path)
.await
.map_err(|e| NikaError::ToolError {
code: ToolErrorCode::ReadFailed.code(),
message: format!("Failed to read file: {}", e),
})?;
let all_lines: Vec<&str> = content.lines().collect();
let total_lines = all_lines.len();
let offset = params.offset.unwrap_or(1).saturating_sub(1);
let limit = params.limit.unwrap_or(Self::DEFAULT_LIMIT);
let selected_lines: Vec<&str> = all_lines.into_iter().skip(offset).take(limit).collect();
let lines_returned = selected_lines.len();
let truncated = offset + lines_returned < total_lines;
let formatted = selected_lines
.iter()
.enumerate()
.map(|(i, line)| {
let line_num = offset + i + 1;
let truncated_line = if line.len() > Self::MAX_LINE_LENGTH {
let mut end = Self::MAX_LINE_LENGTH;
while end > 0 && !line.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &line[..end])
} else {
line.to_string()
};
format!("{:>6}\t{}", line_num, truncated_line)
})
.collect::<Vec<_>>()
.join("\n");
self.ctx.mark_as_read(&path);
self.ctx
.emit(ToolEvent::FileRead {
path: params.file_path,
lines: lines_returned,
truncated,
})
.await;
Ok(ReadResult {
content: formatted,
total_lines,
lines_returned,
truncated,
})
}
}
#[async_trait]
impl FileTool for ReadTool {
fn name(&self) -> &'static str {
"read"
}
fn description(&self) -> &'static str {
"Read a file from the filesystem. Returns content with line numbers. \
Use offset and limit for large files. Must use absolute paths within \
the working directory."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute path to the file to read"
},
"offset": {
"type": "integer",
"description": "Line number to start reading from (1-indexed)",
"minimum": 1
},
"limit": {
"type": "integer",
"description": "Maximum number of lines to read (default: 2000)",
"minimum": 1,
"maximum": 10000
}
},
"required": ["file_path", "offset", "limit"],
"additionalProperties": false
})
}
async fn call(&self, params: Value) -> Result<ToolOutput, NikaError> {
let params: ReadParams =
serde_json::from_value(params).map_err(|e| NikaError::ToolError {
code: ToolErrorCode::ReadFailed.code(),
message: format!("Invalid parameters: {}", e),
})?;
let result = self.execute(params).await?;
Ok(ToolOutput::success_with_data(
result.content.clone(),
serde_json::to_value(&result).unwrap_or_default(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::context::testing::{create_test_file, setup_test};
#[tokio::test]
async fn test_read_simple_file() {
let (temp_dir, ctx) = setup_test().await;
let path = create_test_file(&temp_dir, "test.txt", "line 1\nline 2\nline 3").await;
let file_path = path.to_string_lossy().to_string();
let tool = ReadTool::new(ctx);
let result = tool
.execute(ReadParams {
file_path,
offset: None,
limit: None,
})
.await
.unwrap();
assert_eq!(result.total_lines, 3);
assert_eq!(result.lines_returned, 3);
assert!(!result.truncated);
assert!(result.content.contains("line 1"));
assert!(result.content.contains("line 3"));
}
#[tokio::test]
async fn test_read_with_offset() {
let (temp_dir, ctx) = setup_test().await;
let content = (1..=10)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.join("\n");
let path = create_test_file(&temp_dir, "test.txt", &content).await;
let file_path = path.to_string_lossy().to_string();
let tool = ReadTool::new(ctx);
let result = tool
.execute(ReadParams {
file_path,
offset: Some(5),
limit: Some(3),
})
.await
.unwrap();
assert_eq!(result.total_lines, 10);
assert_eq!(result.lines_returned, 3);
assert!(result.truncated);
assert!(result.content.contains("line 5"));
assert!(result.content.contains("line 7"));
assert!(!result.content.contains("line 4"));
}
#[tokio::test]
async fn test_read_line_numbers_format() {
let (temp_dir, ctx) = setup_test().await;
let path = create_test_file(&temp_dir, "test.txt", "hello\nworld").await;
let file_path = path.to_string_lossy().to_string();
let tool = ReadTool::new(ctx);
let result = tool
.execute(ReadParams {
file_path,
offset: None,
limit: None,
})
.await
.unwrap();
assert!(result.content.contains(" 1\thello"));
assert!(result.content.contains(" 2\tworld"));
}
#[tokio::test]
async fn test_read_marks_file_as_read() {
let (temp_dir, ctx) = setup_test().await;
let path = create_test_file(&temp_dir, "test.txt", "content").await;
let file_path = path.to_string_lossy().to_string();
assert!(!ctx.was_read(&path));
let tool = ReadTool::new(ctx.clone());
tool.execute(ReadParams {
file_path,
offset: None,
limit: None,
})
.await
.unwrap();
assert!(ctx.was_read(&path));
}
#[tokio::test]
async fn test_read_file_not_found() {
let (temp_dir, ctx) = setup_test().await;
let file_path = temp_dir
.path()
.join("nonexistent.txt")
.to_string_lossy()
.to_string();
let tool = ReadTool::new(ctx);
let result = tool
.execute(ReadParams {
file_path,
offset: None,
limit: None,
})
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[tokio::test]
async fn test_read_outside_working_dir() {
let (_temp_dir, ctx) = setup_test().await;
let tool = ReadTool::new(ctx);
let result = tool
.execute(ReadParams {
file_path: "/etc/passwd".to_string(),
offset: None,
limit: None,
})
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("outside"));
}
#[tokio::test]
async fn test_read_relative_path_resolved() {
let (temp_dir, ctx) = setup_test().await;
let file_path = temp_dir.path().join("relative.txt");
tokio::fs::write(&file_path, "relative content")
.await
.unwrap();
let tool = ReadTool::new(ctx);
let result = tool
.execute(ReadParams {
file_path: "relative.txt".to_string(),
offset: None,
limit: None,
})
.await;
assert!(
result.is_ok(),
"relative path should resolve: {:?}",
result.err()
);
assert!(result.unwrap().content.contains("relative content"));
}
#[tokio::test]
async fn test_file_tool_trait() {
let (temp_dir, ctx) = setup_test().await;
let path = create_test_file(&temp_dir, "test.txt", "content").await;
let file_path = path.to_string_lossy().to_string();
let tool = ReadTool::new(ctx);
assert_eq!(tool.name(), "read");
assert!(tool.description().contains("Read a file"));
let schema = tool.parameters_schema();
assert!(schema["properties"]["file_path"].is_object());
let result = tool.call(json!({ "file_path": file_path })).await.unwrap();
assert!(!result.is_error);
assert!(result.content.contains("content"));
}
}