use crate::git_ops::{get_commit_summary, get_current_branch, get_head_sha, get_recent_files};
use crate::loop_context::LoopContext;
use crate::task::{Task, TaskStatus};
use crate::task_store::TaskStore;
use crate::text::floor_char_boundary;
use std::io;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct HandoffResult {
pub path: PathBuf,
pub completed_tasks: usize,
pub open_tasks: usize,
pub has_continuation_prompt: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum HandoffError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
}
pub struct HandoffWriter {
context: LoopContext,
}
impl HandoffWriter {
pub fn new(context: LoopContext) -> Self {
Self { context }
}
pub fn write(&self, original_prompt: &str) -> Result<HandoffResult, HandoffError> {
let path = self.context.handoff_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = self.generate_content(original_prompt);
let (completed_tasks, open_tasks) = self.count_tasks();
std::fs::write(&path, &content)?;
Ok(HandoffResult {
path,
completed_tasks,
open_tasks,
has_continuation_prompt: open_tasks > 0,
})
}
fn generate_content(&self, original_prompt: &str) -> String {
let mut content = String::new();
content.push_str("# Session Handoff\n\n");
content.push_str(&format!(
"_Generated: {}_\n\n",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
));
content.push_str("## Git Context\n\n");
self.write_git_context(&mut content);
content.push_str("\n## Tasks\n\n");
self.write_tasks_section(&mut content);
content.push_str("\n## Key Files\n\n");
self.write_key_files(&mut content);
content.push_str("\n## Next Session\n\n");
self.write_continuation_prompt(&mut content, original_prompt);
content
}
fn write_git_context(&self, content: &mut String) {
let workspace = self.context.workspace();
match get_current_branch(workspace) {
Ok(branch) => content.push_str(&format!("- **Branch:** `{}`\n", branch)),
Err(_) => content.push_str("- **Branch:** _(unknown)_\n"),
}
match get_head_sha(workspace) {
Ok(sha) => {
let summary = get_commit_summary(workspace).unwrap_or_default();
if summary.is_empty() {
content.push_str(&format!("- **HEAD:** `{}`\n", &sha[..7.min(sha.len())]));
} else {
content.push_str(&format!("- **HEAD:** {}\n", summary));
}
}
Err(_) => content.push_str("- **HEAD:** _(no commits)_\n"),
}
if let Some(loop_id) = self.context.loop_id() {
content.push_str(&format!("- **Loop ID:** `{}`\n", loop_id));
}
}
fn write_tasks_section(&self, content: &mut String) {
let tasks_path = self.context.tasks_path();
let store = match TaskStore::load(&tasks_path) {
Ok(s) => s,
Err(_) => {
content.push_str("_No task history available._\n");
return;
}
};
let tasks = store.all();
if tasks.is_empty() {
content.push_str("_No tasks tracked in this session._\n");
return;
}
let completed: Vec<&Task> = tasks
.iter()
.filter(|t| t.status == TaskStatus::Closed)
.collect();
if !completed.is_empty() {
content.push_str("### Completed\n\n");
for task in &completed {
content.push_str(&format!("- [x] {}\n", task.title));
}
content.push('\n');
}
let open: Vec<&Task> = tasks
.iter()
.filter(|t| t.status != TaskStatus::Closed)
.collect();
if !open.is_empty() {
content.push_str("### Remaining\n\n");
for task in &open {
let status_marker = match task.status {
TaskStatus::Failed => "[~]",
_ => "[ ]",
};
let blocked = if task.blocked_by.is_empty() {
String::new()
} else {
format!(" _(blocked by: {})_", task.blocked_by.join(", "))
};
content.push_str(&format!("- {} {}{}\n", status_marker, task.title, blocked));
}
}
}
fn write_key_files(&self, content: &mut String) {
match get_recent_files(self.context.workspace(), 10) {
Ok(files) if !files.is_empty() => {
content.push_str("Recently modified:\n\n");
for file in files {
content.push_str(&format!("- `{}`\n", file));
}
}
_ => {
content.push_str("_No recent file modifications tracked._\n");
}
}
}
fn write_continuation_prompt(&self, content: &mut String, original_prompt: &str) {
let tasks_path = self.context.tasks_path();
let store = TaskStore::load(&tasks_path).ok();
let open_tasks: Vec<String> = store
.as_ref()
.map(|s| {
s.all()
.iter()
.filter(|t| t.status != TaskStatus::Closed)
.map(|t| t.title.clone())
.collect()
})
.unwrap_or_default();
if open_tasks.is_empty() {
content.push_str("Session completed successfully. No pending work.\n\n");
content.push_str("**Original objective:**\n\n");
content.push_str("```\n");
content.push_str(&truncate_prompt(original_prompt, 500));
content.push_str("\n```\n");
} else {
content.push_str(
"The following prompt can be used to continue where this session left off:\n\n",
);
content.push_str("```\n");
content.push_str("Continue the previous work. ");
content.push_str(&format!("Remaining tasks ({}):\n", open_tasks.len()));
for task in &open_tasks {
content.push_str(&format!("- {}\n", task));
}
content.push_str("\nOriginal objective: ");
content.push_str(&truncate_prompt(original_prompt, 200));
content.push_str("\n```\n");
}
}
fn count_tasks(&self) -> (usize, usize) {
let tasks_path = self.context.tasks_path();
let store = match TaskStore::load(&tasks_path) {
Ok(s) => s,
Err(_) => return (0, 0),
};
let completed = store
.all()
.iter()
.filter(|t| t.status == TaskStatus::Closed)
.count();
let open = store
.all()
.iter()
.filter(|t| t.status != TaskStatus::Closed)
.count();
(completed, open)
}
}
fn truncate_prompt(prompt: &str, max_len: usize) -> String {
let prompt = prompt.trim();
if prompt.len() <= max_len {
prompt.to_string()
} else {
let safe_len = floor_char_boundary(prompt, max_len);
format!("{}...", &prompt[..safe_len])
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup_test_context() -> (TempDir, LoopContext) {
let temp = TempDir::new().unwrap();
let ctx = LoopContext::primary(temp.path().to_path_buf());
ctx.ensure_directories().unwrap();
(temp, ctx)
}
#[test]
fn test_handoff_writer_creates_file() {
let (_temp, ctx) = setup_test_context();
let writer = HandoffWriter::new(ctx.clone());
let result = writer.write("Test prompt").unwrap();
assert!(result.path.exists());
assert_eq!(result.path, ctx.handoff_path());
}
#[test]
fn test_handoff_content_has_sections() {
let (_temp, ctx) = setup_test_context();
let writer = HandoffWriter::new(ctx.clone());
writer.write("Test prompt").unwrap();
let content = fs::read_to_string(ctx.handoff_path()).unwrap();
assert!(content.contains("# Session Handoff"));
assert!(content.contains("## Git Context"));
assert!(content.contains("## Tasks"));
assert!(content.contains("## Key Files"));
assert!(content.contains("## Next Session"));
}
#[test]
fn test_handoff_with_no_tasks() {
let (_temp, ctx) = setup_test_context();
let writer = HandoffWriter::new(ctx.clone());
let result = writer.write("Test prompt").unwrap();
assert_eq!(result.completed_tasks, 0);
assert_eq!(result.open_tasks, 0);
assert!(!result.has_continuation_prompt);
}
#[test]
fn test_handoff_with_tasks() {
let (_temp, ctx) = setup_test_context();
let mut store = TaskStore::load(&ctx.tasks_path()).unwrap();
let task1 = crate::task::Task::new("Completed task".to_string(), 1);
let id1 = task1.id.clone();
store.add(task1);
store.close(&id1);
let task2 = crate::task::Task::new("Open task".to_string(), 2);
store.add(task2);
store.save().unwrap();
let writer = HandoffWriter::new(ctx.clone());
let result = writer.write("Test prompt").unwrap();
assert_eq!(result.completed_tasks, 1);
assert_eq!(result.open_tasks, 1);
assert!(result.has_continuation_prompt);
let content = fs::read_to_string(ctx.handoff_path()).unwrap();
assert!(content.contains("[x] Completed task"));
assert!(content.contains("[ ] Open task"));
assert!(content.contains("Remaining tasks"));
}
#[test]
fn test_truncate_prompt_short() {
let result = truncate_prompt("short prompt", 100);
assert_eq!(result, "short prompt");
}
#[test]
fn test_truncate_prompt_long() {
let long_prompt = "a".repeat(200);
let result = truncate_prompt(&long_prompt, 50);
assert_eq!(result.len(), 53); assert!(result.ends_with("..."));
}
#[test]
fn test_truncate_prompt_with_emoji() {
let prompt = "✅rest";
let result = truncate_prompt(prompt, 1);
assert_eq!(result, "...");
}
#[test]
fn test_truncate_prompt_with_emoji_near_boundary() {
let prompt = "x✅rest";
assert_eq!(truncate_prompt(prompt, 1), "x...");
assert_eq!(truncate_prompt(prompt, 2), "x...");
assert_eq!(truncate_prompt(prompt, 3), "x...");
assert_eq!(truncate_prompt(prompt, 4), "x✅...");
}
}