use std::path::{Path, PathBuf};
use crate::catchup::json::PausedSessionJson;
use crate::catchup::session_finder::{PausedSession, find_paused_sessions};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ResolutionPath {
SessionId,
TmuxWindow,
}
impl ResolutionPath {
pub fn as_str(self) -> &'static str {
match self {
ResolutionPath::SessionId => "session_id",
ResolutionPath::TmuxWindow => "tmux_window",
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ResolvedSnapshot {
pub path: PathBuf,
pub via: ResolutionPath,
}
impl ResolvedSnapshot {
pub fn new(path: PathBuf, via: ResolutionPath) -> Self {
Self { path, via }
}
}
pub fn resolve_snapshot_for_caller(
project_dir: &Path,
session_id: Option<&str>,
tmux_window: Option<&str>,
) -> Option<ResolvedSnapshot> {
if let Some(path) =
crate::catchup::session_finder::latest_trusty_mpm_snapshot(project_dir, session_id)
{
return Some(ResolvedSnapshot::new(path, ResolutionPath::SessionId));
}
let caller_window = window_id_of(tmux_window?)?;
newest_snapshot_in_window(project_dir, caller_window)
.map(|path| ResolvedSnapshot::new(path, ResolutionPath::TmuxWindow))
}
fn newest_snapshot_in_window(project_dir: &Path, window_id: &str) -> Option<PathBuf> {
find_paused_sessions(project_dir)
.ok()?
.into_iter()
.find_map(|s| match s {
PausedSession::TrustyMpm {
path,
tmux_window: Some(w),
..
} if window_id_of(&w) == Some(window_id) => Some(path),
_ => None,
})
}
pub fn window_id_of(field: &str) -> Option<&str> {
let id = field.trim().rsplit(':').next()?;
(id.len() > 1 && id.starts_with('@')).then_some(id)
}
#[derive(Debug, Clone, Copy, Default)]
#[non_exhaustive]
pub struct CallerIdentity<'a> {
pub session_id: Option<&'a str>,
pub tmux_window: Option<&'a str>,
}
impl<'a> CallerIdentity<'a> {
pub fn new(session_id: Option<&'a str>, tmux_window: Option<&'a str>) -> Self {
Self {
session_id,
tmux_window,
}
}
}
pub fn redact_sessions_not_owned_by(
project_dir: &Path,
caller: &CallerIdentity<'_>,
sessions: &mut [PausedSessionJson],
) {
let owned_paths = caller
.session_id
.map(|id| {
let sessions_dir = project_dir.join(".trusty-mpm").join("sessions");
crate::catchup::session_log::snapshots_attributed_to(&sessions_dir, id, "md")
.iter()
.map(|p| canonical(p))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let caller_window = caller.tmux_window.and_then(window_id_of);
for s in sessions.iter_mut() {
if !is_owned_by(s, &owned_paths, caller_window) {
withhold(s);
}
}
}
fn is_owned_by(
session: &PausedSessionJson,
owned_paths: &[PathBuf],
caller_window: Option<&str>,
) -> bool {
if let Some(file) = session.source_file.as_deref() {
let path = canonical(Path::new(file));
if owned_paths.contains(&path) {
return true;
}
}
match (
session.tmux_window.as_deref().and_then(window_id_of),
caller_window,
) {
(Some(recorded), Some(mine)) => recorded == mine,
_ => false,
}
}
fn withhold(session: &mut PausedSessionJson) {
session.source_file = None;
session.tmux_window = None;
session.in_progress = None;
session.next_steps = None;
session.git_context = None;
session.owned = false;
}
fn canonical(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catchup::pause::{PauseSnapshotInput, write_pause_snapshot};
fn pause(dir: &Path, session_id: &str, window: Option<&str>) -> PathBuf {
let input = PauseSnapshotInput {
session_id,
summary: "work",
completed: &[],
in_progress: &[],
next_steps: &[],
tmux_window: window,
};
write_pause_snapshot(dir, &input).unwrap().snapshot_path
}
#[test]
fn window_id_of_reads_the_third_component() {
assert_eq!(window_id_of("tm-dogfood:0:@230"), Some("@230"));
assert_eq!(window_id_of(" main:12:@7 "), Some("@7"));
assert_eq!(window_id_of("@230"), Some("@230"));
}
#[test]
fn window_id_of_tolerates_colons_in_the_session_name() {
assert_eq!(window_id_of("my:proj:0:@7"), Some("@7"));
assert_eq!(window_id_of("a:b:c:d:e:12:@230"), Some("@230"));
assert_eq!(
window_id_of("a:b:c:d"),
None,
"a non-@ tail is not a window"
);
let tmp = tempfile::TempDir::new().unwrap();
pause(tmp.path(), "writer", Some("my:proj:0:@7"));
let got = resolve_snapshot_for_caller(tmp.path(), None, Some("my:proj:0:@7"))
.expect("a colon in the session name must not break the caller's own match");
assert_eq!(got.via, ResolutionPath::TmuxWindow);
}
fn entry(path: &Path, window: Option<&str>) -> PausedSessionJson {
PausedSessionJson {
format: "trusty-mpm".to_string(),
paused_at: None,
summary: "work".to_string(),
in_progress: Some("halfway through X".to_string()),
next_steps: Some("finish X".to_string()),
git_context: Some("branch: main".to_string()),
tmux_window: window.map(str::to_string),
source_file: Some(path.display().to_string()),
owned: true,
}
}
#[test]
fn redaction_withholds_handles_and_restorable_state() {
let tmp = tempfile::TempDir::new().unwrap();
let theirs = pause(tmp.path(), "theirs", Some("tm-dogfood:0:@230"));
let mut sessions = vec![entry(&theirs, Some("tm-dogfood:0:@230"))];
redact_sessions_not_owned_by(
tmp.path(),
&CallerIdentity::new(Some("nobody"), Some("other:1:@999")),
&mut sessions,
);
let s = &sessions[0];
assert!(!s.owned, "a session the caller does not own must say so");
assert_eq!(s.source_file, None, "the snapshot path is a handle");
assert_eq!(s.tmux_window, None, "the window is a handle");
assert_eq!(s.in_progress, None);
assert_eq!(s.next_steps, None);
assert_eq!(s.git_context, None);
assert_eq!(s.summary, "work");
assert_eq!(s.format, "trusty-mpm");
}
#[test]
fn redaction_leaves_nothing_to_reconstruct_a_window_claim_from() {
let tmp = tempfile::TempDir::new().unwrap();
let theirs = pause(tmp.path(), "theirs", Some("tm-dogfood:0:@230"));
let mut sessions = vec![entry(&theirs, Some("tm-dogfood:0:@230"))];
redact_sessions_not_owned_by(tmp.path(), &CallerIdentity::default(), &mut sessions);
let wire = serde_json::to_string(&sessions[0]).unwrap();
assert!(
!wire.contains("@230"),
"the window id must not survive anywhere on the wire: {wire}"
);
assert!(
!wire.contains(theirs.to_str().unwrap()),
"the snapshot path must not survive anywhere on the wire: {wire}"
);
}
#[test]
fn owner_sees_every_field() {
let tmp = tempfile::TempDir::new().unwrap();
let mine = pause(tmp.path(), "mine", Some("tm-dogfood:0:@230"));
let mut sessions = vec![entry(&mine, Some("tm-dogfood:0:@230"))];
redact_sessions_not_owned_by(
tmp.path(),
&CallerIdentity::new(Some("mine"), None),
&mut sessions,
);
let s = &sessions[0];
assert!(s.owned);
assert_eq!(s.source_file.as_deref(), Some(mine.to_str().unwrap()));
assert_eq!(s.tmux_window.as_deref(), Some("tm-dogfood:0:@230"));
assert_eq!(s.next_steps.as_deref(), Some("finish X"));
}
#[test]
fn window_owner_sees_every_field() {
let tmp = tempfile::TempDir::new().unwrap();
let theirs = pause(
tmp.path(),
"some-earlier-incarnation",
Some("tm-dogfood:0:@230"),
);
let mut sessions = vec![entry(&theirs, Some("tm-dogfood:0:@230"))];
redact_sessions_not_owned_by(
tmp.path(),
&CallerIdentity::new(Some("relaunched-id"), Some("renamed:7:@230")),
&mut sessions,
);
assert!(sessions[0].owned, "the window that paused it owns it");
assert!(sessions[0].source_file.is_some());
}
#[test]
fn an_unattributable_session_is_owned_by_nobody() {
let tmp = tempfile::TempDir::new().unwrap();
let mut sessions = vec![PausedSessionJson {
format: "claude-mpm".to_string(),
paused_at: None,
summary: "legacy work".to_string(),
in_progress: Some("todo 1".to_string()),
next_steps: None,
git_context: None,
tmux_window: None,
source_file: None,
owned: true,
}];
redact_sessions_not_owned_by(
tmp.path(),
&CallerIdentity::new(Some("anyone"), Some("tm-dogfood:0:@230")),
&mut sessions,
);
assert!(!sessions[0].owned);
assert_eq!(sessions[0].in_progress, None);
assert_eq!(sessions[0].summary, "legacy work");
}
#[test]
fn malformed_window_fields_never_match() {
for bad in ["", " ", "main", "main:0", "a:b:", "a:b:c:d", ":", "::"] {
assert_eq!(window_id_of(bad), None, "{bad:?} must not parse");
}
let tmp = tempfile::TempDir::new().unwrap();
pause(tmp.path(), "writer", Some("tm-dogfood:0:@230"));
for bad in ["", "main", "main:0", "a:b:c:d"] {
assert!(
resolve_snapshot_for_caller(tmp.path(), Some("nobody"), Some(bad)).is_none(),
"caller window {bad:?} must resolve nothing"
);
}
}
#[test]
fn window_fallback_resolves_when_session_id_never_paused() {
let tmp = tempfile::TempDir::new().unwrap();
pause(tmp.path(), "old-incarnation", Some("tm-dogfood:0:@230"));
let got = resolve_snapshot_for_caller(
tmp.path(),
Some("69895d04-149d-4c31-a640-29048831f9a5"),
Some("tm-dogfood:0:@230"),
)
.expect("the window that paused must resolve its own snapshot");
assert_eq!(got.via, ResolutionPath::TmuxWindow);
assert!(got.path.is_file());
}
#[test]
fn window_match_ignores_session_name_and_index() {
let tmp = tempfile::TempDir::new().unwrap();
pause(tmp.path(), "writer", Some("tm-dogfood:0:@230"));
let got = resolve_snapshot_for_caller(tmp.path(), None, Some("renamed:7:@230"))
.expect("the window id is what identifies the window");
assert_eq!(got.via, ResolutionPath::TmuxWindow);
}
#[test]
fn window_fallback_reports_its_resolution_path() {
assert_eq!(ResolutionPath::SessionId.as_str(), "session_id");
assert_eq!(ResolutionPath::TmuxWindow.as_str(), "tmux_window");
}
#[test]
fn exact_session_id_match_wins_over_window_match() {
let tmp = tempfile::TempDir::new().unwrap();
let mine = pause(tmp.path(), "mine", Some("tm-dogfood:0:@230"));
let theirs = pause(tmp.path(), "theirs", Some("tm-dogfood:0:@230"));
assert_ne!(mine, theirs);
let got = resolve_snapshot_for_caller(tmp.path(), Some("mine"), Some("tm-dogfood:0:@230"))
.expect("an owning id always resolves");
assert_eq!(got.path, mine, "the exact id must not be overridden");
assert_eq!(got.via, ResolutionPath::SessionId);
}
#[test]
fn window_fallback_is_scoped_to_the_project_dir() {
let a = tempfile::TempDir::new().unwrap();
let b = tempfile::TempDir::new().unwrap();
pause(a.path(), "writer", Some("tm-dogfood:0:@230"));
assert!(resolve_snapshot_for_caller(a.path(), None, Some("tm-dogfood:0:@230")).is_some());
assert!(
resolve_snapshot_for_caller(b.path(), None, Some("tm-dogfood:0:@230")).is_none(),
"another project's store must not answer"
);
}
#[test]
fn snapshot_without_a_recorded_window_is_skipped() {
let tmp = tempfile::TempDir::new().unwrap();
pause(tmp.path(), "legacy", None);
assert!(resolve_snapshot_for_caller(tmp.path(), None, Some("tm-dogfood:0:@230")).is_none());
let windowed = pause(tmp.path(), "modern", Some("tm-dogfood:0:@230"));
let got = resolve_snapshot_for_caller(tmp.path(), None, Some("tm-dogfood:0:@230")).unwrap();
assert_eq!(got.path, windowed);
}
#[test]
fn no_session_id_and_no_window_resolves_nothing() {
let tmp = tempfile::TempDir::new().unwrap();
pause(tmp.path(), "writer", Some("tm-dogfood:0:@230"));
assert!(resolve_snapshot_for_caller(tmp.path(), None, None).is_none());
assert!(resolve_snapshot_for_caller(tmp.path(), Some("nobody"), None).is_none());
}
}