1use std::collections::HashMap;
26use std::path::Path;
27
28type Key = (String, String, String);
30
31fn pane_lines(save: &str) -> Vec<Vec<&str>> {
36 save.lines()
37 .filter(|l| l.starts_with("pane\t"))
38 .map(|l| l.split('\t').collect())
39 .collect()
40}
41
42pub fn fresh_from_saved(key: &Key, save: &str) -> String {
49 let mut out = String::from("command claude");
50 for f in pane_lines(save) {
51 if f.len() < 11 {
52 continue;
53 }
54 if (f[1].to_string(), f[2].to_string(), f[5].to_string()) != *key {
55 continue;
56 }
57 let cmd = f[10].strip_prefix(':').unwrap_or(f[10]);
59 let words: Vec<&str> = cmd.split_whitespace().collect();
60 let mut i = 1;
72 if words.first() == Some(&"command") && words.len() > 1 {
73 i = 2;
74 }
75 while i < words.len() {
76 match words[i] {
77 "--fork-session" | "-c" | "--continue" => {}
78 "--resume" | "-r" | "--session-id" => {
79 if i + 1 < words.len() && !words[i + 1].starts_with('-') {
80 i += 1;
81 }
82 }
83 w => {
84 out.push(' ');
85 out.push_str(w);
86 }
87 }
88 i += 1;
89 }
90 break;
91 }
92 out
93}
94
95pub fn rewrite(map: &HashMap<Key, String>, save: &str) -> String {
100 let mut out = String::new();
101 for line in save.lines() {
102 if let Some(rest) = line.strip_prefix("pane\t") {
103 let mut f: Vec<&str> = rest.split('\t').collect();
104 if f.len() >= 10 {
106 let key = (f[0].to_string(), f[1].to_string(), f[4].to_string());
107 if let Some(cmd) = map.get(&key) {
108 let replaced = format!(":{}", cmd);
109 f[9] = &replaced;
110 out.push_str("pane\t");
111 out.push_str(&f.join("\t"));
112 out.push('\n');
113 continue;
114 }
115 }
116 }
117 out.push_str(line);
118 out.push('\n');
119 }
120 out
121}
122
123pub struct Outcome {
124 pub resumed: usize,
125 pub fresh: usize,
126 pub lost: usize,
127 pub notes: Vec<String>,
128 pub map: HashMap<Key, String>,
129}
130
131impl Outcome {
132 pub fn total(&self) -> usize {
133 self.resumed + self.fresh + self.lost
134 }
135
136 pub fn summary(&self) -> String {
137 format!(
138 "{} claude pane(s): {} resumed, {} fresh, {} left alone",
139 self.total(),
140 self.resumed,
141 self.fresh,
142 self.lost
143 )
144 }
145}
146
147pub fn decide(panes: &str, records: &str, save: &str) -> Outcome {
153 let mut by_id: HashMap<&str, Key> = HashMap::new();
154 for l in panes.lines() {
155 let f: Vec<&str> = l.split('\t').collect();
156 if f.len() >= 4 {
157 by_id.insert(f[0], (f[1].into(), f[2].into(), f[3].into()));
158 }
159 }
160
161 let mut o = Outcome {
162 resumed: 0,
163 fresh: 0,
164 lost: 0,
165 notes: Vec::new(),
166 map: HashMap::new(),
167 };
168 for l in records.lines() {
169 let f: Vec<&str> = l.split('\t').collect();
173 if f.is_empty() || f[0].is_empty() {
174 continue;
175 }
176 let (pane, tgt) = (f[0], f.get(1).copied().unwrap_or(""));
177 let status = f.get(3).copied().unwrap_or("");
178 let why = f.get(4).copied().unwrap_or("");
179 let cmd = f.get(5).copied().unwrap_or("");
180
181 let Some(key) = by_id.get(pane) else {
182 o.lost += 1;
183 o.notes.push(format!(
184 "{}: pane {} vanished between the two reads",
185 tgt, pane
186 ));
187 continue;
188 };
189 let cmd = if cmd.is_empty() {
190 o.fresh += 1;
191 o.notes.push(format!(
192 "{}: fresh session, {}, DIG",
193 tgt,
194 if why.is_empty() {
195 "no live session process"
196 } else {
197 why
198 }
199 ));
200 fresh_from_saved(key, save)
201 } else if status == "resume" {
202 o.resumed += 1;
203 cmd.to_string()
204 } else {
205 o.fresh += 1;
206 o.notes.push(format!("{}: fresh session, {}", tgt, why));
207 cmd.to_string()
208 };
209 o.map.insert(key.clone(), cmd);
210 }
211 o
212}
213
214pub fn commit(target: &Path, body: &str, original: &str) -> Result<(), String> {
217 if body.lines().count() < original.lines().count() {
218 return Err("rewrite dropped lines, kept the original".into());
219 }
220 let dir = target.parent().unwrap_or(Path::new("."));
221 let tmp = dir.join(format!(".taimux-resurrect.{}.tmp", std::process::id()));
222 std::fs::write(&tmp, body).map_err(|e| format!("rewrite failed: {}", e))?;
223 if let Ok(m) = std::fs::metadata(target) {
225 let _ = std::fs::set_permissions(&tmp, m.permissions());
226 }
227 std::fs::rename(&tmp, target).map_err(|e| {
228 let _ = std::fs::remove_file(&tmp);
229 format!("rewrite failed: {}", e)
230 })
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 fn pane(sess: &str, win: &str, idx: &str, cmd: &str) -> String {
244 format!(
245 "pane\t{}\t{}\t1\t:*Z\t{}\t_\t:/w\t0\tbash\t:{}\n",
246 sess, win, idx, cmd
247 )
248 }
249
250 fn key(s: &str, w: &str, p: &str) -> Key {
251 (s.into(), w.into(), p.into())
252 }
253
254 #[test]
255 fn only_the_command_field_changes() {
256 let save = format!(
257 "{}{}",
258 pane("main", "1", "0", "claude"),
259 "window\tmain\t1\n"
260 );
261 let mut map = HashMap::new();
262 map.insert(
263 key("main", "1", "0"),
264 "command claude --resume /t.jsonl".into(),
265 );
266 let out = rewrite(&map, &save);
267 assert!(out.contains(":command claude --resume /t.jsonl"));
268 assert!(out.contains(":*Z")); assert!(out.contains("window\tmain\t1"));
271 assert_eq!(out.lines().count(), save.lines().count());
272 }
273
274 #[test]
277 fn an_unmapped_pane_is_untouched() {
278 let save = pane("main", "1", "0", "vim");
279 let out = rewrite(&HashMap::new(), &save);
280 assert_eq!(out, save);
281 }
282
283 #[test]
284 fn a_fresh_command_drops_every_claim_on_a_conversation() {
285 let save = pane(
286 "main",
287 "1",
288 "0",
289 "claude --model opus --resume /old.jsonl --fork-session",
290 );
291 assert_eq!(
292 fresh_from_saved(&key("main", "1", "0"), &save),
293 "command claude --model opus"
294 );
295 }
296
297 #[test]
304 fn a_command_prefixed_save_does_not_double_the_program() {
305 let save = pane(
306 "main",
307 "1",
308 "0",
309 "command claude --model opus --resume /old.jsonl",
310 );
311 assert_eq!(
312 fresh_from_saved(&key("main", "1", "0"), &save),
313 "command claude --model opus"
314 );
315 }
316
317 #[test]
318 fn a_pane_the_save_does_not_hold_still_gives_a_bare_claude() {
319 assert_eq!(
320 fresh_from_saved(&key("nope", "9", "9"), &pane("main", "1", "0", "claude")),
321 "command claude"
322 );
323 }
324
325 #[test]
326 fn a_resumable_record_is_counted_and_mapped() {
327 let panes = "%1\tmain\t1\t0\n";
328 let records =
329 "%1\tmain:1.0\t/w\tresume\tpane map, idle\tcommand claude --resume /t.jsonl\n";
330 let o = decide(panes, records, "");
331 assert_eq!((o.resumed, o.fresh, o.lost), (1, 0, 0));
332 assert_eq!(
333 o.map.get(&key("main", "1", "0")).map(|s| s.as_str()),
334 Some("command claude --resume /t.jsonl")
335 );
336 assert_eq!(
337 o.summary(),
338 "1 claude pane(s): 1 resumed, 0 fresh, 0 left alone"
339 );
340 }
341
342 #[test]
345 fn an_unresolved_record_comes_back_fresh_with_a_note() {
346 let panes = "%1\tmain\t1\t0\n";
347 let records = "%1\tmain:1.0\t/w\tunresolved\t3 share this title, idle\tcommand claude\n";
348 let o = decide(panes, records, "");
349 assert_eq!((o.resumed, o.fresh, o.lost), (0, 1, 0));
350 assert!(o.notes[0].contains("fresh session, 3 share this title"));
351 }
352
353 #[test]
357 fn a_record_with_no_command_falls_back_to_the_save_file() {
358 let panes = "%1\tmain\t1\t0\n";
359 let records = "%1\tmain:1.0\t/w\tunresolved\tno session process\t\n";
360 let save = pane("main", "1", "0", "claude --resume /old.jsonl");
361 let o = decide(panes, records, &save);
362 assert_eq!((o.resumed, o.fresh, o.lost), (0, 1, 0));
363 assert_eq!(
364 o.map.get(&key("main", "1", "0")).map(|s| s.as_str()),
365 Some("command claude")
366 );
367 assert!(o.notes[0].contains("DIG"));
368 }
369
370 #[test]
373 fn a_pane_that_vanished_between_the_reads_is_left_alone() {
374 let records = "%9\tmain:9.9\t/w\tresume\tpane map, idle\tcommand claude\n";
375 let o = decide("%1\tmain\t1\t0\n", records, "");
376 assert_eq!((o.resumed, o.fresh, o.lost), (0, 0, 1));
377 assert!(o.notes[0].contains("vanished between the two reads"));
378 assert!(o.map.is_empty());
379 }
380
381 #[test]
385 fn a_rewrite_that_lost_lines_is_refused() {
386 let d = std::env::temp_dir().join(format!("jmrr{}", std::process::id()));
387 std::fs::create_dir_all(&d).unwrap();
388 let f = d.join("last");
389 std::fs::write(&f, "a\nb\nc\n").unwrap();
390 assert_eq!(
391 commit(&f, "a\nb\n", "a\nb\nc\n"),
392 Err("rewrite dropped lines, kept the original".into())
393 );
394 assert_eq!(std::fs::read_to_string(&f).unwrap(), "a\nb\nc\n");
395 assert!(commit(&f, "a\nb\nc\n", "a\nb\nc\n").is_ok());
396 let _ = std::fs::remove_dir_all(&d);
397 }
398
399 #[test]
402 fn nothing_is_left_behind_beside_the_target() {
403 let d = std::env::temp_dir().join(format!("jmrr2{}", std::process::id()));
404 std::fs::create_dir_all(&d).unwrap();
405 let f = d.join("last");
406 std::fs::write(&f, "x\n").unwrap();
407 commit(&f, "y\n", "x\n").expect("committed");
408 assert_eq!(std::fs::read_to_string(&f).unwrap(), "y\n");
409 let left: Vec<_> = std::fs::read_dir(&d)
410 .unwrap()
411 .flatten()
412 .map(|e| e.file_name().to_string_lossy().into_owned())
413 .collect();
414 assert_eq!(left, vec!["last".to_string()]);
415 let _ = std::fs::remove_dir_all(&d);
416 }
417}