use super::*;
pub(crate) fn collect_tool_use_paths(blocks: Option<&[Block]>, out: &mut BTreeMap<String, String>) {
let Some(blocks) = blocks else { return };
for b in blocks {
if let Block::ToolUse {
id: Some(id),
name: Some(name),
input: Some(input),
} = b
{
let key = match name.as_str() {
"Read" | "Edit" | "Write" | "MultiEdit" => "file_path",
"NotebookEdit" => "notebook_path",
_ => continue,
};
if let Some(p) = input.get(key).and_then(serde_json::Value::as_str) {
if !p.is_empty() {
out.insert(id.clone(), p.to_string());
}
}
}
}
}
pub(crate) fn extract_input_fallback(
line_no: usize,
turn_index: usize,
rec: &Record,
target_file: Option<&str>,
ids_with_result: &std::collections::HashSet<String>,
failed_ids: &std::collections::HashSet<String>,
events: &mut Vec<FileEvent>,
) {
let ts = rec.timestamp.clone();
let Some(blocks) = rec.blocks() else { return };
for b in blocks {
let Block::ToolUse {
id,
name: Some(name),
input: Some(input),
} = b
else {
continue;
};
if let Some(id) = id {
if ids_with_result.contains(id) || failed_ids.contains(id) {
continue;
}
}
let path = input
.get("file_path")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if !path_matches(target_file, path) {
continue;
}
match name.as_str() {
"Write" => {
if let Some(content) = input.get("content").and_then(serde_json::Value::as_str) {
events.push(FileEvent {
line_no,
turn_index,
timestamp_utc: ts.clone(),
kind: EventKind::FullSnapshot {
content: content.to_string(),
total_lines: line_count(content),
source: SnapSource::Write,
},
});
}
}
"Edit" => {
let hunks = vec![EditHunk {
old_string: input
.get("old_string")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
new_string: input
.get("new_string")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
replace_all: input
.get("replace_all")
.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: None,
structured_patch: None,
},
});
}
"MultiEdit" => {
let hunks: Vec<EditHunk> = input
.get("edits")
.and_then(serde_json::Value::as_array)
.map(|arr| {
arr.iter()
.map(|e| EditHunk {
old_string: e
.get("old_string")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
new_string: e
.get("new_string")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
replace_all: e
.get("replace_all")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
})
.collect()
})
.unwrap_or_default();
if !hunks.is_empty() {
events.push(FileEvent {
line_no,
turn_index,
timestamp_utc: ts.clone(),
kind: EventKind::Edit {
hunks,
original_file: None,
structured_patch: None,
},
});
}
}
_ => {}
}
}
}
pub(crate) fn path_matches(target: Option<&str>, path: &str) -> bool {
let Some(t) = target else { return false };
if t == path {
return true;
}
path.strip_suffix(t)
.map(|prefix| prefix.is_empty() || prefix.ends_with(['/', '\\']))
.unwrap_or(false)
}
pub(crate) fn extract_from_record(
line_no: usize,
turn_index: usize,
rec: &Record,
target_file: Option<&str>,
id_to_path: &BTreeMap<String, String>,
events: &mut Vec<FileEvent>,
) {
let ts = rec.timestamp.clone();
if let Some(snap) = rec.snapshot.as_ref() {
if let Some(tfb) = snap.get("trackedFileBackups").and_then(|v| v.as_object()) {
for path in tfb.keys() {
if path_matches(target_file, path) {
events.push(FileEvent {
line_no,
turn_index,
timestamp_utc: snap
.get("timestamp")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.or_else(|| ts.clone()),
kind: EventKind::HistorySnapshotMarker,
});
}
}
}
}
if let Some(att) = rec.attachment_value() {
extract_from_attachment(line_no, turn_index, &ts, &att, target_file, events);
}
if let Some(tur) = rec.tool_use_result_value() {
extract_from_tool_use_result(
line_no,
turn_index,
&ts,
&tur,
rec.cwd.as_deref(),
target_file,
events,
);
}
let Some(blocks) = rec.blocks() else { return };
for b in blocks {
match b {
Block::ToolResult {
tool_use_id,
content: Some(content),
is_error: Some(true),
} => {
if let Some(kind) = classify_integrity_error(content) {
let attributed = tool_use_id
.as_ref()
.and_then(|id| id_to_path.get(id))
.map(String::as_str);
if path_matches(target_file, attributed.unwrap_or_default()) {
events.push(FileEvent {
line_no,
turn_index,
timestamp_utc: ts.clone(),
kind: EventKind::IntegrityError {
kind,
raw: crate::model::tool_result_content_text(content),
},
});
}
}
}
Block::ToolUse {
name: Some(name),
input: Some(input),
..
} if name == "Bash" => {
if let Some(cmd) = input.get("command").and_then(serde_json::Value::as_str) {
for bm in crate::bash_mutations::parse_bash_mutations(cmd) {
if crate::bash_mutations::is_class_marker(&bm.path) {
continue;
}
let (resolved, resolution) = bm.resolve(rec.cwd.as_deref());
if path_matches(target_file, &resolved)
|| path_matches(target_file, &bm.path)
{
events.push(FileEvent {
line_no,
turn_index,
timestamp_utc: ts.clone(),
kind: EventKind::BashTouch {
verb: bm.verb.to_string(),
path: resolved,
resolution: resolution.as_str(),
},
});
}
}
}
}
_ => {}
}
}
}