use chrono::{DateTime, Utc};
use serde::Serialize;
use super::CatchupOptions;
use super::derive_palace_id_for;
use super::git::{CommitSummary, git_commits_since};
use super::palace::fetch_recent_palace_drawers;
use super::session_finder::{PausedSession, find_paused_sessions};
use super::state::load_catchup_state;
#[derive(Debug, Clone, Serialize)]
pub struct PausedSessionJson {
pub format: String,
pub paused_at: Option<DateTime<Utc>>,
pub summary: String,
pub in_progress: Option<String>,
pub next_steps: Option<String>,
pub git_context: Option<String>,
pub tmux_window: Option<String>,
pub source_file: Option<String>,
}
fn paused_session_to_json(session: &PausedSession) -> PausedSessionJson {
match session {
PausedSession::TrustyMpm {
path,
paused_at,
summary,
git_context,
in_progress,
next_steps,
tmux_window,
} => PausedSessionJson {
format: "trusty-mpm".to_string(),
paused_at: *paused_at,
summary: summary.clone(),
in_progress: in_progress.clone(),
next_steps: next_steps.clone(),
git_context: git_context.clone(),
tmux_window: tmux_window.clone(),
source_file: Some(path.display().to_string()),
},
PausedSession::ClaudeMpm { session: s } => {
let in_progress = {
let mut items: Vec<String> = Vec::new();
if let Some(t) = &s.todos {
items.extend(t.iter().cloned());
}
if let Some(t) = &s.task_list {
items.extend(t.iter().cloned());
}
if items.is_empty() {
None
} else {
Some(items.join("\n"))
}
};
PausedSessionJson {
format: "claude-mpm".to_string(),
paused_at: s
.paused_at
.as_deref()
.and_then(|ts| ts.parse::<DateTime<Utc>>().ok()),
summary: s.resume_instructions.clone().unwrap_or_default(),
in_progress,
next_steps: s
.open_questions
.as_ref()
.filter(|q| !q.is_empty())
.map(|q| q.join("\n")),
git_context: s.git_context.clone(),
tmux_window: None,
source_file: None,
}
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct RecentMemoryJson {
pub title: String,
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct CatchupJson {
pub sessions: Vec<PausedSessionJson>,
pub recent_commits: Vec<CommitSummary>,
pub recent_memory: Vec<RecentMemoryJson>,
}
pub async fn generate_catchup_json(opts: &CatchupOptions) -> CatchupJson {
let palace_id = derive_palace_id_for(&opts.project_dir);
let watermark: Option<DateTime<Utc>> = if opts.full {
None
} else {
load_catchup_state(&palace_id).map(|s| s.last_catchup_at)
};
let sessions = match find_paused_sessions(&opts.project_dir) {
Ok(sessions) => {
let sessions: Vec<_> = if let Some(wm) = watermark {
sessions
.into_iter()
.filter(|s| s.sort_key().is_none_or(|ts| ts > wm))
.collect()
} else {
sessions
};
sessions.iter().map(paused_session_to_json).collect()
}
Err(e) => {
eprintln!("catchup: warning: could not scan paused sessions: {e}");
Vec::new()
}
};
let recent_commits = if opts.include_git {
git_commits_since(&opts.project_dir, watermark)
.into_iter()
.take(opts.git_limit)
.collect()
} else {
Vec::new()
};
let recent_memory = if opts.include_palace {
match fetch_recent_palace_drawers(
&opts.memory_url,
&palace_id,
opts.drawer_limit,
watermark,
)
.await
{
Some(drawers) => drawers
.iter()
.map(|d| RecentMemoryJson {
title: d.title.clone(),
tags: d.tags.clone(),
})
.collect(),
None => Vec::new(),
}
} else {
Vec::new()
};
CatchupJson {
sessions,
recent_commits,
recent_memory,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catchup::CatchupOptions;
use crate::catchup::state::{CatchupState, save_catchup_state};
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
fn init_git_repo(tmp: &TempDir) {
let p = tmp.path();
std::process::Command::new("git")
.arg("-C")
.arg(p)
.args(["init"])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(p)
.args(["config", "user.email", "t@t.com"])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(p)
.args(["config", "user.name", "T"])
.output()
.unwrap();
fs::write(p.join("README.md"), b"test").unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(p)
.args(["add", "."])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(p)
.args(["commit", "-m", "init"])
.output()
.unwrap();
}
#[tokio::test]
async fn generate_catchup_json_returns_structured_fields() {
let tmp = TempDir::new().unwrap();
init_git_repo(&tmp);
let sessions_dir = tmp.path().join(".trusty-mpm").join("sessions");
fs::create_dir_all(&sessions_dir).unwrap();
fs::write(
sessions_dir.join("session-20260627-100000.md"),
"## Summary\nDid the thing.\n\n## Next Steps\nShip it.\n",
)
.unwrap();
let opts = CatchupOptions {
project_dir: tmp.path().to_path_buf(),
memory_url: "http://127.0.0.1:19999".to_string(),
include_git: true,
include_palace: true,
git_limit: 50,
drawer_limit: 15,
full: true,
};
let json = generate_catchup_json(&opts).await;
assert_eq!(json.sessions.len(), 1, "fixture snapshot should be found");
assert_eq!(json.sessions[0].format, "trusty-mpm");
assert_eq!(json.sessions[0].summary, "Did the thing.");
assert_eq!(json.sessions[0].next_steps.as_deref(), Some("Ship it."));
assert!(
json.sessions[0].source_file.is_some(),
"trusty-mpm sessions carry a source_file"
);
assert!(
json.recent_commits.iter().any(|c| c.msg == "init"),
"commit summary should be structured, not prose: {:?}",
json.recent_commits
);
assert!(json.recent_memory.is_empty());
}
#[tokio::test]
async fn generate_catchup_json_respects_watermark() {
let tmp = TempDir::new().unwrap();
init_git_repo(&tmp);
let palace_id = derive_palace_id_for(tmp.path());
let state = CatchupState {
last_catchup_at: "2099-01-01T00:00:00Z".parse().unwrap(),
palace_id: palace_id.clone(),
last_git_sha: None,
};
save_catchup_state(&palace_id, &state).unwrap();
let opts = CatchupOptions {
project_dir: tmp.path().to_path_buf(),
memory_url: "http://127.0.0.1:19999".to_string(),
include_git: true,
include_palace: false,
git_limit: 50,
drawer_limit: 15,
full: false,
};
let json = generate_catchup_json(&opts).await;
assert!(
json.recent_commits.is_empty(),
"future watermark should exclude all commits: {:?}",
json.recent_commits
);
}
#[test]
fn paused_session_to_json_maps_trusty_mpm_fields() {
let session = PausedSession::TrustyMpm {
path: PathBuf::from("/tmp/session-1.md"),
paused_at: None,
summary: "Summary text".to_string(),
git_context: Some("branch: main".to_string()),
in_progress: Some("doing X".to_string()),
next_steps: Some("do Y".to_string()),
tmux_window: Some("main:2:@7".to_string()),
};
let json = paused_session_to_json(&session);
assert_eq!(json.format, "trusty-mpm");
assert_eq!(json.summary, "Summary text");
assert_eq!(json.git_context.as_deref(), Some("branch: main"));
assert_eq!(json.tmux_window.as_deref(), Some("main:2:@7"));
assert_eq!(json.source_file.as_deref(), Some("/tmp/session-1.md"));
}
#[test]
fn paused_session_to_json_maps_claude_mpm_fields() {
use crate::catchup::mpm_session::ClaudeMpmSession;
let session = ClaudeMpmSession {
resume_instructions: Some("Resume from step 3".to_string()),
todos: Some(vec!["todo 1".to_string()]),
open_questions: Some(vec!["q1".to_string()]),
git_context: Some("branch: main".to_string()),
..Default::default()
};
let json = paused_session_to_json(&PausedSession::ClaudeMpm { session });
assert_eq!(json.format, "claude-mpm");
assert_eq!(json.summary, "Resume from step 3");
assert_eq!(json.in_progress.as_deref(), Some("todo 1"));
assert_eq!(json.next_steps.as_deref(), Some("q1"));
assert!(json.tmux_window.is_none());
assert!(json.source_file.is_none());
}
}