use super::*;
use crate::bash_mutations::{bash_anchors, parse_bash_mutations, AnchorCmd, BashMutation, CwdAt};
#[derive(Debug, Default)]
pub(crate) struct TurnBashAnchors {
pub(crate) events: Vec<FileEvent>,
pub(crate) suppress: Vec<(usize, String)>,
}
struct BashUse<'a> {
line_no: usize,
ts: Option<String>,
cmd: &'a str,
cwd: Option<&'a str>,
}
pub(crate) fn collect_turn_bash_anchors(
records: &[(usize, Record)],
idxs: &[usize],
target_file: Option<&str>,
failed_ids: &std::collections::HashSet<String>,
) -> TurnBashAnchors {
let mut out = TurnBashAnchors::default();
if target_file.is_none() {
return out;
}
let mut uses: BTreeMap<&str, BashUse> = BTreeMap::new();
for &i in idxs {
let (line_no, rec) = (&records[i].0, &records[i].1);
let Some(blocks) = rec.blocks() else { continue };
for b in blocks {
if let Block::ToolUse {
id: Some(id),
name: Some(name),
input: Some(input),
} = b
{
if name == "Bash" {
if let Some(cmd) = input.get("command").and_then(serde_json::Value::as_str) {
uses.insert(
id.as_str(),
BashUse {
line_no: *line_no,
ts: rec.timestamp.clone(),
cmd,
cwd: rec.cwd.as_deref(),
},
);
}
}
}
}
}
if uses.is_empty() {
return out;
}
for (id, bu) in &uses {
if failed_ids.contains(*id) {
continue;
}
let anchors = bash_anchors(bu.cmd);
if anchors.read.is_none() && anchors.writes.is_empty() {
continue;
}
let clean_echo =
anchors.writes.is_empty() || !anchors.multi_segment || clean_result(records, idxs, id);
let resolved_rows: Vec<(String, String)> = parse_bash_mutations(bu.cmd)
.into_iter()
.filter(|r| !crate::bash_mutations::is_class_marker(&r.path))
.map(|r| {
let (res, _) = r.resolve(bu.cwd);
(r.path, res)
})
.collect();
for anchor in anchors.writes {
if !clean_echo {
break;
}
let (operand, content, heredoc, append) = match anchor {
AnchorCmd::WriteFull {
operand,
content,
heredoc,
} => (operand, content, heredoc, false),
AnchorCmd::Append {
operand,
content,
heredoc,
} => (operand, content, heredoc, true),
AnchorCmd::ReadFull { .. } | AnchorCmd::ReadWindow { .. } => continue,
};
let (resolved, _res) = BashMutation {
path: operand.clone(),
verb: "anchor",
cwd_at: CwdAt::Spawn,
}
.resolve(bu.cwd);
let hits = resolved_rows
.iter()
.filter(|(p, res)| *p == operand || *res == resolved)
.count();
if hits > 1 {
continue;
}
if !path_matches(target_file, &resolved) && !path_matches(target_file, &operand) {
continue;
}
if append {
out.events.push(FileEvent {
line_no: bu.line_no,
turn_index: 0, timestamp_utc: bu.ts.clone(),
kind: EventKind::BashAppend { content },
});
} else {
out.events.push(FileEvent {
line_no: bu.line_no,
turn_index: 0,
timestamp_utc: bu.ts.clone(),
kind: EventKind::FullSnapshot {
total_lines: line_count(&content),
content,
source: if heredoc {
SnapSource::BashHeredoc
} else {
SnapSource::BashWrite
},
},
});
}
out.suppress.push((bu.line_no, resolved));
out.suppress.push((bu.line_no, operand));
}
let Some(read) = anchors.read else { continue };
let operand = match &read {
AnchorCmd::ReadFull { operand } | AnchorCmd::ReadWindow { operand, .. } => {
operand.clone()
}
_ => continue,
};
let (resolved, _res) = BashMutation {
path: operand.clone(),
verb: "anchor",
cwd_at: CwdAt::Spawn,
}
.resolve(bu.cwd);
if !path_matches(target_file, &resolved) && !path_matches(target_file, &operand) {
continue;
}
match read {
AnchorCmd::ReadFull { .. } => {
if let Some((line_no, ts, stdout)) = gated_stdout(records, idxs, id) {
out.events.push(FileEvent {
line_no,
turn_index: 0,
timestamp_utc: ts,
kind: EventKind::FullSnapshot {
total_lines: line_count(&stdout),
content: stdout,
source: SnapSource::BashCat,
},
});
}
}
AnchorCmd::WriteFull { .. } | AnchorCmd::Append { .. } => {}
AnchorCmd::ReadWindow { start, end, .. } => {
let Some((line_no, ts, stdout)) = gated_stdout(records, idxs, id) else {
continue;
};
let lines = crate::recover::split_lines(&stdout);
if lines.is_empty() {
continue; }
if let Some(e) = end {
let expected = e - start + 1;
if lines.len() > expected {
continue; }
}
let hit_eof = end.is_none_or(|e| lines.len() < e - start + 1);
if start == 1 && hit_eof {
out.events.push(FileEvent {
line_no,
turn_index: 0,
timestamp_utc: ts,
kind: EventKind::FullSnapshot {
total_lines: line_count(&stdout),
content: stdout,
source: SnapSource::BashCat,
},
});
} else {
out.events.push(FileEvent {
line_no,
turn_index: 0,
timestamp_utc: ts,
kind: EventKind::BashWindowRead {
start_line: start,
lines,
},
});
}
}
}
}
out
}
fn clean_result(records: &[(usize, Record)], idxs: &[usize], id: &str) -> bool {
for &i in idxs {
let rec = &records[i].1;
let Some(blocks) = rec.blocks() else { continue };
let carries = blocks
.iter()
.any(|b| matches!(b, Block::ToolResult { tool_use_id: Some(tid), .. } if tid == id));
if !carries {
continue;
}
let Some(tur) = rec.tool_use_result_value() else {
return false;
};
let stderr_clean = tur
.get("stderr")
.and_then(serde_json::Value::as_str)
.is_none_or(|s| s.trim().is_empty());
let interrupted = tur
.get("interrupted")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
return stderr_clean && !interrupted;
}
false
}
fn gated_stdout(
records: &[(usize, Record)],
idxs: &[usize],
id: &str,
) -> Option<(usize, Option<String>, String)> {
for &i in idxs {
let (line_no, rec) = (&records[i].0, &records[i].1);
let Some(blocks) = rec.blocks() else { continue };
let carries = blocks
.iter()
.any(|b| matches!(b, Block::ToolResult { tool_use_id: Some(tid), .. } if tid == id));
if !carries {
continue;
}
let tur = rec.tool_use_result_value()?;
let stdout = tur.get("stdout").and_then(serde_json::Value::as_str)?;
let stderr_clean = tur
.get("stderr")
.and_then(serde_json::Value::as_str)
.is_none_or(|s| s.trim().is_empty());
let interrupted = tur
.get("interrupted")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let persisted = tur.get("persistedOutputPath").is_some();
if stderr_clean && !interrupted && !persisted {
return Some((*line_no, rec.timestamp.clone(), stdout.to_string()));
}
return None;
}
None
}