use crate::common::{package_resolver::PackageResolver, path_traversal::PathTraversal};
use rig::tool::{Tool, ToolContext};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{fs, path::PathBuf, sync::Arc};
use tokio::sync::Mutex;
const MAX_CHARS: usize = 10000;
const MAX_LINES: usize = 1000;
#[derive(Debug, Deserialize, Serialize)]
pub struct ReadFileArgs {
path: PathBuf,
#[serde(default)]
start: Option<usize>,
#[serde(default)]
end: Option<usize>,
#[serde(default)]
root: Option<String>,
}
pub struct ReadFile {
package_resolver: Arc<Mutex<PackageResolver>>,
path_traversal: Arc<Mutex<PathTraversal>>,
has_pacmans: bool, }
impl ReadFile {
pub async fn new(
package_resolver: Arc<Mutex<PackageResolver>>,
path_traversal: Arc<Mutex<PathTraversal>>,
) -> Self {
let has_pacmans = !package_resolver.lock().await.list_pacmans().is_empty();
Self {
package_resolver,
path_traversal,
has_pacmans,
}
}
}
impl Tool for ReadFile {
const NAME: &'static str = "read_file";
type Error = super::ToolError;
type Args = ReadFileArgs;
type Output = super::FileContent;
#[tracing::instrument(skip(self, _context))]
async fn call(
&self,
_context: &mut ToolContext,
args: Self::Args,
) -> Result<Self::Output, Self::Error> {
let package_resolver = self.package_resolver.lock().await;
let path_traversal = self.path_traversal.lock().await;
let package_root = package_resolver
.package_root(args.root.as_deref())?
.map(PathBuf::from);
let path = path_traversal.absolute(&args.path, package_root.as_deref())?;
if path.is_dir() {
return Err(super::ToolError::NotAFile {
path: args.path.to_string_lossy().to_string(),
suggestion: "Use list_dir to explore this directory instead.".to_string(),
});
}
let start = match args.start {
Some(v) => v.saturating_sub(1),
None => 0,
};
let end = args.end.map(|v| {
if v > 0 {
v } else {
1 }
});
let mut lines_taken = 0;
let mut characters_taken = 0;
let mut information = "The selected range results in no lines.".to_string();
let content: Vec<String> = fs::read_to_string(path)
.map_err(|err| super::io_error_to_tool_error(err, &args.path))?
.lines()
.skip(start)
.take_while(|line| {
let line_width = line.chars().count() + 1; if let Some(end_line) = end
&& start + lines_taken > end_line
{
information = "".to_string(); false
} else if lines_taken >= MAX_LINES {
information = format!("The maximum number of lines ({}) has been reached. Further lines will be skipped.", MAX_LINES);
false
} else if characters_taken + line_width > MAX_CHARS {
if lines_taken == 0 {
information = format!("The first selected lines exceeded the maximum number of characters ({}). \
No lines will be returned.", MAX_CHARS);
} else {
information = format!("The maximum number of characters ({}) has been reached. Further lines will be skipped.", MAX_CHARS);
}
false
} else {
lines_taken += 1;
characters_taken += line_width;
true
}
})
.map(|line| line.to_string())
.collect();
let extract = content.join("\n");
Ok(super::FileContent {
range: super::Range {
path: args.path,
start: start + 1,
end: start + lines_taken + 1,
},
content: extract.to_owned(),
information,
})
}
fn description(&self) -> String {
format!(
"Read the content of a file, optionally specifying a line range. \
Lines are 1-indexed and inclusive. \
If no range is specified, the entire file is returned with the following exceptions: \
The result is limited to {} lines and {} characters; if the file exceeds these limits, it is clipped.",
MAX_LINES, MAX_CHARS
)
}
fn parameters(&self) -> serde_json::Value {
let path_description = format!(
"Relative path to the file to read *within* the {}. \
Use an empty string `\"\"` to read a file located at the root of the {}. \
**Never** include {}absolute paths here.",
if self.has_pacmans {
"selected root namespace"
} else {
"workspace root"
},
if self.has_pacmans {
"selected scope"
} else {
"workspace"
},
if self.has_pacmans {
"package identifiers (like `cargo:...`) or "
} else {
""
}
);
let mut params = json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": path_description
},
"start": {
"type": "integer",
"description": "First line number to read (1-indexed, inclusive). \
If not provided, reads from the beginning of the file."
},
"end": {
"type": "integer",
"description": "Last line number to read (1-indexed, inclusive). \
If not provided, reads to the end of the file. \
If end exceeds the file length, reads until the end."
},
},
"required": ["path"]
});
if self.has_pacmans {
if let Some(props) = params["properties"].as_object_mut() {
props.insert(
"root".to_string(),
json!({
"type": "string",
"description": "Identifier of a package or dependency (e.g., `cargo:crate@version`) to use as the scope for reading. \
If omitted, defaults to the workspace root. \
This selects *which* codebase, while `path` selects the specific file inside it."
}),
);
}
}
params
}
fn map_error(&self, error: Self::Error) -> rig::tool::ToolExecutionError {
rig::tool::ToolExecutionError::from_error(error)
}
}