//! Context recovery for task resumption and restart.
//!
//! This module provides functionality to rebuild context when tasks resume or restart,
//! ensuring Claude can quickly orient itself with the current state of the worktree.
use std::path::Path;
use std::process::Command;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Summary of git status information.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GitStatusSummary {
/// Number of staged files.
pub staged: u32,
/// Number of modified (unstaged) files.
pub modified: u32,
/// Number of untracked files.
pub untracked: u32,
/// Number of deleted files.
pub deleted: u32,
/// Brief description (e.g., "3 modified, 1 staged").
pub summary: String,
}
/// Information about a checkpoint (git commit).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckpointInfo {
/// Commit SHA (short form).
pub sha: String,
/// Commit message (first line).
pub message: String,
/// Commit timestamp.
pub timestamp: DateTime<Utc>,
}
/// A single progress entry from the progress log.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressEntry {
/// Timestamp of the entry.
pub timestamp: DateTime<Utc>,
/// Entry type (e.g., "ITERATION", "TASK_START", "CHECKPOINT").
pub entry_type: String,
/// Entry message or summary.
pub message: String,
}
/// Context recovery data for a task.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextRecovery {
/// Current git branch name.
pub current_branch: String,
/// Git status summary.
pub git_status: GitStatusSummary,
/// Last checkpoint information (most recent commit).
pub last_checkpoint: Option<CheckpointInfo>,
/// Recent progress entries from progress.log.
pub recent_progress: Vec<ProgressEntry>,
/// Current phase of the task.
pub current_phase: String,
}
impl ContextRecovery {
/// Format the context recovery as a markdown header suitable for injection into prompts.
pub fn to_markdown(&self) -> String {
let mut output = String::new();
output.push_str("## Context Recovery\n");
output.push_str(&format!("- Current branch: {}\n", self.current_branch));
output.push_str(&format!("- Git status: {}\n", self.git_status.summary));
if let Some(ref checkpoint) = self.last_checkpoint {
output.push_str(&format!(
"- Last checkpoint: {} - {} ({})\n",
checkpoint.sha,
checkpoint.message,
checkpoint.timestamp.format("%Y-%m-%d %H:%M:%S UTC")
));
} else {
output.push_str("- Last checkpoint: none\n");
}
if self.recent_progress.is_empty() {
output.push_str("- Progress log (recent): none\n");
} else {
output.push_str("- Progress log (recent):\n");
for entry in &self.recent_progress {
output.push_str(&format!(
" - [{}] {}: {}\n",
entry.timestamp.format("%H:%M:%S"),
entry.entry_type,
entry.message
));
}
}
output.push_str(&format!("- Current phase: {}\n", self.current_phase));
output.push_str("\n---\n\n");
output
}
}
/// Build context recovery information for a task worktree.
///
/// # Arguments
/// * `worktree_path` - Path to the task's worktree directory
/// * `phase` - Current task phase (e.g., "execute", "review")
/// * `max_progress_entries` - Maximum number of recent progress entries to include
///
/// # Returns
/// A formatted markdown string containing context recovery information.
pub fn build_context_recovery(
worktree_path: &Path,
phase: &str,
max_progress_entries: usize,
) -> String {
let context = build_context_recovery_data(worktree_path, phase, max_progress_entries);
context.to_markdown()
}
/// Build context recovery data structure for a task worktree.
///
/// # Arguments
/// * `worktree_path` - Path to the task's worktree directory
/// * `phase` - Current task phase (e.g., "execute", "review")
/// * `max_progress_entries` - Maximum number of recent progress entries to include
///
/// # Returns
/// A ContextRecovery struct containing all gathered information.
pub fn build_context_recovery_data(
worktree_path: &Path,
phase: &str,
max_progress_entries: usize,
) -> ContextRecovery {
let current_branch = get_current_branch(worktree_path).unwrap_or_else(|| "unknown".to_string());
let git_status = get_git_status_summary(worktree_path);
let last_checkpoint = get_last_checkpoint(worktree_path);
let recent_progress = get_recent_progress(worktree_path, max_progress_entries);
ContextRecovery {
current_branch,
git_status,
last_checkpoint,
recent_progress,
current_phase: phase.to_string(),
}
}
/// Get the current git branch name.
fn get_current_branch(worktree_path: &Path) -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(worktree_path)
.output()
.ok()?;
if output.status.success() {
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
if branch.is_empty() || branch == "HEAD" {
// Detached HEAD state - try to get the commit SHA
let sha_output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.current_dir(worktree_path)
.output()
.ok()?;
if sha_output.status.success() {
Some(format!("(detached at {})", String::from_utf8_lossy(&sha_output.stdout).trim()))
} else {
Some("(detached)".to_string())
}
} else {
Some(branch)
}
} else {
None
}
}
/// Get a summary of the git status.
fn get_git_status_summary(worktree_path: &Path) -> GitStatusSummary {
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(worktree_path)
.output();
let output = match output {
Ok(o) if o.status.success() => o,
_ => return GitStatusSummary {
summary: "unable to get git status".to_string(),
..Default::default()
},
};
let status_output = String::from_utf8_lossy(&output.stdout);
let mut staged = 0u32;
let mut modified = 0u32;
let mut untracked = 0u32;
let mut deleted = 0u32;
for line in status_output.lines() {
if line.len() < 2 {
continue;
}
let index_status = line.chars().next().unwrap_or(' ');
let worktree_status = line.chars().nth(1).unwrap_or(' ');
// Count staged changes (index column)
match index_status {
'A' | 'M' | 'R' | 'C' => staged += 1,
'D' => {
staged += 1;
deleted += 1;
}
_ => {}
}
// Count unstaged changes (worktree column)
match worktree_status {
'M' => modified += 1,
'D' => {
modified += 1;
deleted += 1;
}
'?' => untracked += 1,
_ => {}
}
}
// Build summary string
let mut parts = Vec::new();
if staged > 0 {
parts.push(format!("{} staged", staged));
}
if modified > 0 {
parts.push(format!("{} modified", modified));
}
if untracked > 0 {
parts.push(format!("{} untracked", untracked));
}
let summary = if parts.is_empty() {
"clean".to_string()
} else {
parts.join(", ")
};
GitStatusSummary {
staged,
modified,
untracked,
deleted,
summary,
}
}
/// Get information about the last checkpoint (most recent commit).
fn get_last_checkpoint(worktree_path: &Path) -> Option<CheckpointInfo> {
// Get the most recent commit info
let output = Command::new("git")
.args([
"log",
"-1",
"--format=%h|%s|%aI", // short sha | subject | ISO 8601 date
])
.current_dir(worktree_path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let log_output = String::from_utf8_lossy(&output.stdout).trim().to_string();
let parts: Vec<&str> = log_output.splitn(3, '|').collect();
if parts.len() < 3 {
return None;
}
let sha = parts[0].to_string();
let message = parts[1].to_string();
let timestamp = DateTime::parse_from_rfc3339(parts[2])
.ok()?
.with_timezone(&Utc);
Some(CheckpointInfo {
sha,
message,
timestamp,
})
}
/// Get recent progress entries from the progress.log file.
fn get_recent_progress(worktree_path: &Path, max_entries: usize) -> Vec<ProgressEntry> {
let progress_log_path = worktree_path.join("progress.log");
let content = match std::fs::read_to_string(&progress_log_path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let mut entries = Vec::new();
// Parse the progress.log file
// Expected format:
// [2024-01-15T10:30:00Z] ENTRY_TYPE
// key: value
// key: value
// (blank line)
let mut lines = content.lines().peekable();
while let Some(line) = lines.next() {
// Look for timestamp lines
if line.starts_with('[') && line.contains(']') {
if let Some(entry) = parse_progress_entry(line, &mut lines) {
entries.push(entry);
}
}
}
// Return the most recent entries (up to max_entries)
let start = if entries.len() > max_entries {
entries.len() - max_entries
} else {
0
};
entries[start..].to_vec()
}
/// Parse a single progress entry from the log.
fn parse_progress_entry<'a, I>(header_line: &str, lines: &mut std::iter::Peekable<I>) -> Option<ProgressEntry>
where
I: Iterator<Item = &'a str>,
{
// Parse header: [2024-01-15T10:30:00Z] ENTRY_TYPE
let close_bracket = header_line.find(']')?;
let timestamp_str = &header_line[1..close_bracket];
let entry_type = header_line[close_bracket + 1..].trim().to_string();
let timestamp = DateTime::parse_from_rfc3339(timestamp_str)
.ok()?
.with_timezone(&Utc);
// Collect message from following lines until blank line or next entry
let mut message_parts = Vec::new();
while let Some(&next_line) = lines.peek() {
if next_line.is_empty() || next_line.starts_with('#') {
lines.next(); // consume blank/comment line
break;
}
if next_line.starts_with('[') {
// Next entry starts, don't consume
break;
}
let line = lines.next().unwrap();
// Extract key-value or just the line
if let Some(colon_pos) = line.find(':') {
let key = line[..colon_pos].trim();
let value = line[colon_pos + 1..].trim();
if key == "progress" || key == "message" || key == "reason" {
message_parts.push(value.to_string());
}
}
}
let message = if message_parts.is_empty() {
entry_type.clone()
} else {
message_parts.join("; ")
};
Some(ProgressEntry {
timestamp,
entry_type,
message,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup_git_repo() -> TempDir {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path();
// Initialize git repo
Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.unwrap();
// Configure git for commits
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(path)
.output()
.unwrap();
// Create initial commit
fs::write(path.join("README.md"), "# Test").unwrap();
Command::new("git")
.args(["add", "README.md"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(path)
.output()
.unwrap();
temp_dir
}
#[test]
fn test_get_current_branch() {
let temp_dir = setup_git_repo();
let branch = get_current_branch(temp_dir.path());
// Default branch could be main or master depending on git config
assert!(branch.is_some());
let branch_name = branch.unwrap();
assert!(branch_name == "main" || branch_name == "master");
}
#[test]
fn test_git_status_clean() {
let temp_dir = setup_git_repo();
let status = get_git_status_summary(temp_dir.path());
assert_eq!(status.staged, 0);
assert_eq!(status.modified, 0);
assert_eq!(status.untracked, 0);
assert_eq!(status.summary, "clean");
}
#[test]
fn test_git_status_with_changes() {
let temp_dir = setup_git_repo();
let path = temp_dir.path();
// Create untracked file
fs::write(path.join("new_file.txt"), "new content").unwrap();
// Modify tracked file
fs::write(path.join("README.md"), "# Modified").unwrap();
let status = get_git_status_summary(path);
assert_eq!(status.modified, 1);
assert_eq!(status.untracked, 1);
assert!(status.summary.contains("modified"));
assert!(status.summary.contains("untracked"));
}
#[test]
fn test_get_last_checkpoint() {
let temp_dir = setup_git_repo();
let checkpoint = get_last_checkpoint(temp_dir.path());
assert!(checkpoint.is_some());
let checkpoint = checkpoint.unwrap();
assert_eq!(checkpoint.message, "Initial commit");
assert!(!checkpoint.sha.is_empty());
}
#[test]
fn test_build_context_recovery() {
let temp_dir = setup_git_repo();
let markdown = build_context_recovery(temp_dir.path(), "execute", 5);
assert!(markdown.contains("## Context Recovery"));
assert!(markdown.contains("Current branch:"));
assert!(markdown.contains("Git status:"));
assert!(markdown.contains("Last checkpoint:"));
assert!(markdown.contains("Current phase: execute"));
}
#[test]
fn test_progress_log_parsing() {
let temp_dir = setup_git_repo();
let path = temp_dir.path();
// Create a mock progress.log
let progress_content = r#"# progress.log
# Auto-generated by Makima - DO NOT EDIT MANUALLY
[2024-01-15T10:30:00Z] TASK_START
task_id: test-123
task_name: Test Task
[2024-01-15T10:35:00Z] ITERATION 1
progress: Analyzed codebase structure
files_modified: 2
[2024-01-15T10:40:00Z] ITERATION 2
progress: Implemented first feature
files_modified: 3
"#;
fs::write(path.join("progress.log"), progress_content).unwrap();
let entries = get_recent_progress(path, 5);
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].entry_type, "TASK_START");
assert_eq!(entries[1].entry_type, "ITERATION 1");
assert_eq!(entries[2].entry_type, "ITERATION 2");
}
#[test]
fn test_context_recovery_to_markdown() {
let context = ContextRecovery {
current_branch: "feature/test".to_string(),
git_status: GitStatusSummary {
staged: 1,
modified: 2,
untracked: 0,
deleted: 0,
summary: "1 staged, 2 modified".to_string(),
},
last_checkpoint: Some(CheckpointInfo {
sha: "abc1234".to_string(),
message: "Added feature X".to_string(),
timestamp: Utc::now(),
}),
recent_progress: vec![
ProgressEntry {
timestamp: Utc::now(),
entry_type: "ITERATION 1".to_string(),
message: "Started implementation".to_string(),
},
],
current_phase: "execute".to_string(),
};
let markdown = context.to_markdown();
assert!(markdown.contains("## Context Recovery"));
assert!(markdown.contains("feature/test"));
assert!(markdown.contains("1 staged, 2 modified"));
assert!(markdown.contains("abc1234"));
assert!(markdown.contains("Added feature X"));
assert!(markdown.contains("ITERATION 1"));
assert!(markdown.contains("execute"));
}
}