use super::*;
#[allow(clippy::too_many_arguments)]
pub(crate) fn extract_from_tool_use_result(
line_no: usize,
turn_index: usize,
ts: &Option<String>,
tur: &serde_json::Value,
record_cwd: Option<&str>,
target_file: Option<&str>,
events: &mut Vec<FileEvent>,
) {
if let Some(hint) = tur
.get("staleReadFileStateHint")
.and_then(serde_json::Value::as_str)
{
if let Some((paths, _more)) = parse_stale_read_hint(hint) {
for p in paths {
let resolved = if crate::bash_mutations::is_absolute_shell_path(&p) {
p
} else if let Some(cwd) =
record_cwd.filter(|c| crate::bash_mutations::is_absolute_shell_path(c))
{
crate::bash_mutations::join_shell_path(cwd, &[&p])
} else {
p
};
if path_matches(target_file, &resolved) {
events.push(FileEvent {
line_no,
turn_index,
timestamp_utc: ts.clone(),
kind: EventKind::StaleReadHint { path: resolved },
});
}
}
}
}
if let Some(file) = tur.get("file").and_then(|v| v.as_object()) {
let path = file.get("filePath").and_then(serde_json::Value::as_str);
if path_matches(target_file, path.unwrap_or_default()) {
let content = file
.get("content")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let start_line = file
.get("startLine")
.and_then(serde_json::Value::as_u64)
.unwrap_or(1) as usize;
let total_lines = file
.get("totalLines")
.and_then(serde_json::Value::as_u64)
.map(|n| n as usize);
let num_lines = file
.get("numLines")
.and_then(serde_json::Value::as_u64)
.map(|n| n as usize);
let truncated = file
.get("truncatedByTokenCap")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
push_read_event(
line_no,
turn_index,
ts,
&content,
start_line,
num_lines,
total_lines,
truncated,
SnapSource::FullRead,
events,
);
return;
}
}
let path = tur.get("filePath").and_then(serde_json::Value::as_str);
if !path_matches(target_file, path.unwrap_or_default()) {
return;
}
let has_edit_strings = tur.get("oldString").is_some() || tur.get("newString").is_some();
let structured_patch = parse_structured_patch(tur.get("structuredPatch"));
if has_edit_strings {
let hunks = vec![EditHunk {
old_string: tur
.get("oldString")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
new_string: tur
.get("newString")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
replace_all: tur
.get("replaceAll")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
}];
events.push(FileEvent {
line_no,
turn_index,
timestamp_utc: ts.clone(),
kind: EventKind::Edit {
hunks,
original_file: tur
.get("originalFile")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
structured_patch,
},
});
if tur
.get("staleRecovered")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
events.push(FileEvent {
line_no,
turn_index,
timestamp_utc: ts.clone(),
kind: EventKind::StaleRecovered,
});
}
return;
}
if let Some(content) = tur.get("content").and_then(serde_json::Value::as_str) {
let total = line_count(content);
events.push(FileEvent {
line_no,
turn_index,
timestamp_utc: ts.clone(),
kind: EventKind::FullSnapshot {
content: content.to_string(),
total_lines: total,
source: SnapSource::Write,
},
});
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn push_read_event(
line_no: usize,
turn_index: usize,
ts: &Option<String>,
content: &str,
start_line: usize,
num_lines: Option<usize>,
total_lines: Option<usize>,
truncated: bool,
source: SnapSource,
events: &mut Vec<FileEvent>,
) {
let mut lines: Vec<String> = split_lines(content);
let observed = num_lines.unwrap_or(lines.len());
let total = total_lines.unwrap_or(observed.max(start_line + lines.len().saturating_sub(1)));
if truncated && observed >= total {
lines.pop();
if lines.is_empty() {
return;
}
}
let is_full = !truncated && start_line == 1 && observed >= total && total > 0;
if is_full {
events.push(FileEvent {
line_no,
turn_index,
timestamp_utc: ts.clone(),
kind: EventKind::FullSnapshot {
content: content.to_string(),
total_lines: total.max(lines.len()),
source,
},
});
} else {
events.push(FileEvent {
line_no,
turn_index,
timestamp_utc: ts.clone(),
kind: EventKind::PartialRead {
start_line: start_line.max(1),
lines,
total_lines: total,
},
});
}
}
pub(crate) fn extract_from_attachment(
line_no: usize,
turn_index: usize,
ts: &Option<String>,
att: &serde_json::Value,
target_file: Option<&str>,
events: &mut Vec<FileEvent>,
) {
let atype = att.get("type").and_then(serde_json::Value::as_str);
if atype == Some("edited_text_file") {
let path = att
.get("filename")
.or_else(|| att.get("filePath"))
.and_then(serde_json::Value::as_str);
if path_matches(target_file, path.unwrap_or_default()) {
let snippet_text = att
.get("snippet")
.or_else(|| att.get("content"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let snippet = strip_gutter(snippet_text);
events.push(FileEvent {
line_no,
turn_index,
timestamp_utc: ts.clone(),
kind: EventKind::ExternalEdit { snippet },
});
}
return;
}
if let Some(file) = att
.get("content")
.and_then(|c| c.get("file"))
.or_else(|| att.get("file"))
{
let path = file.get("filePath").and_then(serde_json::Value::as_str);
if path_matches(target_file, path.unwrap_or_default()) {
let content = file
.get("content")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let start_line = file
.get("startLine")
.and_then(serde_json::Value::as_u64)
.unwrap_or(1) as usize;
let total_lines = file
.get("totalLines")
.and_then(serde_json::Value::as_u64)
.map(|n| n as usize);
let num_lines = file
.get("numLines")
.and_then(serde_json::Value::as_u64)
.map(|n| n as usize);
let truncated = file
.get("truncatedByTokenCap")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
push_read_event(
line_no,
turn_index,
ts,
&content,
start_line,
num_lines,
total_lines,
truncated,
SnapSource::FileAttachment,
events,
);
}
}
}
pub(crate) fn classify_integrity_error(content: &serde_json::Value) -> Option<IntegrityKind> {
let text = crate::model::tool_result_content_text(content);
if text.contains("has been modified since read") || text.contains("File has been modified") {
Some(IntegrityKind::ModifiedSinceRead)
} else if text.contains("has not been read yet") || text.contains("Read it first") {
Some(IntegrityKind::NotReadYet)
} else if text.contains("String to replace not found in file") {
Some(IntegrityKind::StringNotFound)
} else if text.contains("File does not exist") {
Some(IntegrityKind::FileDoesNotExist)
} else {
None
}
}
pub(crate) fn parse_stale_read_hint(hint: &str) -> Option<(Vec<String>, usize)> {
let rest = hint.strip_prefix("[This command modified ")?;
let colon = rest.find(": ")?;
let mut tail = &rest[colon + 2..];
if let Some(end) = tail.rfind(". Call Read before editing.]") {
tail = &tail[..end];
} else if let Some(stripped) = tail.strip_suffix(']') {
tail = stripped;
}
let mut more = 0usize;
if let Some(pos) = tail.rfind(" and ") {
if let Some(n) = tail[pos + 5..]
.strip_suffix(" more")
.and_then(|n| n.trim().parse::<usize>().ok())
{
more = n;
tail = &tail[..pos];
}
}
let paths: Vec<String> = tail
.split(", ")
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect();
(!paths.is_empty()).then_some((paths, more))
}
pub(crate) fn parse_structured_patch(v: Option<&serde_json::Value>) -> Option<Vec<PatchHunk>> {
let arr = v?.as_array()?;
let mut out = Vec::with_capacity(arr.len());
for h in arr {
let old_start = h.get("oldStart").and_then(serde_json::Value::as_u64)? as usize;
let old_lines = h.get("oldLines").and_then(serde_json::Value::as_u64)? as usize;
let new_lines = h.get("newLines").and_then(serde_json::Value::as_u64)? as usize;
let lines = h
.get("lines")
.and_then(serde_json::Value::as_array)
.map(|a| {
a.iter()
.filter_map(|l| l.as_str().map(str::to_string))
.collect::<Vec<_>>()
})
.unwrap_or_default();
out.push(PatchHunk {
old_start,
old_lines,
new_lines,
lines,
});
}
Some(out)
}