use super::*;
pub(crate) fn resolve_cutoff(when: &str, events: &[FileEvent]) -> Result<Option<usize>> {
let when = when.trim();
if when.is_empty() || when == "@latest" {
return Ok(None);
}
if let Some(rest) = when.strip_prefix("@line:") {
let n: usize = rest
.trim()
.parse()
.with_context(|| format!("--at @line:<N> needs an integer, got {rest:?}"))?;
return Ok(Some(n));
}
if let Some(rest) = when.strip_prefix("@turn:") {
let target: usize = rest
.trim()
.parse()
.with_context(|| format!("--at @turn:<N> needs an integer, got {rest:?}"))?;
let cutoff = events
.iter()
.filter(|e| e.turn_index <= target)
.map(|e| e.line_no)
.max();
return Ok(Some(cutoff.unwrap_or(0)));
}
let window = TimeWindow::from_args(None, Some(when))?;
let cutoff = events
.iter()
.filter(|e| window.contains(e.timestamp_utc.as_deref()))
.map(|e| e.line_no)
.max();
Ok(Some(cutoff.unwrap_or(0)))
}
#[derive(Debug)]
pub(crate) struct RenderCtx {
pub(crate) mode: RecoverMode,
pub(crate) file: Option<String>,
pub(crate) line_range: Option<crate::text::RangeSpec>,
pub(crate) at: Option<String>,
pub(crate) skipped_lines: usize,
pub(crate) scope_top: usize,
pub(crate) scope_sub: usize,
}
pub(crate) fn apply_line_range(
lines: Vec<(usize, String)>,
line_range: Option<crate::text::RangeSpec>,
) -> Vec<(usize, String)> {
match line_range {
Some(spec) => {
let len = lines.iter().map(|(n, _)| *n).max().unwrap_or(0);
let (lo, hi) = spec.resolve(len, true);
lines
.into_iter()
.filter(|(n, _)| *n >= lo && *n <= hi)
.collect()
}
None => lines,
}
}
pub(crate) fn write_out_guarded(p: &Path, blob: &str) -> Result<bool> {
if blob.is_empty() {
eprintln!(
"note: nothing reconstructed in range; --out file {} left untouched",
p.display()
);
return Ok(false);
}
std::fs::write(p, blob).with_context(|| format!("cannot write --out file {}", p.display()))?;
Ok(true)
}
pub(crate) fn scope_span(sessions: &[ScanResult]) -> (usize, usize) {
let sub = sessions.iter().filter(|s| s.is_subagent).count();
(sessions.len() - sub, sub)
}
pub(crate) fn cmp_ts(a: &Option<String>, b: &Option<String>) -> std::cmp::Ordering {
use std::cmp::Ordering;
match (a, b) {
(Some(x), Some(y)) => x.cmp(y),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
}
}
pub(crate) fn merge_groups_for_reconstruction(sessions: Vec<ScanResult>) -> Vec<ScanResult> {
let mut groups: BTreeMap<String, Vec<ScanResult>> = BTreeMap::new();
for s in sessions {
groups
.entry(s.parent_session_id.clone())
.or_default()
.push(s);
}
let mut out: Vec<ScanResult> = Vec::new();
for (parent_key, group) in groups {
let contributing = group.iter().filter(|s| !s.events.is_empty()).count();
if contributing < 2 {
out.extend(group);
continue;
}
let merged_id = group
.iter()
.find(|s| !s.is_subagent)
.map(|s| s.session_id.clone())
.unwrap_or_else(|| parent_key.clone());
let mut tagged: Vec<(String, FileEvent)> = group
.iter()
.flat_map(|s| s.events.iter().cloned().map(|e| (s.session_id.clone(), e)))
.collect();
tagged.sort_by(|a, b| cmp_ts(&a.1.timestamp_utc, &b.1.timestamp_utc));
let mut merged_line_origin: BTreeMap<usize, (String, usize)> = BTreeMap::new();
let mut events: Vec<FileEvent> = Vec::with_capacity(tagged.len());
for (i, (sid, mut e)) in tagged.into_iter().enumerate() {
merged_line_origin.insert(i + 1, (sid, e.line_no));
e.line_no = i + 1;
events.push(e);
}
let mut opaque: Vec<OpaqueCommand> = group
.iter()
.flat_map(|s| s.opaque.iter().cloned())
.collect();
opaque.sort_by(|a, b| cmp_ts(&a.timestamp_utc, &b.timestamp_utc));
out.push(ScanResult {
session_id: merged_id.clone(),
is_subagent: false,
parent_session_id: merged_id,
events,
opaque,
merged_line_origin,
skipped_lines: 0,
});
}
out
}
pub(crate) struct WindowDisclosure {
pub(crate) classes: BTreeMap<String, usize>,
pub(crate) opaque_classes: usize,
pub(crate) powershell: usize,
pub(crate) suggested: Option<String>,
}
impl WindowDisclosure {
pub(crate) fn is_clean(&self) -> bool {
self.opaque_classes == 0 && self.powershell == 0
}
}
pub(crate) fn window_disclosure(s: &ScanResult, file: Option<&str>) -> WindowDisclosure {
let mut classes: BTreeMap<String, usize> = BTreeMap::new();
let mut powershell = 0usize;
for o in &s.opaque {
if o.marker == "powershell" {
powershell += 1;
} else {
*classes.entry(o.marker.clone()).or_default() += 1;
}
}
let opaque_classes = classes.values().sum();
let first = s
.events
.iter()
.filter_map(|e| e.timestamp_utc.as_deref())
.min();
let last = s
.events
.iter()
.filter_map(|e| e.timestamp_utc.as_deref())
.max();
let suggested =
file.map(|f| suggested_search(basename_of(f), &s.parent_session_id, first, last));
WindowDisclosure {
classes,
opaque_classes,
powershell,
suggested,
}
}
pub(crate) fn opaque_note(d: &WindowDisclosure) -> Option<String> {
if d.is_clean() {
return None;
}
let mut parts: Vec<String> = Vec::new();
if d.opaque_classes > 0 {
let list = d
.classes
.iter()
.map(|(m, n)| {
if *n > 1 {
format!("{m} x{n}")
} else {
m.clone()
}
})
.collect::<Vec<_>>()
.join(", ");
parts.push(format!(
"{} mutating-class command(s) whose file set is not in the command text ({list})",
d.opaque_classes
));
}
if d.powershell > 0 {
parts.push(format!(
"{} PowerShell command(s), never parsed",
d.powershell
));
}
Some(parts.join(" and "))
}
pub(crate) fn boundary_loc(s: &ScanResult, line_no: usize) -> String {
match s.merged_line_origin.get(&line_no) {
Some((sid, real)) => format!("L{real} in {sid}"),
None => format!("L{line_no}"),
}
}
pub(crate) fn boundary_source(s: &ScanResult, line_no: usize) -> (&str, usize) {
match s.merged_line_origin.get(&line_no) {
Some((sid, real)) => (sid.as_str(), *real),
None => (s.session_id.as_str(), line_no),
}
}
pub(crate) fn boundary_detail_with_clue(s: &ScanResult, b: &Boundary) -> String {
if b.kind != "external_edit" {
return b.detail.clone();
}
let (sid, real) = boundary_source(s, b.line_no);
let clue = s
.opaque
.iter()
.filter(|o| {
(o.marker.starts_with("fmt:") || o.marker.starts_with("interp:"))
&& o.session_id == sid
&& o.line_no <= real
})
.max_by_key(|o| o.line_no);
match clue {
Some(o) => format!(
"{}; this can be an external edit, the project's formatter, or a hook: a \
formatter-class command ({}) ran at L{} in this window, so check the \
project's conventions",
b.detail, o.marker, o.line_no
),
None => b.detail.clone(),
}
}
pub(crate) fn suggested_search(
basename: &str,
parent_session_id: &str,
first_utc: Option<&str>,
last_utc: Option<&str>,
) -> String {
let pattern = regex::escape(basename).replace('\'', "'\\''");
let mut cmd = format!("csift search '{pattern}' @{parent_session_id} -t agent.tool.use");
if let Some(t) = first_utc {
cmd.push_str(&format!(" --since {t}"));
}
if let Some(t) = last_utc {
cmd.push_str(&format!(" --until {t}"));
}
cmd
}
pub(crate) fn render_text(
ctx: &RenderCtx,
sessions: &[ScanResult],
out_path: Option<&Path>,
) -> Result<()> {
if matches!(ctx.mode, RecoverMode::Restore) {
return render_restore(ctx, sessions, out_path, false);
}
crate::text::emit_scope_banner(ctx.scope_top, ctx.scope_sub);
match ctx.mode {
RecoverMode::Restore => unreachable!("handled above"),
RecoverMode::Coverage => render_coverage_text(ctx, sessions),
RecoverMode::At | RecoverMode::Salvage => render_at_text(ctx, sessions, out_path),
RecoverMode::Patches => render_patches_text(ctx, sessions, out_path),
}
}