use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Meta {
pub id: String,
pub timestamp: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cli_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
Assistant,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Message {
pub role: Role,
pub content: Vec<Block>,
pub timestamp: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_reason: Option<StopReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Block {
Text { text: String },
Thinking {
text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
encrypted: Option<String>,
},
ToolUse { id: String, tool: Tool },
ToolResult {
tool_use_id: String,
content: ToolOutput,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
is_error: bool,
},
Image { source: ImageSource },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
EndTurn,
ToolUse,
MaxTokens,
StopSequence,
Aborted,
Error,
Other(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Usage {
pub input_tokens: u64,
pub output_tokens: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_tokens: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImageSource {
#[serde(rename = "type")]
pub source_type: String,
pub media_type: String,
pub data: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ToolOutput {
Text(String),
Json(Value),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "name")]
pub enum Tool {
Command {
command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
args: Option<String>,
},
Read {
file_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
offset: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
limit: Option<u64>,
},
Write {
file_path: String,
content: String,
},
Edit {
file_path: String,
old_string: String,
new_string: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
replace_all: bool,
},
MultiEdit {
file_path: String,
edits: Vec<EditOp>,
},
Bash {
command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
workdir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
timeout_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
run_in_background: bool,
},
Raw {
tool_name: String,
input: Value,
},
}
impl Tool {
#[must_use]
pub fn from_canonical(name: &str, input: Value) -> Tool {
fn typed<A: for<'de> Deserialize<'de>>(input: &Value) -> Option<A> {
A::deserialize(input).ok()
}
match name {
_ if name.starts_with('/') => typed::<CommandArgs>(&input)
.map(|a| Tool::Command {
command: name.to_string(),
args: a.args,
})
.unwrap_or(Tool::Raw {
tool_name: name.to_string(),
input,
}),
"Read" => typed::<ReadArgs>(&input)
.map(|a| Tool::Read {
file_path: a.file_path,
offset: a.offset,
limit: a.limit,
})
.unwrap_or(Tool::Raw {
tool_name: name.to_string(),
input,
}),
"Write" => typed::<WriteArgs>(&input)
.map(|a| Tool::Write {
file_path: a.file_path,
content: a.content,
})
.unwrap_or(Tool::Raw {
tool_name: name.to_string(),
input,
}),
"Edit" => typed::<EditArgs>(&input)
.map(|a| Tool::Edit {
file_path: a.file_path,
old_string: a.old_string,
new_string: a.new_string,
replace_all: a.replace_all,
})
.unwrap_or(Tool::Raw {
tool_name: name.to_string(),
input,
}),
"MultiEdit" => typed::<MultiEditArgs>(&input)
.map(|a| Tool::MultiEdit {
file_path: a.file_path,
edits: a.edits,
})
.unwrap_or(Tool::Raw {
tool_name: name.to_string(),
input,
}),
"Bash" => typed::<BashArgs>(&input)
.map(|a| Tool::Bash {
command: a.command,
workdir: a.workdir,
timeout_ms: a.timeout_ms,
description: a.description,
run_in_background: a.run_in_background,
})
.unwrap_or(Tool::Raw {
tool_name: name.to_string(),
input,
}),
other => Tool::Raw {
tool_name: other.to_string(),
input,
},
}
}
#[must_use]
pub fn to_canonical(&self) -> (String, Value) {
let value = |v: serde_json::Result<Value>| v.unwrap_or(Value::Null);
match self {
Tool::Command { command, args } => (
command.clone(),
value(serde_json::to_value(CommandArgs {
args: args.as_deref(),
})),
),
Tool::Read {
file_path,
offset,
limit,
} => (
"Read".into(),
value(serde_json::to_value(ReadArgs {
file_path: file_path.as_str(),
offset: *offset,
limit: *limit,
})),
),
Tool::Write { file_path, content } => (
"Write".into(),
value(serde_json::to_value(WriteArgs {
file_path: file_path.as_str(),
content: content.as_str(),
})),
),
Tool::Edit {
file_path,
old_string,
new_string,
replace_all,
} => (
"Edit".into(),
value(serde_json::to_value(EditArgs {
file_path: file_path.as_str(),
old_string: old_string.as_str(),
new_string: new_string.as_str(),
replace_all: *replace_all,
})),
),
Tool::MultiEdit { file_path, edits } => (
"MultiEdit".into(),
value(serde_json::to_value(MultiEditArgs {
file_path: file_path.as_str(),
edits: edits.as_slice(),
})),
),
Tool::Bash {
command,
workdir,
timeout_ms,
description,
run_in_background,
} => (
"Bash".into(),
value(serde_json::to_value(BashArgs {
command: command.as_str(),
workdir: workdir.as_deref(),
timeout_ms: *timeout_ms,
description: description.as_deref(),
run_in_background: *run_in_background,
})),
),
Tool::Raw { tool_name, input } => (tool_name.clone(), input.clone()),
}
}
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct CommandArgs<S = String> {
#[serde(default, skip_serializing_if = "Option::is_none")]
args: Option<S>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReadArgs<S = String> {
file_path: S,
#[serde(default, skip_serializing_if = "Option::is_none")]
offset: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
limit: Option<u64>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WriteArgs<S = String> {
file_path: S,
content: S,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct EditArgs<S = String> {
file_path: S,
old_string: S,
new_string: S,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
replace_all: bool,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct MultiEditArgs<S = String, E = Vec<EditOp>> {
file_path: S,
edits: E,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct BashArgs<S = String> {
command: S,
#[serde(default, skip_serializing_if = "Option::is_none")]
workdir: Option<S>,
#[serde(default, skip_serializing_if = "Option::is_none")]
timeout_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<S>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
run_in_background: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn typed_tool_round_trips() {
let input = json!({
"file_path": "/a/b.rs",
"old_string": "x",
"new_string": "y",
});
let tool = Tool::from_canonical("Edit", input.clone());
assert!(matches!(tool, Tool::Edit { .. }));
let (name, back) = tool.to_canonical();
assert_eq!(name, "Edit");
assert_eq!(back, input);
}
#[test]
fn edit_preserves_replace_all() {
let input = json!({
"file_path": "/a", "old_string": "x", "new_string": "y", "replace_all": true,
});
let (_, back) = Tool::from_canonical("Edit", input.clone()).to_canonical();
assert_eq!(back, input);
}
#[test]
fn unknown_key_on_known_tool_falls_back_to_raw_losslessly() {
let input = json!({"file_path": "/a", "surprise": 1});
let tool = Tool::from_canonical("Read", input.clone());
match &tool {
Tool::Raw {
tool_name,
input: kept,
} => {
assert_eq!(tool_name, "Read");
assert_eq!(*kept, input);
}
other => panic!("expected Raw, got {other:?}"),
}
assert_eq!(tool.to_canonical(), ("Read".to_string(), input));
}
#[test]
fn mcp_tool_is_raw() {
let input = json!({"q": "hello"});
let tool = Tool::from_canonical("mcp__search__query", input.clone());
assert_eq!(
tool.to_canonical(),
("mcp__search__query".to_string(), input)
);
}
#[test]
fn bash_keeps_incidental_fields() {
let input = json!({"command": "ls", "description": "list", "timeout_ms": 5000});
let tool = Tool::from_canonical("Bash", input.clone());
assert!(matches!(tool, Tool::Bash { .. }));
assert_eq!(tool.to_canonical().1, input);
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EditOp {
pub old_string: String,
pub new_string: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub replace_all: bool,
}