use std::path::{Path, PathBuf};
use chrono::Utc;
use crate::catchup::git::capture_git_status;
use crate::catchup::session_log::{self, SessionLogEntry};
use crate::catchup::sha_scrub::scrub_sha_like;
#[derive(Debug, Clone)]
pub struct PauseSnapshotInput<'a> {
pub session_id: &'a str,
pub summary: &'a str,
pub completed: &'a [String],
pub in_progress: &'a [String],
pub next_steps: &'a [String],
pub tmux_window: Option<&'a str>,
}
#[derive(Debug, Clone)]
pub struct PauseSnapshotOutcome {
pub snapshot_path: PathBuf,
pub timestamp: chrono::DateTime<Utc>,
}
fn render_bullet_section(header: &str, items: &[String]) -> Option<String> {
if items.is_empty() {
return None;
}
let mut out = format!("## {header}\n");
for item in items {
out.push_str("- ");
out.push_str(&scrub_sha_like(item));
out.push('\n');
}
Some(out)
}
pub fn write_pause_snapshot(
project_dir: &Path,
input: &PauseSnapshotInput<'_>,
) -> anyhow::Result<PauseSnapshotOutcome> {
let sessions_dir = project_dir.join(".trusty-mpm").join("sessions");
let now = Utc::now();
let basename = format!("session-{}.md", now.format("%Y%m%d-%H%M%S"));
let relative = match session_log::session_dir_name(input.session_id) {
Some(dir) => format!("{dir}/{basename}"),
None => {
tracing::warn!(
session_id = input.session_id,
"session id is not usable as a directory name; writing the pause \
snapshot flat at the store root (attribution still comes from \
sessions-log.jsonl)"
);
basename
}
};
let snapshot_path = sessions_dir.join(&relative);
if let Some(parent) = snapshot_path.parent() {
std::fs::create_dir_all(parent)?;
}
let git = capture_git_status(project_dir);
let mut git_context = String::new();
if let Some(branch) = &git.branch {
git_context.push_str(&format!("Branch: {branch}\n"));
}
if let Some(commit) = &git.last_commit {
git_context.push_str(&format!("Last commit: {commit}\n"));
}
if let Some(summary) = &git.uncommitted_summary {
git_context.push_str(&format!("Uncommitted changes: {summary}\n"));
}
let mut md = format!(
"# Session Pause - {}\n\n## Summary\n{}\n\n",
now.to_rfc3339(),
scrub_sha_like(input.summary) );
if let Some(section) = render_bullet_section("Completed", input.completed) {
md.push_str(§ion);
md.push('\n');
}
if let Some(section) = render_bullet_section("In Progress", input.in_progress) {
md.push_str(§ion);
md.push('\n');
}
if let Some(section) = render_bullet_section("Next Steps", input.next_steps) {
md.push_str(§ion);
md.push('\n');
}
if !git_context.is_empty() {
md.push_str("## Git Context\n");
md.push_str(&git_context);
md.push('\n');
}
if let Some(win) = input.tmux_window.filter(|w| !w.is_empty()) {
md.push_str("## Tmux Window\n");
md.push_str(win);
md.push('\n');
}
std::fs::write(&snapshot_path, md)?;
session_log::append_entry(
&sessions_dir,
&SessionLogEntry {
session_id: input.session_id.to_string(),
event: session_log::EVENT_PAUSE.to_string(),
snapshot: relative,
timestamp: now.to_rfc3339(),
},
)?;
Ok(PauseSnapshotOutcome {
snapshot_path,
timestamp: now,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catchup::session_finder::{PausedSession, find_paused_sessions};
use tempfile::TempDir;
fn init_git_repo(dir: &Path) {
let run = |args: &[&str]| {
std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap();
};
run(&["init"]);
run(&["config", "user.email", "t@t.com"]);
run(&["config", "user.name", "T"]);
std::fs::write(dir.join("README.md"), b"hi").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "init"]);
}
#[test]
fn write_pause_snapshot_round_trips_through_reader() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let input = PauseSnapshotInput {
session_id: "s1",
summary: "Did the thing.",
completed: &["step 1".to_string()],
in_progress: &["step 2".to_string()],
next_steps: &["step 3".to_string()],
tmux_window: Some("main:2:@7"),
};
let outcome = write_pause_snapshot(tmp.path(), &input).unwrap();
assert!(outcome.snapshot_path.exists());
let sessions = find_paused_sessions(tmp.path()).unwrap();
assert_eq!(sessions.len(), 1);
match &sessions[0] {
PausedSession::TrustyMpm {
summary,
in_progress,
next_steps,
tmux_window,
git_context,
..
} => {
assert_eq!(summary, "Did the thing.");
assert_eq!(in_progress.as_deref(), Some("- step 2"));
assert_eq!(next_steps.as_deref(), Some("- step 3"));
assert_eq!(tmux_window.as_deref(), Some("main:2:@7"));
assert!(
git_context
.as_deref()
.is_some_and(|g| g.contains("Branch:")),
"git context should be present: {git_context:?}"
);
}
other => panic!("expected TrustyMpm variant, got {other:?}"),
}
}
#[test]
fn write_pause_snapshot_omits_empty_sections() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let input = PauseSnapshotInput {
session_id: "s1",
summary: "Just a summary.",
completed: &[],
in_progress: &[],
next_steps: &[],
tmux_window: None,
};
let outcome = write_pause_snapshot(tmp.path(), &input).unwrap();
let content = std::fs::read_to_string(&outcome.snapshot_path).unwrap();
assert!(!content.contains("## Completed"));
assert!(!content.contains("## In Progress"));
assert!(!content.contains("## Next Steps"));
assert!(!content.contains("## Tmux Window"));
assert!(content.contains("## Git Context"));
}
#[test]
fn write_pause_snapshot_appends_log_entry() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let input = PauseSnapshotInput {
session_id: "s-log",
summary: "Summary.",
completed: &[],
in_progress: &[],
next_steps: &[],
tmux_window: None,
};
let outcome = write_pause_snapshot(tmp.path(), &input).unwrap();
let sessions_dir = tmp.path().join(".trusty-mpm").join("sessions");
let entries = session_log::read_log(&sessions_dir);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].session_id, "s-log");
assert_eq!(entries[0].event, session_log::EVENT_PAUSE);
assert_eq!(
sessions_dir.join(&entries[0].snapshot),
outcome.snapshot_path
);
}
#[test]
fn write_pause_snapshot_writes_under_session_dir() {
let tmp = TempDir::new().unwrap();
let id = "7bd5c27a-475b-41df-9e9f-a6f630801717";
let input = PauseSnapshotInput {
session_id: id,
summary: "Summary.",
completed: &[],
in_progress: &[],
next_steps: &[],
tmux_window: None,
};
let outcome = write_pause_snapshot(tmp.path(), &input).unwrap();
let sessions_dir = tmp.path().join(".trusty-mpm").join("sessions");
assert_eq!(
outcome.snapshot_path.parent().unwrap(),
sessions_dir.join(id),
"snapshot belongs to its session's directory"
);
let entries = session_log::read_log(&sessions_dir);
assert_eq!(entries.len(), 1);
let basename = outcome.snapshot_path.file_name().unwrap().to_str().unwrap();
assert_eq!(entries[0].snapshot, format!("{id}/{basename}"));
assert_eq!(
session_log::resolve_session_snapshot(&sessions_dir, id, "md").as_ref(),
Some(&outcome.snapshot_path),
);
assert_eq!(
session_log::resolve_session_snapshot(&sessions_dir, "someone-else", "md"),
None,
);
}
#[test]
fn write_pause_snapshot_falls_back_to_root_for_unsafe_id() {
let tmp = TempDir::new().unwrap();
let id = "weird/id";
let input = PauseSnapshotInput {
session_id: id,
summary: "Summary.",
completed: &[],
in_progress: &[],
next_steps: &[],
tmux_window: None,
};
let outcome = write_pause_snapshot(tmp.path(), &input).unwrap();
let sessions_dir = tmp.path().join(".trusty-mpm").join("sessions");
assert_eq!(outcome.snapshot_path.parent().unwrap(), sessions_dir);
assert_eq!(
session_log::resolve_session_snapshot(&sessions_dir, id, "md").as_ref(),
Some(&outcome.snapshot_path),
);
assert_eq!(
session_log::resolve_session_snapshot(&sessions_dir, "other", "md"),
None,
);
}
fn head_sha_tokens(md: &str) -> Vec<String> {
md.lines()
.filter(|line| !line.starts_with("Last commit:"))
.flat_map(|line| line.split(|c: char| !c.is_ascii_alphanumeric()))
.filter(|w| {
(7..=40).contains(&w.len())
&& w.chars().all(|c| c.is_ascii_hexdigit())
&& w.chars().any(|c| c.is_ascii_digit())
&& w.chars().any(|c| c.is_ascii_alphabetic())
})
.map(str::to_string)
.collect()
}
#[test]
fn a_snapshot_contains_no_head_sha() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let head = std::process::Command::new("git")
.arg("-C")
.arg(tmp.path())
.args(["log", "-1", "--format=%h"])
.output()
.unwrap();
let head = String::from_utf8_lossy(&head.stdout).trim().to_string();
let input = PauseSnapshotInput {
session_id: "s1",
summary: "On feat/9390-x for #9390, 2026-10-10 21:46: PR #12 head d3963848ab.",
completed: &["added the facade; deadbeef and xdeadbeef1x are words".to_string()],
in_progress: &[
"PR #12 head d3963848ab awaiting CI".to_string(),
"compare abc1234..def5678".to_string(),
],
next_steps: &["merge `0123456789abcdef0123456789abcdef01234567`".to_string()],
tmux_window: None,
};
let outcome = write_pause_snapshot(tmp.path(), &input).unwrap();
let md = std::fs::read_to_string(&outcome.snapshot_path).unwrap();
assert_eq!(
head_sha_tokens(&md),
Vec::<String>::new(),
"no head SHA may survive outside `Last commit:`:\n{md}"
);
for kept in [
"feat/9390-x",
"#9390",
"2026-10-10",
"21:46",
"PR #12",
"added the facade",
"deadbeef",
"xdeadbeef1x",
] {
assert!(md.contains(kept), "{kept:?} must survive:\n{md}");
}
assert!(md.contains("<sha?>"), "a stripped id is flagged:\n{md}");
assert!(
md.lines()
.any(|l| l.starts_with(&format!("Last commit: {head} "))),
"the #7501 baseline keeps its commit id:\n{md}"
);
}
#[test]
fn write_pause_snapshot_nonrepo_still_writes() {
let tmp = TempDir::new().unwrap();
let input = PauseSnapshotInput {
session_id: "s1",
summary: "Summary.",
completed: &[],
in_progress: &[],
next_steps: &[],
tmux_window: None,
};
let outcome = write_pause_snapshot(tmp.path(), &input).unwrap();
let content = std::fs::read_to_string(&outcome.snapshot_path).unwrap();
assert!(!content.contains("## Git Context"));
}
}