use std::collections::HashMap;
use std::path::Path;
type Key = (String, String, String);
fn pane_lines(save: &str) -> Vec<Vec<&str>> {
save.lines()
.filter(|l| l.starts_with("pane\t"))
.map(|l| l.split('\t').collect())
.collect()
}
pub fn fresh_from_saved(key: &Key, save: &str) -> String {
let mut out = String::from("command claude");
for f in pane_lines(save) {
if f.len() < 11 {
continue;
}
if (f[1].to_string(), f[2].to_string(), f[5].to_string()) != *key {
continue;
}
let cmd = f[10].strip_prefix(':').unwrap_or(f[10]);
let words: Vec<&str> = cmd.split_whitespace().collect();
let mut i = 1;
if words.first() == Some(&"command") && words.len() > 1 {
i = 2;
}
while i < words.len() {
match words[i] {
"--fork-session" | "-c" | "--continue" => {}
"--resume" | "-r" | "--session-id" => {
if i + 1 < words.len() && !words[i + 1].starts_with('-') {
i += 1;
}
}
w => {
out.push(' ');
out.push_str(w);
}
}
i += 1;
}
break;
}
out
}
pub fn rewrite(map: &HashMap<Key, String>, save: &str) -> String {
let mut out = String::new();
for line in save.lines() {
if let Some(rest) = line.strip_prefix("pane\t") {
let mut f: Vec<&str> = rest.split('\t').collect();
if f.len() >= 10 {
let key = (f[0].to_string(), f[1].to_string(), f[4].to_string());
if let Some(cmd) = map.get(&key) {
let replaced = format!(":{}", cmd);
f[9] = &replaced;
out.push_str("pane\t");
out.push_str(&f.join("\t"));
out.push('\n');
continue;
}
}
}
out.push_str(line);
out.push('\n');
}
out
}
pub struct Outcome {
pub resumed: usize,
pub fresh: usize,
pub lost: usize,
pub notes: Vec<String>,
pub map: HashMap<Key, String>,
}
impl Outcome {
pub fn total(&self) -> usize {
self.resumed + self.fresh + self.lost
}
pub fn summary(&self) -> String {
format!(
"{} claude pane(s): {} resumed, {} fresh, {} left alone",
self.total(),
self.resumed,
self.fresh,
self.lost
)
}
}
pub fn decide(panes: &str, records: &str, save: &str) -> Outcome {
let mut by_id: HashMap<&str, Key> = HashMap::new();
for l in panes.lines() {
let f: Vec<&str> = l.split('\t').collect();
if f.len() >= 4 {
by_id.insert(f[0], (f[1].into(), f[2].into(), f[3].into()));
}
}
let mut o = Outcome {
resumed: 0,
fresh: 0,
lost: 0,
notes: Vec::new(),
map: HashMap::new(),
};
for l in records.lines() {
let f: Vec<&str> = l.split('\t').collect();
if f.is_empty() || f[0].is_empty() {
continue;
}
let (pane, tgt) = (f[0], f.get(1).copied().unwrap_or(""));
let status = f.get(3).copied().unwrap_or("");
let why = f.get(4).copied().unwrap_or("");
let cmd = f.get(5).copied().unwrap_or("");
let Some(key) = by_id.get(pane) else {
o.lost += 1;
o.notes.push(format!(
"{}: pane {} vanished between the two reads",
tgt, pane
));
continue;
};
let cmd = if cmd.is_empty() {
o.fresh += 1;
o.notes.push(format!(
"{}: fresh session, {}, DIG",
tgt,
if why.is_empty() {
"no live session process"
} else {
why
}
));
fresh_from_saved(key, save)
} else if status == "resume" {
o.resumed += 1;
cmd.to_string()
} else {
o.fresh += 1;
o.notes.push(format!("{}: fresh session, {}", tgt, why));
cmd.to_string()
};
o.map.insert(key.clone(), cmd);
}
o
}
pub fn commit(target: &Path, body: &str, original: &str) -> Result<(), String> {
if body.lines().count() < original.lines().count() {
return Err("rewrite dropped lines, kept the original".into());
}
let dir = target.parent().unwrap_or(Path::new("."));
let tmp = dir.join(format!(".taimux-resurrect.{}.tmp", std::process::id()));
std::fs::write(&tmp, body).map_err(|e| format!("rewrite failed: {}", e))?;
if let Ok(m) = std::fs::metadata(target) {
let _ = std::fs::set_permissions(&tmp, m.permissions());
}
std::fs::rename(&tmp, target).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
format!("rewrite failed: {}", e)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn pane(sess: &str, win: &str, idx: &str, cmd: &str) -> String {
format!(
"pane\t{}\t{}\t1\t:*Z\t{}\t_\t:/w\t0\tbash\t:{}\n",
sess, win, idx, cmd
)
}
fn key(s: &str, w: &str, p: &str) -> Key {
(s.into(), w.into(), p.into())
}
#[test]
fn only_the_command_field_changes() {
let save = format!(
"{}{}",
pane("main", "1", "0", "claude"),
"window\tmain\t1\n"
);
let mut map = HashMap::new();
map.insert(
key("main", "1", "0"),
"command claude --resume /t.jsonl".into(),
);
let out = rewrite(&map, &save);
assert!(out.contains(":command claude --resume /t.jsonl"));
assert!(out.contains(":*Z")); assert!(out.contains("window\tmain\t1"));
assert_eq!(out.lines().count(), save.lines().count());
}
#[test]
fn an_unmapped_pane_is_untouched() {
let save = pane("main", "1", "0", "vim");
let out = rewrite(&HashMap::new(), &save);
assert_eq!(out, save);
}
#[test]
fn a_fresh_command_drops_every_claim_on_a_conversation() {
let save = pane(
"main",
"1",
"0",
"claude --model opus --resume /old.jsonl --fork-session",
);
assert_eq!(
fresh_from_saved(&key("main", "1", "0"), &save),
"command claude --model opus"
);
}
#[test]
fn a_command_prefixed_save_does_not_double_the_program() {
let save = pane(
"main",
"1",
"0",
"command claude --model opus --resume /old.jsonl",
);
assert_eq!(
fresh_from_saved(&key("main", "1", "0"), &save),
"command claude --model opus"
);
}
#[test]
fn a_pane_the_save_does_not_hold_still_gives_a_bare_claude() {
assert_eq!(
fresh_from_saved(&key("nope", "9", "9"), &pane("main", "1", "0", "claude")),
"command claude"
);
}
#[test]
fn a_resumable_record_is_counted_and_mapped() {
let panes = "%1\tmain\t1\t0\n";
let records =
"%1\tmain:1.0\t/w\tresume\tpane map, idle\tcommand claude --resume /t.jsonl\n";
let o = decide(panes, records, "");
assert_eq!((o.resumed, o.fresh, o.lost), (1, 0, 0));
assert_eq!(
o.map.get(&key("main", "1", "0")).map(|s| s.as_str()),
Some("command claude --resume /t.jsonl")
);
assert_eq!(
o.summary(),
"1 claude pane(s): 1 resumed, 0 fresh, 0 left alone"
);
}
#[test]
fn an_unresolved_record_comes_back_fresh_with_a_note() {
let panes = "%1\tmain\t1\t0\n";
let records = "%1\tmain:1.0\t/w\tunresolved\t3 share this title, idle\tcommand claude\n";
let o = decide(panes, records, "");
assert_eq!((o.resumed, o.fresh, o.lost), (0, 1, 0));
assert!(o.notes[0].contains("fresh session, 3 share this title"));
}
#[test]
fn a_record_with_no_command_falls_back_to_the_save_file() {
let panes = "%1\tmain\t1\t0\n";
let records = "%1\tmain:1.0\t/w\tunresolved\tno session process\t\n";
let save = pane("main", "1", "0", "claude --resume /old.jsonl");
let o = decide(panes, records, &save);
assert_eq!((o.resumed, o.fresh, o.lost), (0, 1, 0));
assert_eq!(
o.map.get(&key("main", "1", "0")).map(|s| s.as_str()),
Some("command claude")
);
assert!(o.notes[0].contains("DIG"));
}
#[test]
fn a_pane_that_vanished_between_the_reads_is_left_alone() {
let records = "%9\tmain:9.9\t/w\tresume\tpane map, idle\tcommand claude\n";
let o = decide("%1\tmain\t1\t0\n", records, "");
assert_eq!((o.resumed, o.fresh, o.lost), (0, 0, 1));
assert!(o.notes[0].contains("vanished between the two reads"));
assert!(o.map.is_empty());
}
#[test]
fn a_rewrite_that_lost_lines_is_refused() {
let d = std::env::temp_dir().join(format!("jmrr{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let f = d.join("last");
std::fs::write(&f, "a\nb\nc\n").unwrap();
assert_eq!(
commit(&f, "a\nb\n", "a\nb\nc\n"),
Err("rewrite dropped lines, kept the original".into())
);
assert_eq!(std::fs::read_to_string(&f).unwrap(), "a\nb\nc\n");
assert!(commit(&f, "a\nb\nc\n", "a\nb\nc\n").is_ok());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn nothing_is_left_behind_beside_the_target() {
let d = std::env::temp_dir().join(format!("jmrr2{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let f = d.join("last");
std::fs::write(&f, "x\n").unwrap();
commit(&f, "y\n", "x\n").expect("committed");
assert_eq!(std::fs::read_to_string(&f).unwrap(), "y\n");
let left: Vec<_> = std::fs::read_dir(&d)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(left, vec!["last".to_string()]);
let _ = std::fs::remove_dir_all(&d);
}
}