use super::*;
use crate::catchup::mpm_session::ClaudeMpmSession;
use std::fs;
use tempfile::TempDir;
fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
let p = dir.join(name);
fs::write(&p, content).unwrap();
p
}
#[test]
fn parse_filename_timestamp_roundtrip() {
let ts = parse_filename_timestamp("20260627-142030");
assert!(ts.is_some());
let ts = ts.unwrap();
assert_eq!(ts.format("%Y-%m-%d").to_string(), "2026-06-27");
}
#[test]
fn parse_filename_timestamp_rejects_short() {
assert!(parse_filename_timestamp("2026062").is_none());
assert!(parse_filename_timestamp("").is_none());
}
#[test]
fn parse_filename_timestamp_rejects_non_ascii_without_panicking() {
assert!(parse_filename_timestamp("123é456-142030").is_none());
}
#[test]
fn extract_section_finds_content() {
let md = "# Title\n\n## Summary\nDid lots of work.\n\n## Next Steps\nFix tests.";
assert_eq!(
extract_section(md, "Summary").as_deref(),
Some("Did lots of work.")
);
assert_eq!(
extract_section(md, "Next Steps").as_deref(),
Some("Fix tests.")
);
assert!(extract_section(md, "Missing").is_none());
}
#[test]
fn extract_section_strips_trailing_header_annotation_from_body() {
let md = "## In Progress\n\nNOTHING.\n\n\
## Next Steps (all Bob's call — none required)\n\n\
- Bob's tested one-liner (handed over, not yet run).\n\
- Installer NOT VERIFIED in isolation.\n";
let next_steps = extract_section(md, "Next Steps").expect("section present");
assert!(
!next_steps.contains("all Bob's call"),
"trailing header annotation leaked into body: {next_steps:?}"
);
assert!(next_steps.starts_with("- Bob's tested one-liner"));
assert_eq!(
extract_section(md, "In Progress").as_deref(),
Some("NOTHING.")
);
}
#[test]
fn extract_section_first_occurrence_wins_when_header_repeated() {
let md = "## Next Steps (draft, ignore)\nstale draft text\n\n\
## Next Steps\nReal next steps.\n";
assert_eq!(
extract_section(md, "Next Steps").as_deref(),
Some("stale draft text")
);
}
#[test]
fn extract_section_allows_trailing_whitespace_on_header_line() {
let md = "## Next Steps \nDo the thing.\n";
assert_eq!(
extract_section(md, "Next Steps").as_deref(),
Some("Do the thing.")
);
}
#[test]
fn extract_section_handles_representative_corpus_header_styles() {
let parenthetical = "## Completed\nDid stuff.\n\n\
## In Progress (BACKGROUND AGENTS — check GitHub for their PRs on resume)\n\
#2108 BUILD WAVE 1 — three engineers dispatched ~20:45 EDT, isolated worktrees:\n\
- #2117 status endpoint — DONE, PR #2396 OPEN awaiting review gate\n\n\
## Git Context\nBranch: main\n";
let in_progress = extract_section(parenthetical, "In Progress").expect("present");
assert!(!in_progress.contains("BACKGROUND AGENTS"));
assert!(in_progress.starts_with("#2108 BUILD WAVE 1"));
let em_dash = "## Summary\nRelease chain.\n\n\
## In Progress — LIVE AGENTS AT PAUSE (verify via gh, not memory)\n\n\
1. **Release chain — agent `a84bd2cbfb656d183`** (worktree\n\
`.claude/worktrees/agent-a84bd2cbfb656d183`).\n\n\
## Git Context\nBranch: main\n";
let in_progress = extract_section(em_dash, "In Progress").expect("present");
assert!(!in_progress.contains("LIVE AGENTS AT PAUSE"));
assert!(in_progress.starts_with("1. **Release chain"));
let clean = "## In Progress\nNOTHING in flight.\n\n\
## Next Steps (pending Bob decisions / next dispatches)\n\
1. Cut tm 0.19.10? — phase 1 merged AFTER 0.19.9.\n";
assert_eq!(
extract_section(clean, "In Progress").as_deref(),
Some("NOTHING in flight.")
);
let next_steps = extract_section(clean, "Next Steps").expect("present");
assert!(!next_steps.contains("pending Bob decisions"));
assert!(next_steps.starts_with("1. Cut tm 0.19.10?"));
}
#[test]
fn find_merges_both_formats() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let tm_dir = project.join(".trusty-mpm").join("sessions");
fs::create_dir_all(&tm_dir).unwrap();
write_file(
&tm_dir,
"session-20260627-100000.md",
"## Summary\nDone something.\n## Git Context\nbranch: main",
);
let cm_dir = project.join(".claude-mpm").join("sessions");
fs::create_dir_all(&cm_dir).unwrap();
write_file(
&cm_dir,
"session-20260626-090000.json",
r#"{"session_id":"cm1","paused_at":"2026-06-26T09:00:00Z"}"#,
);
let sessions = find_paused_sessions(project).unwrap();
assert_eq!(sessions.len(), 2);
assert!(
matches!(sessions[0], PausedSession::TrustyMpm { .. }),
"newer trusty-mpm session should be first"
);
assert!(
matches!(sessions[1], PausedSession::ClaudeMpm { .. }),
"older claude-mpm session should be second"
);
}
#[test]
fn find_orders_newest_first() {
let tmp = TempDir::new().unwrap();
let cm_dir = tmp.path().join(".claude-mpm").join("sessions");
fs::create_dir_all(&cm_dir).unwrap();
write_file(
&cm_dir,
"session-20260625-080000.json",
r#"{"session_id":"old","paused_at":"2026-06-25T08:00:00Z"}"#,
);
write_file(
&cm_dir,
"session-20260627-100000.json",
r#"{"session_id":"new","paused_at":"2026-06-27T10:00:00Z"}"#,
);
let sessions = find_paused_sessions(tmp.path()).unwrap();
assert_eq!(sessions.len(), 2);
let first_key = sessions[0].sort_key().unwrap();
let second_key = sessions[1].sort_key().unwrap();
assert!(first_key > second_key, "newest should be first");
}
#[test]
fn render_contains_digest_not_conversation() {
let session = ClaudeMpmSession {
session_id: "test-123".to_string(),
paused_at: Some("2026-06-27T10:00:00Z".to_string()),
resume_instructions: Some("Resume from step 3".to_string()),
important_reminders: Some(vec!["Don't break prod".to_string()]),
git_context: Some("branch: main".to_string()),
..Default::default()
};
let sessions = vec![PausedSession::ClaudeMpm { session }];
let output = render_resume_context(&sessions);
assert!(
output.contains("Resume from step 3"),
"resume instructions should be present"
);
assert!(
output.contains("Don't break prod"),
"reminders should be present"
);
assert!(
output.contains("branch: main"),
"git context should be present"
);
assert!(
!output.contains("conversation"),
"conversation must NOT appear in rendered output"
);
}
#[test]
fn parse_extracts_tmux_window() {
let tmp = TempDir::new().unwrap();
let p = write_file(
tmp.path(),
"session-20260627-100000.md",
"## Summary\nWork.\n\n## Tmux Window\nmain:2:@7\n\n## Git Context\nbranch: main",
);
let session = parse_trusty_mpm_session(&p).unwrap();
match session {
PausedSession::TrustyMpm { tmux_window, .. } => {
assert_eq!(tmux_window.as_deref(), Some("main:2:@7"));
}
_ => panic!("expected TrustyMpm variant"),
}
}
#[test]
fn parse_missing_tmux_window_is_none() {
let tmp = TempDir::new().unwrap();
let p = write_file(
tmp.path(),
"session-20260627-100000.md",
"## Summary\nWork.\n\n## Git Context\nbranch: main",
);
let session = parse_trusty_mpm_session(&p).unwrap();
match session {
PausedSession::TrustyMpm { tmux_window, .. } => {
assert!(tmux_window.is_none(), "back-compat: absent section => None");
}
_ => panic!("expected TrustyMpm variant"),
}
}
#[test]
fn render_tmux_window_present_and_omitted() {
let with = PausedSession::TrustyMpm {
path: PathBuf::from("/tmp/session-20260627-100000.md"),
paused_at: None,
summary: "Work.".to_string(),
git_context: None,
in_progress: None,
next_steps: None,
tmux_window: Some("main:2:@7".to_string()),
};
let output = render_resume_context(std::slice::from_ref(&with));
assert!(
output.contains("**Tmux Window:** main:2:@7"),
"recorded window should render"
);
let without = PausedSession::TrustyMpm {
path: PathBuf::from("/tmp/session-20260627-100000.md"),
paused_at: None,
summary: "Work.".to_string(),
git_context: None,
in_progress: None,
next_steps: None,
tmux_window: None,
};
let output = render_resume_context(std::slice::from_ref(&without));
assert!(
!output.contains("Tmux Window"),
"absent window must not render a line"
);
}
fn log_pause(sdir: &Path, id: &str, snap: &str, ts: &str) {
crate::catchup::session_log::append_entry(
sdir,
&crate::catchup::session_log::SessionLogEntry {
session_id: id.to_string(),
event: "pause".to_string(),
snapshot: snap.to_string(),
timestamp: ts.to_string(),
},
)
.unwrap();
}
const SESSION_A: &str = "2eb72dca-de08-481b-8dfa-22ab7f81b1f9";
const SESSION_B: &str = "7bd5c27a-475b-41df-9e9f-a6f630801717";
#[test]
fn latest_snapshot_prefers_session_log() {
let tmp = TempDir::new().unwrap();
let sdir = tmp.path().join(".trusty-mpm").join("sessions");
fs::create_dir_all(&sdir).unwrap();
write_file(&sdir, "session-A.md", "## Summary\nS1 work");
write_file(&sdir, "session-B.md", "## Summary\nS2 work");
log_pause(&sdir, "s1", "session-A.md", "t1");
log_pause(&sdir, "s2", "session-B.md", "t2");
let got = latest_trusty_mpm_snapshot(tmp.path(), Some("s1")).unwrap();
assert_eq!(got.file_name().unwrap(), "session-A.md");
let got = latest_trusty_mpm_snapshot(tmp.path(), Some("s2")).unwrap();
assert_eq!(got.file_name().unwrap(), "session-B.md");
}
#[test]
fn latest_snapshot_refuses_cross_session_fallback() {
let tmp = TempDir::new().unwrap();
let sdir = tmp.path().join(".trusty-mpm").join("sessions");
fs::create_dir_all(&sdir).unwrap();
let snap = "session-20260809-010155.md";
write_file(&sdir, snap, "## Summary\nSession A's work.\n");
log_pause(&sdir, SESSION_A, snap, "2026-08-09T01:01:55.796934+00:00");
assert_eq!(
latest_trusty_mpm_snapshot(tmp.path(), Some(SESSION_B)),
None,
"session B has no snapshot of its own and must NOT be handed A's"
);
assert_eq!(
latest_trusty_mpm_snapshot(tmp.path(), Some(SESSION_A))
.unwrap()
.file_name()
.unwrap(),
snap,
"A still resolves its own snapshot"
);
}
#[test]
fn latest_snapshot_requires_a_session_id() {
let tmp = TempDir::new().unwrap();
let sdir = tmp.path().join(".trusty-mpm").join("sessions");
fs::create_dir_all(&sdir).unwrap();
write_file(&sdir, "session-20260809-010155.md", "## Summary\nA's work");
log_pause(&sdir, SESSION_A, "session-20260809-010155.md", "t1");
assert_eq!(latest_trusty_mpm_snapshot(tmp.path(), None), None);
assert!(latest_trusty_mpm_snapshot(&tmp.path().join("nope"), Some(SESSION_A)).is_none());
}
#[test]
fn latest_snapshot_reads_legacy_flat_snapshot_via_log() {
let tmp = TempDir::new().unwrap();
let sdir = tmp.path().join(".trusty-mpm").join("sessions");
fs::create_dir_all(&sdir).unwrap();
write_file(
&sdir,
"session-20260807-040441.md",
"## Summary\nA's legacy",
);
std::thread::sleep(std::time::Duration::from_millis(10));
write_file(&sdir, "session-20260807-043031.md", "## Summary\norphan");
log_pause(&sdir, SESSION_A, "session-20260807-040441.md", "t1");
assert_eq!(
latest_trusty_mpm_snapshot(tmp.path(), Some(SESSION_A))
.unwrap()
.file_name()
.unwrap(),
"session-20260807-040441.md",
"the logged flat file resolves without migrating it"
);
assert_eq!(
latest_trusty_mpm_snapshot(tmp.path(), Some(SESSION_B)),
None,
"the unattributable orphan must not resolve to an arbitrary session"
);
}
#[test]
fn find_includes_per_session_directories() {
let tmp = TempDir::new().unwrap();
let sdir = tmp.path().join(".trusty-mpm").join("sessions");
fs::create_dir_all(sdir.join(SESSION_A)).unwrap();
write_file(&sdir, "session-20260807-040441.md", "## Summary\nflat");
write_file(
&sdir.join(SESSION_A),
"session-20260809-010155.md",
"## Summary\nnested",
);
let found = find_paused_sessions(tmp.path()).unwrap();
let summaries: Vec<&str> = found
.iter()
.filter_map(|s| match s {
PausedSession::TrustyMpm { summary, .. } => Some(summary.as_str()),
_ => None,
})
.collect();
assert_eq!(
summaries.len(),
2,
"both layouts are discovered: {summaries:?}"
);
assert!(summaries.contains(&"flat"));
assert!(summaries.contains(&"nested"));
}
#[test]
fn render_empty_returns_no_sessions_message() {
let output = render_resume_context(&[]);
assert!(
output.contains("No paused sessions"),
"empty renders a notice"
);
}
fn set_mtime(path: &Path, ts: DateTime<Utc>) {
let f = fs::File::options().write(true).open(path).unwrap();
f.set_modified(std::time::SystemTime::from(ts)).unwrap();
}
#[test]
fn parse_falls_back_to_mtime_when_filename_lacks_timestamp() {
let tmp = TempDir::new().unwrap();
let p = write_file(tmp.path(), "session-20260730-bounce.md", "# Hand written\n");
let expected: DateTime<Utc> = "2026-07-30T14:55:00Z".parse().unwrap();
set_mtime(&p, expected);
match parse_trusty_mpm_session(&p).unwrap() {
PausedSession::TrustyMpm { paused_at, .. } => {
assert_eq!(
paused_at,
Some(expected),
"an undated filename must fall back to the file's mtime"
);
}
other => panic!("expected TrustyMpm, got {other:?}"),
}
}
#[test]
fn find_orders_undated_snapshot_by_mtime() {
let tmp = TempDir::new().unwrap();
let sdir = tmp.path().join(".trusty-mpm").join("sessions");
fs::create_dir_all(&sdir).unwrap();
write_file(&sdir, "session-20260101-000000.md", "## Summary\nold");
let undated = write_file(&sdir, "session-rescue.md", "# Hand written\n");
set_mtime(&undated, "2026-06-01T00:00:00Z".parse().unwrap());
let sessions = find_paused_sessions(tmp.path()).unwrap();
assert_eq!(sessions.len(), 2);
match &sessions[0] {
PausedSession::TrustyMpm { path, .. } => {
assert_eq!(
path.file_name().unwrap(),
"session-rescue.md",
"the undated-but-newer snapshot must sort first"
);
}
other => panic!("expected TrustyMpm, got {other:?}"),
}
}
fn undatable_session() -> PausedSession {
let s = PausedSession::ClaudeMpm {
session: ClaudeMpmSession {
paused_at: Some("not-a-timestamp".to_string()),
source_mtime: None,
..Default::default()
},
};
assert!(
s.sort_key().is_none(),
"fixture must actually be undatable, or the tests below prove nothing"
);
s
}
fn dated_session(ts: &str) -> PausedSession {
PausedSession::ClaudeMpm {
session: ClaudeMpmSession {
paused_at: Some(ts.to_string()),
..Default::default()
},
}
}
#[test]
fn filter_sessions_since_drops_undatable_session() {
let wm: DateTime<Utc> = "2026-08-01T00:00:00Z".parse().unwrap();
let out = filter_sessions_since(
vec![undatable_session(), dated_session("2026-08-06T21:13:05Z")],
Some(wm),
);
assert_eq!(
out.kept.len(),
1,
"only the datable, newer-than-watermark session survives"
);
assert_eq!(
out.kept[0].sort_key(),
Some("2026-08-06T21:13:05Z".parse::<DateTime<Utc>>().unwrap())
);
}
#[test]
fn filter_sessions_since_reports_dropped_count() {
let wm: DateTime<Utc> = "2026-08-01T00:00:00Z".parse().unwrap();
let out = filter_sessions_since(
vec![
undatable_session(),
undatable_session(),
dated_session("2026-07-01T00:00:00Z"), dated_session("2026-08-06T21:13:05Z"),
],
Some(wm),
);
assert_eq!(out.kept.len(), 1);
assert_eq!(
out.dropped_undatable, 2,
"only undatable exclusions are counted, never merely-too-old ones"
);
}
#[test]
fn filter_sessions_since_keeps_everything_without_watermark() {
let out = filter_sessions_since(
vec![undatable_session(), dated_session("2026-08-06T21:13:05Z")],
None,
);
assert_eq!(out.kept.len(), 2);
assert_eq!(out.dropped_undatable, 0);
}
#[test]
fn claude_mpm_session_with_no_paused_at_is_dated_by_mtime() {
let tmp = TempDir::new().unwrap();
let sdir = tmp.path().join(".claude-mpm").join("sessions");
fs::create_dir_all(&sdir).unwrap();
let p = write_file(
&sdir,
"session-legacy.json",
r#"{"session_id":"legacy-only-id"}"#,
);
set_mtime(&p, "2026-08-06T21:13:05Z".parse().unwrap());
let sessions = find_paused_sessions(tmp.path()).unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(
sessions[0].sort_key(),
Some("2026-08-06T21:13:05Z".parse::<DateTime<Utc>>().unwrap()),
"a claude-mpm session with no paused_at must be dated by its file mtime"
);
let wm: DateTime<Utc> = "2026-08-01T00:00:00Z".parse().unwrap();
let out = filter_sessions_since(sessions, Some(wm));
assert_eq!(
out.kept.len(),
1,
"and must survive a watermark it postdates"
);
assert_eq!(out.dropped_undatable, 0);
}
#[test]
fn contract_latest_snapshot_none_session_id_is_none() {
let tmp = TempDir::new().unwrap();
let sdir = tmp.path().join(".trusty-mpm").join("sessions");
fs::create_dir_all(&sdir).unwrap();
write_file(&sdir, "session-20260809-010155.md", "## Summary\nA's work");
log_pause(&sdir, SESSION_A, "session-20260809-010155.md", "t1");
write_file(&sdir, "session-20260809-020000.md", "## Summary\nB's work");
log_pause(&sdir, SESSION_B, "session-20260809-020000.md", "t2");
assert_eq!(
latest_trusty_mpm_snapshot(tmp.path(), None),
None,
"an unidentified caller must not be handed another session's snapshot"
);
assert_eq!(
latest_trusty_mpm_snapshot(tmp.path(), Some(SESSION_A)),
Some(sdir.join("session-20260809-010155.md")),
);
}
#[test]
fn contract_filter_sessions_since_partitions_the_input() {
let tmp = TempDir::new().unwrap();
let before = "2026-08-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
let watermark = "2026-08-05T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
let after = "2026-08-09T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
let mk = |paused_at: Option<DateTime<Utc>>| PausedSession::TrustyMpm {
path: tmp.path().join("s.md"),
paused_at,
summary: String::new(),
git_context: None,
in_progress: None,
next_steps: None,
tmux_window: None,
};
let sessions = vec![
mk(Some(after)), mk(Some(before)), mk(None), mk(Some(watermark)), mk(Some(after)), ];
let total = sessions.len();
let out = filter_sessions_since(sessions, Some(watermark));
assert_eq!(
out.kept.len(),
2,
"only the two strictly-after sessions survive"
);
assert_eq!(
out.dropped_undatable, 1,
"the undatable session is withheld AND counted"
);
assert!(out.kept.len() + out.dropped_undatable <= total);
assert_eq!(total - out.kept.len() - out.dropped_undatable, 2);
let all = vec![mk(Some(after)), mk(None), mk(Some(before))];
let out = filter_sessions_since(all, None);
assert_eq!(out.kept.len(), 3);
assert_eq!(out.dropped_undatable, 0);
}
#[test]
fn contract_sort_key_none_means_excluded_not_always_included() {
let tmp = TempDir::new().unwrap();
let undatable = PausedSession::TrustyMpm {
path: tmp.path().join("never-written.md"),
paused_at: None,
summary: String::new(),
git_context: None,
in_progress: None,
next_steps: None,
tmux_window: None,
};
assert!(
undatable.sort_key().is_none(),
"precondition for the rest of this test"
);
let ancient = "1990-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
let out = filter_sessions_since(vec![undatable], Some(ancient));
assert!(
out.kept.is_empty(),
"`None` must mean excluded, not always-included"
);
assert_eq!(out.dropped_undatable, 1);
}