//! Tool definitions for file editing via LLM.
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::db::models::{BodyElement, ChartType};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tool {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ToolResult {
pub success: bool,
pub message: String,
}
/// Available tools for file editing
pub static AVAILABLE_TOOLS: once_cell::sync::Lazy<Vec<Tool>> =
once_cell::sync::Lazy::new(|| {
vec![
Tool {
name: "add_heading".to_string(),
description: "Add a heading element to the file body".to_string(),
parameters: json!({
"type": "object",
"properties": {
"level": {
"type": "integer",
"description": "Heading level (1-6)",
"minimum": 1,
"maximum": 6
},
"text": {
"type": "string",
"description": "The heading text"
},
"position": {
"type": "integer",
"description": "Optional position to insert at (0-indexed). If not specified, appends to end."
}
},
"required": ["level", "text"]
}),
},
Tool {
name: "add_paragraph".to_string(),
description: "Add a paragraph element to the file body".to_string(),
parameters: json!({
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The paragraph text"
},
"position": {
"type": "integer",
"description": "Optional position to insert at (0-indexed). If not specified, appends to end."
}
},
"required": ["text"]
}),
},
Tool {
name: "add_chart".to_string(),
description: "Add a chart visualization to the file body. Supports line, bar, pie, and area charts.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"chart_type": {
"type": "string",
"enum": ["line", "bar", "pie", "area"],
"description": "Type of chart to create"
},
"title": {
"type": "string",
"description": "Optional chart title"
},
"data": {
"type": "array",
"description": "Array of data points. Each point should have a 'name' field and one or more numeric value fields.",
"items": {
"type": "object"
}
},
"config": {
"type": "object",
"description": "Optional chart configuration (colors, axes, etc.)"
},
"position": {
"type": "integer",
"description": "Optional position to insert at (0-indexed). If not specified, appends to end."
}
},
"required": ["chart_type", "data"]
}),
},
Tool {
name: "remove_element".to_string(),
description: "Remove an element from the file body by index".to_string(),
parameters: json!({
"type": "object",
"properties": {
"index": {
"type": "integer",
"description": "Index of element to remove (0-indexed)"
}
},
"required": ["index"]
}),
},
Tool {
name: "update_element".to_string(),
description: "Update an existing element in the file body".to_string(),
parameters: json!({
"type": "object",
"properties": {
"index": {
"type": "integer",
"description": "Index of element to update (0-indexed)"
},
"element": {
"type": "object",
"description": "New element data. Must include 'type' field (heading, paragraph, chart)."
}
},
"required": ["index", "element"]
}),
},
Tool {
name: "reorder_elements".to_string(),
description: "Move an element from one position to another".to_string(),
parameters: json!({
"type": "object",
"properties": {
"from_index": {
"type": "integer",
"description": "Current index of the element"
},
"to_index": {
"type": "integer",
"description": "New index for the element"
}
},
"required": ["from_index", "to_index"]
}),
},
Tool {
name: "set_summary".to_string(),
description: "Set the file summary text".to_string(),
parameters: json!({
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "The summary text"
}
},
"required": ["summary"]
}),
},
Tool {
name: "parse_csv".to_string(),
description: "Parse CSV data into JSON format suitable for charts".to_string(),
parameters: json!({
"type": "object",
"properties": {
"csv": {
"type": "string",
"description": "CSV data string with header row"
}
},
"required": ["csv"]
}),
},
Tool {
name: "clear_body".to_string(),
description: "Clear all elements from the file body".to_string(),
parameters: json!({
"type": "object",
"properties": {}
}),
},
]
});
/// Result of executing a tool call with modified file state
#[derive(Debug)]
pub struct ToolExecutionResult {
pub result: ToolResult,
pub new_body: Option<Vec<BodyElement>>,
pub new_summary: Option<String>,
pub parsed_data: Option<serde_json::Value>,
}
/// Execute a tool call and return the result along with any state changes
pub fn execute_tool_call(
call: &ToolCall,
current_body: &[BodyElement],
current_summary: Option<&str>,
) -> ToolExecutionResult {
match call.name.as_str() {
"add_heading" => execute_add_heading(call, current_body),
"add_paragraph" => execute_add_paragraph(call, current_body),
"add_chart" => execute_add_chart(call, current_body),
"remove_element" => execute_remove_element(call, current_body),
"update_element" => execute_update_element(call, current_body),
"reorder_elements" => execute_reorder_elements(call, current_body),
"set_summary" => execute_set_summary(call, current_summary),
"parse_csv" => execute_parse_csv(call),
"clear_body" => execute_clear_body(),
_ => ToolExecutionResult {
result: ToolResult {
success: false,
message: format!("Unknown tool: {}", call.name),
},
new_body: None,
new_summary: None,
parsed_data: None,
},
}
}
fn execute_add_heading(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
let level = call.arguments.get("level").and_then(|v| v.as_u64()).unwrap_or(1) as u8;
let text = call
.arguments
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let position = call.arguments.get("position").and_then(|v| v.as_u64());
let element = BodyElement::Heading { level, text: text.clone() };
let mut new_body = current_body.to_vec();
if let Some(pos) = position {
let pos = pos as usize;
if pos <= new_body.len() {
new_body.insert(pos, element);
} else {
new_body.push(element);
}
} else {
new_body.push(element);
}
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Added heading: {}", text),
},
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
}
}
fn execute_add_paragraph(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
let text = call
.arguments
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let position = call.arguments.get("position").and_then(|v| v.as_u64());
let element = BodyElement::Paragraph { text: text.clone() };
let mut new_body = current_body.to_vec();
if let Some(pos) = position {
let pos = pos as usize;
if pos <= new_body.len() {
new_body.insert(pos, element);
} else {
new_body.push(element);
}
} else {
new_body.push(element);
}
let preview = if text.len() > 50 {
format!("{}...", &text[..50])
} else {
text
};
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Added paragraph: {}", preview),
},
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
}
}
fn execute_add_chart(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
let chart_type_str = call
.arguments
.get("chart_type")
.and_then(|v| v.as_str())
.unwrap_or("bar");
let chart_type = match chart_type_str {
"line" => ChartType::Line,
"bar" => ChartType::Bar,
"pie" => ChartType::Pie,
"area" => ChartType::Area,
_ => ChartType::Bar,
};
let title = call
.arguments
.get("title")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let data = call
.arguments
.get("data")
.cloned()
.unwrap_or(json!([]));
let config = call.arguments.get("config").cloned();
let position = call.arguments.get("position").and_then(|v| v.as_u64());
let element = BodyElement::Chart {
chart_type,
title: title.clone(),
data,
config,
};
let mut new_body = current_body.to_vec();
if let Some(pos) = position {
let pos = pos as usize;
if pos <= new_body.len() {
new_body.insert(pos, element);
} else {
new_body.push(element);
}
} else {
new_body.push(element);
}
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!(
"Added {} chart{}",
chart_type_str,
title.map(|t| format!(": {}", t)).unwrap_or_default()
),
},
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
}
}
fn execute_remove_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
let index = call.arguments.get("index").and_then(|v| v.as_u64());
let Some(index) = index else {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Missing index parameter".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
};
};
let index = index as usize;
if index >= current_body.len() {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: format!("Index {} out of bounds (body has {} elements)", index, current_body.len()),
},
new_body: None,
new_summary: None,
parsed_data: None,
};
}
let mut new_body = current_body.to_vec();
new_body.remove(index);
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Removed element at index {}", index),
},
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
}
}
fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
let index = call.arguments.get("index").and_then(|v| v.as_u64());
let element_json = call.arguments.get("element");
let Some(index) = index else {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Missing index parameter".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
};
};
let Some(element_json) = element_json else {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Missing element parameter".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
};
};
let index = index as usize;
if index >= current_body.len() {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: format!("Index {} out of bounds (body has {} elements)", index, current_body.len()),
},
new_body: None,
new_summary: None,
parsed_data: None,
};
}
let new_element: Result<BodyElement, _> = serde_json::from_value(element_json.clone());
match new_element {
Ok(element) => {
let mut new_body = current_body.to_vec();
new_body[index] = element;
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Updated element at index {}", index),
},
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
}
}
Err(e) => ToolExecutionResult {
result: ToolResult {
success: false,
message: format!("Invalid element format: {}", e),
},
new_body: None,
new_summary: None,
parsed_data: None,
},
}
}
fn execute_reorder_elements(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
let from_index = call.arguments.get("from_index").and_then(|v| v.as_u64());
let to_index = call.arguments.get("to_index").and_then(|v| v.as_u64());
let (Some(from), Some(to)) = (from_index, to_index) else {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Missing from_index or to_index parameter".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
};
};
let from = from as usize;
let to = to as usize;
if from >= current_body.len() || to >= current_body.len() {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: format!(
"Index out of bounds: from={}, to={}, body has {} elements",
from, to, current_body.len()
),
},
new_body: None,
new_summary: None,
parsed_data: None,
};
}
let mut new_body = current_body.to_vec();
let element = new_body.remove(from);
new_body.insert(to, element);
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Moved element from index {} to {}", from, to),
},
new_body: Some(new_body),
new_summary: None,
parsed_data: None,
}
}
fn execute_set_summary(call: &ToolCall, _current_summary: Option<&str>) -> ToolExecutionResult {
let summary = call
.arguments
.get("summary")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
ToolExecutionResult {
result: ToolResult {
success: true,
message: "Summary updated".to_string(),
},
new_body: None,
new_summary: Some(summary),
parsed_data: None,
}
}
fn execute_parse_csv(call: &ToolCall) -> ToolExecutionResult {
let csv = call
.arguments
.get("csv")
.and_then(|v| v.as_str())
.unwrap_or("");
let lines: Vec<&str> = csv.lines().collect();
if lines.is_empty() {
return ToolExecutionResult {
result: ToolResult {
success: false,
message: "Empty CSV data".to_string(),
},
new_body: None,
new_summary: None,
parsed_data: None,
};
}
let headers: Vec<&str> = lines[0].split(',').map(|s| s.trim()).collect();
let mut data: Vec<serde_json::Value> = Vec::new();
for line in lines.iter().skip(1) {
if line.trim().is_empty() {
continue;
}
let values: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
let mut row = serde_json::Map::new();
for (i, header) in headers.iter().enumerate() {
if let Some(value) = values.get(i) {
// Try to parse as number, otherwise use string
if let Ok(num) = value.parse::<f64>() {
row.insert(header.to_string(), json!(num));
} else {
row.insert(header.to_string(), json!(value));
}
}
}
data.push(serde_json::Value::Object(row));
}
ToolExecutionResult {
result: ToolResult {
success: true,
message: format!("Parsed {} rows from CSV", data.len()),
},
new_body: None,
new_summary: None,
parsed_data: Some(json!(data)),
}
}
fn execute_clear_body() -> ToolExecutionResult {
ToolExecutionResult {
result: ToolResult {
success: true,
message: "Cleared all body elements".to_string(),
},
new_body: Some(vec![]),
new_summary: None,
parsed_data: None,
}
}