use async_trait::async_trait;
use serde_json::{json, Value};
use std::path::PathBuf;
use crate::error::{Error, Result};
use crate::llm::ToolSpec;
use crate::tools::{resolve_within, Tool};
pub struct EstimateTokens {
workspace: PathBuf,
}
impl EstimateTokens {
pub fn new(workspace: impl Into<PathBuf>) -> Self {
Self {
workspace: workspace.into(),
}
}
fn estimate(&self, text: &str) -> (usize, usize, &'static str) {
let chars = text.len();
let tokens = (chars as f64 / 4.0).ceil() as usize;
(tokens, chars, "chars-over-4")
}
}
#[async_trait]
impl Tool for EstimateTokens {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "estimate_tokens".into(),
description:
"Estimate the number of tokens in a piece of text or a file. Useful for budgeting transcript space."
.into(),
parameters: json!({
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Literal text to estimate tokens for"
},
"path": {
"type": "string",
"description": "Path to a file (workspace-relative) to estimate tokens for"
}
},
"anyOf": [
{"required": ["text"]},
{"required": ["path"]}
]
}),
}
}
fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
crate::tools::ToolSideEffect::ReadOnly
}
async fn execute(&self, arguments: Value) -> Result<String> {
let text_arg = arguments.get("text");
let path_arg = arguments.get("path");
let (tokens, chars, method) = match (text_arg, path_arg) {
(Some(text_val), None) => {
let text = text_val.as_str().ok_or_else(|| Error::BadToolArgs {
name: "estimate_tokens".into(),
message: "text must be a string".into(),
})?;
self.estimate(text)
}
(None, Some(path_val)) => {
let path = path_val.as_str().ok_or_else(|| Error::BadToolArgs {
name: "estimate_tokens".into(),
message: "path must be a string".into(),
})?;
let abs_path = resolve_within(&self.workspace, path)?;
let content =
tokio::fs::read_to_string(&abs_path)
.await
.map_err(|e| Error::Tool {
name: "estimate_tokens".into(),
message: format!("failed to read file {}: {}", abs_path.display(), e),
})?;
self.estimate(&content)
}
(Some(_), Some(_)) => {
return Err(Error::BadToolArgs {
name: "estimate_tokens".into(),
message: "provide exactly one of 'text' or 'path', not both".into(),
});
}
(None, None) => {
return Err(Error::BadToolArgs {
name: "estimate_tokens".into(),
message: "must provide either 'text' or 'path'".into(),
});
}
};
Ok(format!(
"tokens≈{} (chars={}, method={})",
tokens, chars, method
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_workspace() -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::TempDir::new().unwrap();
let ws = tmp.path().to_path_buf();
(tmp, ws)
}
#[test]
fn estimate_text_basic() {
let tool = EstimateTokens::new("/tmp");
let text = "Hello, world! This is a test string.";
let (tokens, chars, method) = tool.estimate(text);
assert_eq!(chars, 36);
assert_eq!(tokens, 9);
assert_eq!(method, "chars-over-4");
}
#[tokio::test]
async fn estimate_path_reads_file() {
let (_tmp, ws) = tmp_workspace();
let tool = EstimateTokens::new(&ws);
let test_path = ws.join("test.txt");
let content = "This is a test file.\nIt has multiple lines.\n";
tokio::fs::write(&test_path, content).await.unwrap();
let result = tool.execute(json!({ "path": "test.txt" })).await.unwrap();
assert!(result.contains("tokens≈"));
assert!(result.contains("chars="));
assert!(result.contains("method=chars-over-4"));
}
#[tokio::test]
async fn estimate_path_outside_workspace() {
let (_tmp, ws) = tmp_workspace();
let tool = EstimateTokens::new(&ws);
let result = tool.execute(json!({ "path": "../etc/passwd" })).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("escapes workspace"));
}
#[tokio::test]
async fn estimate_neither_arg_errors() {
let tool = EstimateTokens::new("/tmp");
let result = tool.execute(json!({})).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err
.to_string()
.contains("must provide either 'text' or 'path'"));
}
#[tokio::test]
async fn estimate_both_args_errors() {
let tool = EstimateTokens::new("/tmp");
let result = tool
.execute(json!({ "text": "hello", "path": "test.txt" }))
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("provide exactly one of"));
}
#[tokio::test]
async fn estimate_text_direct() {
let tool = EstimateTokens::new("/tmp");
let result = tool
.execute(json!({ "text": "Hello, world!" }))
.await
.unwrap();
assert!(result.contains("tokens≈4"));
assert!(result.contains("chars=13"));
}
}