use pushkin_core::edits::Replacement;
use serde_json::Value;
use super::super::shared::{patch_action, relativize, session_from};
use super::super::{FileWrite, Intent, ParseOutcome, ToolAction};
pub(crate) fn normalize_claude_family(value: &Value) -> ParseOutcome {
let session = session_from(value, "session_id");
let Some(tool_input) = value.get("tool_input") else {
if value.get("stop_hook_active").is_some() {
return Ok(ToolAction {
session,
files: vec![],
is_stop: true,
intent: Intent::Write,
command: None,
});
}
return Err(None);
};
let tool_name = value.get("tool_name").and_then(Value::as_str);
if tool_name == Some("Bash") {
if let Some(command) = tool_input.get("command").and_then(Value::as_str) {
return Ok(ToolAction {
session,
files: vec![],
is_stop: false,
intent: Intent::Shell,
command: Some(command.to_owned()),
});
}
}
let path = tool_input
.get("file_path")
.and_then(Value::as_str)
.map(relativize)
.ok_or(None)?;
if matches!(tool_name, Some("Read" | "NotebookRead")) {
let bounded = tool_input.get("offset").is_some() || tool_input.get("limit").is_some();
return Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: String::new(),
edits: Vec::new(),
}],
is_stop: false,
intent: if bounded {
Intent::ReadRange
} else {
Intent::ReadWhole
},
command: None,
});
}
let mutation_tool = match tool_name {
Some("Edit") => Some("Edit"),
Some("MultiEdit") => Some("MultiEdit"),
_ => None,
};
if let Some(tool) = mutation_tool {
return Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: String::new(),
edits: claude_replacements(tool_input),
}],
is_stop: false,
intent: Intent::MutateNoContent(tool),
command: None,
});
}
let content = tool_input.get("content").and_then(Value::as_str);
match (tool_name.is_some(), content) {
(true, Some(content)) => Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: content.to_owned(),
edits: Vec::new(),
}],
is_stop: false,
intent: Intent::Write,
command: None,
}),
_ => Err(Some(path)),
}
}
pub fn claude_replacements(tool_input: &Value) -> Vec<Replacement> {
if let Some(edits) = tool_input.get("edits").and_then(Value::as_array) {
return edits.iter().filter_map(one_replacement).collect();
}
one_replacement(tool_input).into_iter().collect()
}
pub(crate) fn one_replacement(value: &Value) -> Option<Replacement> {
Some(Replacement {
old: value.get("old_string").and_then(Value::as_str)?.to_owned(),
new: value.get("new_string").and_then(Value::as_str)?.to_owned(),
replace_all: value
.get("replace_all")
.and_then(Value::as_bool)
.unwrap_or(false),
anchor: None,
})
}
pub fn auggie_replacements(tool_input: &Value) -> Vec<Replacement> {
let mut replacements = Vec::new();
let mut index = 1;
while let Some(old) = string_field(tool_input, "old_str", index) {
let Some(new) = string_field(tool_input, "new_str", index) else {
return Vec::new();
};
replacements.push(Replacement {
old,
new,
replace_all: false,
anchor: line_anchor(tool_input, index),
});
index += 1;
}
if names_fragment_at_or_beyond(tool_input, index) {
return Vec::new();
}
replacements
}
pub(crate) fn string_field(tool_input: &Value, prefix: &str, index: usize) -> Option<String> {
tool_input
.get(format!("{prefix}_{index}"))
.and_then(Value::as_str)
.map(str::to_owned)
}
pub(crate) fn line_anchor(
tool_input: &Value,
index: usize,
) -> Option<pushkin_core::edits::LineSpan> {
let bound = |name: &str| {
tool_input
.get(format!("old_str_{name}_line_number_{index}"))
.and_then(Value::as_u64)
.and_then(|value| usize::try_from(value).ok())
};
Some(pushkin_core::edits::LineSpan {
start: bound("start")?,
end: bound("end")?,
})
}
pub(crate) fn names_fragment_at_or_beyond(tool_input: &Value, from: usize) -> bool {
tool_input
.as_object()
.into_iter()
.flatten()
.filter_map(|(key, _)| key.strip_prefix("old_str_"))
.filter_map(|suffix| suffix.parse::<usize>().ok())
.any(|index| index >= from)
}
pub(crate) fn normalize_codex(value: &Value) -> ParseOutcome {
if value
.get("tool_input")
.and_then(|input| input.get("file_path"))
.is_some()
{
return normalize_claude_family(value);
}
let session = session_from(value, "session_id");
let Some(tool_input) = value.get("tool_input") else {
if value.get("stop_hook_active").is_some() {
return Ok(ToolAction {
session,
files: vec![],
is_stop: true,
intent: Intent::Write,
command: None,
});
}
return Err(None);
};
let command = tool_input
.get("command")
.and_then(Value::as_str)
.ok_or(None)?;
patch_action(session, command)
}
pub(crate) fn normalize_auggie(value: &Value) -> ParseOutcome {
let session = session_from(value, "conversation_id");
let Some(tool_input) = value.get("tool_input") else {
if value.get("hook_event_name").and_then(Value::as_str) == Some("Stop") {
return Ok(ToolAction {
session,
files: vec![],
is_stop: true,
intent: Intent::Write,
command: None,
});
}
return Err(None);
};
let path = tool_input
.get("path")
.or_else(|| tool_input.get("file_path"))
.and_then(Value::as_str)
.map(relativize)
.ok_or(None)?;
let tool_name = value.get("tool_name").and_then(Value::as_str);
if tool_name == Some("view") {
let bounded = tool_input.get("view_range").is_some()
|| tool_input.get("search_query_regex").is_some();
return Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: String::new(),
edits: Vec::new(),
}],
is_stop: false,
intent: if bounded {
Intent::ReadRange
} else {
Intent::ReadWhole
},
command: None,
});
}
if tool_name == Some("str-replace-editor") {
return Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: String::new(),
edits: auggie_replacements(tool_input),
}],
is_stop: false,
intent: Intent::MutateNoContent("str-replace-editor"),
command: None,
});
}
let well_formed = tool_name.is_some();
let content = tool_input
.get("file_content")
.or_else(|| tool_input.get("content"))
.and_then(Value::as_str);
match (well_formed, content) {
(true, Some(content)) => Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: content.to_owned(),
edits: Vec::new(),
}],
is_stop: false,
intent: Intent::Write,
command: None,
}),
_ => Err(Some(path)),
}
}