use std::path::{Path, PathBuf};
use anyhow::Context as _;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ClaudeMpmSession {
#[serde(default)]
pub session_id: String,
#[serde(default)]
pub paused_at: Option<String>,
#[serde(default)]
pub duration_hours: Option<f64>,
#[serde(default)]
pub context_usage: Option<f64>,
#[serde(default)]
pub git_context: Option<String>,
#[serde(default)]
pub important_reminders: Option<Vec<String>>,
#[serde(default)]
pub resume_instructions: Option<String>,
#[serde(default)]
pub open_questions: Option<Vec<String>>,
#[serde(default)]
pub todos: Option<Vec<String>>,
#[serde(default)]
pub task_list: Option<Vec<String>>,
#[serde(default)]
pub project_path: Option<String>,
}
pub fn load_latest_claude_mpm_session(
project_dir: &Path,
) -> anyhow::Result<Option<ClaudeMpmSession>> {
let sessions_dir = project_dir.join(".claude-mpm").join("sessions");
if !sessions_dir.is_dir() {
return Ok(None);
}
let pointer = sessions_dir.join("LATEST-SESSION.txt");
if let Ok(content) = std::fs::read_to_string(&pointer) {
let candidate = content
.lines()
.find(|l| l.trim().ends_with(".json"))
.map(|l| l.trim().to_owned());
if let Some(fname) = candidate {
let path = sessions_dir.join(&fname);
if path.exists() {
let session = parse_session_file(&path)?;
return Ok(Some(session));
}
}
let first_line = content.lines().next().unwrap_or("").trim().to_owned();
if !first_line.is_empty() {
let path = sessions_dir.join(&first_line);
if path.exists() && first_line.ends_with(".json") {
let session = parse_session_file(&path)?;
return Ok(Some(session));
}
}
}
newest_session_file(&sessions_dir)
.map(|opt| opt.map(|p| parse_session_file(&p)).transpose())
.unwrap_or(Ok(None))
}
pub fn load_all_claude_mpm_sessions(project_dir: &Path) -> anyhow::Result<Vec<ClaudeMpmSession>> {
let sessions_dir = project_dir.join(".claude-mpm").join("sessions");
if !sessions_dir.is_dir() {
return Ok(vec![]);
}
let mut sessions: Vec<(String, ClaudeMpmSession)> = std::fs::read_dir(&sessions_dir)
.with_context(|| format!("reading {}", sessions_dir.display()))?
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name().into_string().unwrap_or_default();
name.starts_with("session-") && name.ends_with(".json")
})
.filter_map(|e| {
let name = e.file_name().into_string().unwrap_or_default();
let path = e.path();
match parse_session_file(&path) {
Ok(s) => Some((name, s)),
Err(err) => {
tracing::warn!(
path = %path.display(),
error = %err,
"skipping malformed claude-mpm session file"
);
None
}
}
})
.collect();
sessions.sort_by(|(name_a, a), (name_b, b)| {
let ka = a.paused_at.as_deref().unwrap_or(name_a.as_str());
let kb = b.paused_at.as_deref().unwrap_or(name_b.as_str());
kb.cmp(ka)
});
Ok(sessions.into_iter().map(|(_, s)| s).collect())
}
fn parse_session_file(path: &Path) -> anyhow::Result<ClaudeMpmSession> {
let bytes =
std::fs::read(path).with_context(|| format!("reading session file {}", path.display()))?;
serde_json::from_slice(&bytes)
.with_context(|| format!("parsing session file {}", path.display()))
}
fn newest_session_file(sessions_dir: &Path) -> anyhow::Result<Option<PathBuf>> {
let best = std::fs::read_dir(sessions_dir)
.with_context(|| format!("reading {}", sessions_dir.display()))?
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name().into_string().unwrap_or_default();
name.starts_with("session-") && name.ends_with(".json")
})
.max_by_key(|e| e.metadata().and_then(|m| m.modified()).ok());
Ok(best.map(|e| e.path()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_session(dir: &Path, filename: &str, json: &str) -> PathBuf {
let path = dir.join(filename);
fs::write(&path, json).unwrap();
path
}
fn sessions_dir(tmp: &TempDir) -> PathBuf {
let d = tmp.path().join(".claude-mpm").join("sessions");
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn roundtrip_full_json() {
let json = serde_json::json!({
"session_id": "abc-123",
"paused_at": "2026-06-27T10:00:00Z",
"duration_hours": 1.5,
"context_usage": 0.72,
"git_context": "branch: main\ncommit: abc1234",
"important_reminders": ["remember A", "remember B"],
"resume_instructions": "Pick up from step 3",
"open_questions": ["Should we use X?"],
"todos": ["Fix lint"],
"task_list": ["task 1", "task 2"],
"project_path": "/home/user/my-proj"
});
let s: ClaudeMpmSession = serde_json::from_value(json).unwrap();
assert_eq!(s.session_id, "abc-123");
assert_eq!(s.paused_at.as_deref(), Some("2026-06-27T10:00:00Z"));
assert_eq!(s.context_usage, Some(0.72));
assert_eq!(s.important_reminders.as_deref().unwrap().len(), 2);
assert_eq!(s.todos.as_deref().unwrap(), &["Fix lint"]);
}
#[test]
fn roundtrip_skips_conversation() {
let json = serde_json::json!({
"session_id": "skip-conv",
"paused_at": "2026-06-27T11:00:00Z",
"conversation": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "world"}
]
});
let s: ClaudeMpmSession = serde_json::from_value(json).unwrap();
assert_eq!(s.session_id, "skip-conv");
}
#[test]
fn roundtrip_partial_json_uses_defaults() {
let json = serde_json::json!({
"session_id": "partial"
});
let s: ClaudeMpmSession = serde_json::from_value(json).unwrap();
assert_eq!(s.session_id, "partial");
assert!(s.paused_at.is_none());
assert!(s.resume_instructions.is_none());
assert!(s.todos.is_none());
}
#[test]
fn load_latest_uses_pointer() {
let tmp = TempDir::new().unwrap();
let sdir = sessions_dir(&tmp);
let project_dir = tmp.path();
let fname = "session-20260627-100000.json";
write_session(
&sdir,
fname,
r#"{"session_id":"ptr-sess","paused_at":"2026-06-27T10:00:00Z"}"#,
);
fs::write(sdir.join("LATEST-SESSION.txt"), fname).unwrap();
let result = load_latest_claude_mpm_session(project_dir).unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().session_id, "ptr-sess");
}
#[test]
fn load_latest_falls_back_to_newest_mtime() {
let tmp = TempDir::new().unwrap();
let sdir = sessions_dir(&tmp);
write_session(
&sdir,
"session-20260626-090000.json",
r#"{"session_id":"old"}"#,
);
std::thread::sleep(std::time::Duration::from_millis(10));
write_session(
&sdir,
"session-20260627-100000.json",
r#"{"session_id":"new"}"#,
);
let result = load_latest_claude_mpm_session(tmp.path()).unwrap();
assert!(result.is_some());
}
#[test]
fn load_all_returns_sorted() {
let tmp = TempDir::new().unwrap();
let sdir = sessions_dir(&tmp);
write_session(
&sdir,
"session-20260625-080000.json",
r#"{"session_id":"oldest","paused_at":"2026-06-25T08:00:00Z"}"#,
);
write_session(
&sdir,
"session-20260627-100000.json",
r#"{"session_id":"newest","paused_at":"2026-06-27T10:00:00Z"}"#,
);
write_session(
&sdir,
"session-20260626-090000.json",
r#"{"session_id":"middle","paused_at":"2026-06-26T09:00:00Z"}"#,
);
let all = load_all_claude_mpm_sessions(tmp.path()).unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all[0].session_id, "newest");
assert_eq!(all[2].session_id, "oldest");
}
#[test]
fn load_all_skips_malformed_with_warning() {
let tmp = TempDir::new().unwrap();
let sdir = sessions_dir(&tmp);
write_session(
&sdir,
"session-20260625-080000.json",
r#"{"session_id":"valid-a","paused_at":"2026-06-25T08:00:00Z"}"#,
);
write_session(
&sdir,
"session-20260627-100000.json",
r#"{"session_id":"valid-b","paused_at":"2026-06-27T10:00:00Z"}"#,
);
write_session(&sdir, "session-20260626-090000.json", r#"{"session_id":"#);
let all = load_all_claude_mpm_sessions(tmp.path()).unwrap();
assert_eq!(
all.len(),
2,
"expected 2 valid sessions, got {}: {:?}",
all.len(),
all.iter().map(|s| &s.session_id).collect::<Vec<_>>()
);
let ids: Vec<&str> = all.iter().map(|s| s.session_id.as_str()).collect();
assert!(ids.contains(&"valid-a"), "missing valid-a in {ids:?}");
assert!(ids.contains(&"valid-b"), "missing valid-b in {ids:?}");
}
}