use super::*;
pub fn run_recover(args: &RecoverArgs) -> Result<()> {
if args.files_from.is_some() {
return run_recover_batch(args);
}
let mode = args.mode();
if matches!(mode, RecoverMode::Coverage) && args.out.is_some() {
eprintln!(
"note: --out is ignored in --coverage mode (a scoping summary has no artifact \
to write); use --patches / --at to write a file."
);
}
if args.file.is_none() {
bail!("--file <ABS_PATH> (or `@plan`) is required for --patches / --at / --coverage");
}
let turn_range = args
.turn_range
.as_deref()
.map(parse_turn_range)
.transpose()?;
let line_range = args
.line_range
.as_deref()
.map(parse_line_range)
.transpose()?;
let time_window = TimeWindow::from_args(args.since.as_deref(), args.until.as_deref())?;
let session_files = path::resolve_targets_with_session_list(
&args.paths,
args.sessions_from.as_deref(),
args.want_subagents().into(),
path::Caller::Other,
)?;
let plan_target: Option<String> = match args.file.as_deref() {
Some(f) if f == crate::plan::PLAN_SIGIL => {
let pref = crate::plan::resolve_plan_target(&session_files)?;
eprintln!(
"note: {} resolved to {} (bound to session {}{})",
crate::plan::PLAN_SIGIL,
pref.plan_file,
pref.session_id,
if pref.is_subagent { ", subagent" } else { "" }
);
Some(pref.plan_file)
}
_ => None,
};
let target_file = plan_target.as_deref().or(args.file.as_deref());
let per_file: Vec<ScanResult> = session_files
.par_iter()
.map(|p| scan_one_file(p, target_file))
.collect::<Result<Vec<_>>>()?;
let mut skipped_lines = 0usize;
let mut sessions: Vec<ScanResult> = Vec::new();
for sr in per_file {
skipped_lines += sr.skipped_lines;
sessions.push(sr);
}
sessions.sort_by(|a, b| a.session_id.cmp(&b.session_id));
for sr in &mut sessions {
let bounds = turn_range.map(|spec| {
let tc = sr
.events
.iter()
.map(|e| e.turn_index)
.max()
.map_or(0, |m| m + 1);
spec.resolve(tc, false)
});
sr.events.retain(|e| {
window_admits(
e.turn_index,
e.timestamp_utc.as_deref(),
bounds,
&time_window,
)
});
}
let (scope_top, scope_sub) = scope_span(&sessions);
let sessions = if matches!(
mode,
RecoverMode::At | RecoverMode::Coverage | RecoverMode::Restore | RecoverMode::Salvage
) {
merge_groups_for_reconstruction(sessions)
} else {
sessions
};
let ctx = RenderCtx {
mode,
file: target_file.map(str::to_string),
line_range,
at: args.at.clone(),
skipped_lines,
scope_top,
scope_sub,
};
match args.format {
OutputFormat::Text => render_text(&ctx, &sessions, args.out.as_deref())?,
OutputFormat::Json => render_json(&ctx, &sessions, args.out.as_deref())?,
}
Ok(())
}
pub(crate) struct BatchOutcome {
pub(crate) target: String,
pub(crate) status: &'static str, pub(crate) known: usize,
pub(crate) total: usize,
pub(crate) written: Option<std::path::PathBuf>,
}
pub(crate) fn basename_of(p: &str) -> &str {
p.rsplit(['/', '\\']).next().unwrap_or(p)
}
pub(crate) fn raw_needle_safe(base: &str) -> bool {
!base.is_empty() && base.bytes().all(|b| b >= 0x20 && b != b'"' && b != b'\\')
}
pub(crate) fn run_recover_batch(args: &RecoverArgs) -> Result<()> {
let manifest = args
.files_from
.as_deref()
.expect("batch mode requires --files-from");
let Some(out_dir) = args.out_dir.as_deref() else {
bail!("--files-from requires --out-dir <DIR> (where to write the recovered files)");
};
if args.file.is_some() {
bail!("--files-from (batch) is mutually exclusive with --file (single-file mode)");
}
let raw = std::fs::read_to_string(manifest)
.with_context(|| format!("cannot read manifest {}", manifest.display()))?;
let mut seen = std::collections::HashSet::new();
let targets: Vec<String> = raw
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(str::to_string)
.filter(|t| seen.insert(t.clone()))
.collect();
if targets.is_empty() {
bail!("manifest {} lists no files", manifest.display());
}
let basenames: Vec<String> = targets.iter().map(|t| basename_of(t).to_string()).collect();
let always: Vec<usize> = basenames
.iter()
.enumerate()
.filter(|(_, b)| !raw_needle_safe(b))
.map(|(i, _)| i)
.collect();
let ac = aho_corasick::AhoCorasick::new(&basenames)
.context("building the manifest basename matcher")?;
let when = args.at.clone().unwrap_or_default();
let turn_range = args
.turn_range
.as_deref()
.map(parse_turn_range)
.transpose()?;
let time_window = TimeWindow::from_args(args.since.as_deref(), args.until.as_deref())?;
let session_files = path::resolve_targets_with_session_list(
&args.paths,
args.sessions_from.as_deref(),
args.want_subagents().into(),
path::Caller::Other,
)?;
eprintln!(
"recover --files-from: {} file(s), scanning {} transcript(s) once…",
targets.len(),
session_files.len()
);
let per_file: Vec<Vec<(usize, ScanResult)>> = session_files
.par_iter()
.map(|p| scan_one_file_multi(p, &targets, &ac, &always))
.collect::<Result<Vec<_>>>()?;
let mut by_target: Vec<Vec<ScanResult>> = (0..targets.len()).map(|_| Vec::new()).collect();
for file_results in per_file {
for (ti, sr) in file_results {
by_target[ti].push(sr);
}
}
let mut outcomes: Vec<BatchOutcome> = Vec::with_capacity(targets.len());
for (ti, mut scans) in by_target.into_iter().enumerate() {
let target = targets[ti].clone();
for sr in &mut scans {
let bounds = turn_range.map(|spec| {
let tc = sr
.events
.iter()
.map(|e| e.turn_index)
.max()
.map_or(0, |m| m + 1);
spec.resolve(tc, false)
});
sr.events.retain(|e| {
window_admits(
e.turn_index,
e.timestamp_utc.as_deref(),
bounds,
&time_window,
)
});
}
scans.retain(|s| !s.events.is_empty());
let Some((content, known, total)) = reconstruct_best(scans, &when)? else {
outcomes.push(BatchOutcome {
target,
status: "no-history",
known: 0,
total: 0,
written: None,
});
continue;
};
let dest = out_dir.join(target.trim_start_matches('/'));
if dest.exists() && !args.force {
outcomes.push(BatchOutcome {
target,
status: "skipped-exists",
known,
total,
written: None,
});
continue;
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
std::fs::write(&dest, &content)
.with_context(|| format!("cannot write {}", dest.display()))?;
let status = if total > 0 && known >= total {
"complete"
} else {
"partial"
};
outcomes.push(BatchOutcome {
target,
status,
known,
total,
written: Some(dest),
});
}
write_batch_report(out_dir, &outcomes)
}