1use crate::git_ops::{get_commit_summary, get_current_branch, get_head_sha, get_recent_files};
13use crate::loop_context::LoopContext;
14use crate::task::{Task, TaskStatus};
15use crate::task_store::TaskStore;
16use crate::text::floor_char_boundary;
17use std::io;
18use std::path::PathBuf;
19
20#[derive(Debug, Clone)]
22pub struct HandoffResult {
23 pub path: PathBuf,
25
26 pub completed_tasks: usize,
28
29 pub open_tasks: usize,
31
32 pub has_continuation_prompt: bool,
34}
35
36#[derive(Debug, thiserror::Error)]
38pub enum HandoffError {
39 #[error("IO error: {0}")]
41 Io(#[from] io::Error),
42}
43
44pub struct HandoffWriter {
46 context: LoopContext,
47}
48
49impl HandoffWriter {
50 pub fn new(context: LoopContext) -> Self {
52 Self { context }
53 }
54
55 pub fn write(&self, original_prompt: &str) -> Result<HandoffResult, HandoffError> {
65 let path = self.context.handoff_path();
66
67 if let Some(parent) = path.parent() {
69 std::fs::create_dir_all(parent)?;
70 }
71
72 let content = self.generate_content(original_prompt);
73
74 let (completed_tasks, open_tasks) = self.count_tasks();
76
77 std::fs::write(&path, &content)?;
78
79 Ok(HandoffResult {
80 path,
81 completed_tasks,
82 open_tasks,
83 has_continuation_prompt: open_tasks > 0,
84 })
85 }
86
87 fn generate_content(&self, original_prompt: &str) -> String {
89 let mut content = String::new();
90
91 content.push_str("# Session Handoff\n\n");
93 content.push_str(&format!(
94 "_Generated: {}_\n\n",
95 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
96 ));
97
98 content.push_str("## Git Context\n\n");
100 self.write_git_context(&mut content);
101
102 content.push_str("\n## Tasks\n\n");
104 self.write_tasks_section(&mut content);
105
106 content.push_str("\n## Key Files\n\n");
108 self.write_key_files(&mut content);
109
110 content.push_str("\n## Next Session\n\n");
112 self.write_continuation_prompt(&mut content, original_prompt);
113
114 content
115 }
116
117 fn write_git_context(&self, content: &mut String) {
119 let workspace = self.context.workspace();
120
121 match get_current_branch(workspace) {
123 Ok(branch) => content.push_str(&format!("- **Branch:** `{}`\n", branch)),
124 Err(_) => content.push_str("- **Branch:** _(unknown)_\n"),
125 }
126
127 match get_head_sha(workspace) {
129 Ok(sha) => {
130 let summary = get_commit_summary(workspace).unwrap_or_default();
131 if summary.is_empty() {
132 content.push_str(&format!("- **HEAD:** `{}`\n", &sha[..7.min(sha.len())]));
133 } else {
134 content.push_str(&format!("- **HEAD:** {}\n", summary));
135 }
136 }
137 Err(_) => content.push_str("- **HEAD:** _(no commits)_\n"),
138 }
139
140 if let Some(loop_id) = self.context.loop_id() {
142 content.push_str(&format!("- **Loop ID:** `{}`\n", loop_id));
143 }
144 }
145
146 fn write_tasks_section(&self, content: &mut String) {
148 let tasks_path = self.context.tasks_path();
149 let store = match TaskStore::load(&tasks_path) {
150 Ok(s) => s,
151 Err(_) => {
152 content.push_str("_No task history available._\n");
153 return;
154 }
155 };
156
157 let tasks = store.all();
158 if tasks.is_empty() {
159 content.push_str("_No tasks tracked in this session._\n");
160 return;
161 }
162
163 let completed: Vec<&Task> = tasks
165 .iter()
166 .filter(|t| t.status == TaskStatus::Closed)
167 .collect();
168
169 if !completed.is_empty() {
170 content.push_str("### Completed\n\n");
171 for task in &completed {
172 content.push_str(&format!("- [x] {}\n", task.title));
173 }
174 content.push('\n');
175 }
176
177 let open: Vec<&Task> = tasks
179 .iter()
180 .filter(|t| t.status != TaskStatus::Closed)
181 .collect();
182
183 if !open.is_empty() {
184 content.push_str("### Remaining\n\n");
185 for task in &open {
186 let status_marker = match task.status {
187 TaskStatus::Failed => "[~]",
188 _ => "[ ]",
189 };
190 let blocked = if task.blocked_by.is_empty() {
191 String::new()
192 } else {
193 format!(" _(blocked by: {})_", task.blocked_by.join(", "))
194 };
195 content.push_str(&format!("- {} {}{}\n", status_marker, task.title, blocked));
196 }
197 }
198 }
199
200 fn write_key_files(&self, content: &mut String) {
202 match get_recent_files(self.context.workspace(), 10) {
203 Ok(files) if !files.is_empty() => {
204 content.push_str("Recently modified:\n\n");
205 for file in files {
206 content.push_str(&format!("- `{}`\n", file));
207 }
208 }
209 _ => {
210 content.push_str("_No recent file modifications tracked._\n");
211 }
212 }
213 }
214
215 fn write_continuation_prompt(&self, content: &mut String, original_prompt: &str) {
217 let tasks_path = self.context.tasks_path();
218 let store = TaskStore::load(&tasks_path).ok();
219
220 let open_tasks: Vec<String> = store
221 .as_ref()
222 .map(|s| {
223 s.all()
224 .iter()
225 .filter(|t| t.status != TaskStatus::Closed)
226 .map(|t| t.title.clone())
227 .collect()
228 })
229 .unwrap_or_default();
230
231 if open_tasks.is_empty() {
232 content.push_str("Session completed successfully. No pending work.\n\n");
233 content.push_str("**Original objective:**\n\n");
234 content.push_str("```\n");
235 content.push_str(&truncate_prompt(original_prompt, 500));
236 content.push_str("\n```\n");
237 } else {
238 content.push_str(
239 "The following prompt can be used to continue where this session left off:\n\n",
240 );
241 content.push_str("```\n");
242
243 content.push_str("Continue the previous work. ");
245 content.push_str(&format!("Remaining tasks ({}):\n", open_tasks.len()));
246 for task in &open_tasks {
247 content.push_str(&format!("- {}\n", task));
248 }
249 content.push_str("\nOriginal objective: ");
250 content.push_str(&truncate_prompt(original_prompt, 200));
251
252 content.push_str("\n```\n");
253 }
254 }
255
256 fn count_tasks(&self) -> (usize, usize) {
258 let tasks_path = self.context.tasks_path();
259 let store = match TaskStore::load(&tasks_path) {
260 Ok(s) => s,
261 Err(_) => return (0, 0),
262 };
263
264 let completed = store
265 .all()
266 .iter()
267 .filter(|t| t.status == TaskStatus::Closed)
268 .count();
269 let open = store
270 .all()
271 .iter()
272 .filter(|t| t.status != TaskStatus::Closed)
273 .count();
274
275 (completed, open)
276 }
277}
278
279fn truncate_prompt(prompt: &str, max_len: usize) -> String {
282 let prompt = prompt.trim();
283 if prompt.len() <= max_len {
284 prompt.to_string()
285 } else {
286 let safe_len = floor_char_boundary(prompt, max_len);
287 format!("{}...", &prompt[..safe_len])
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use std::fs;
295 use tempfile::TempDir;
296
297 fn setup_test_context() -> (TempDir, LoopContext) {
298 let temp = TempDir::new().unwrap();
299 let ctx = LoopContext::primary(temp.path().to_path_buf());
300 ctx.ensure_directories().unwrap();
301 (temp, ctx)
302 }
303
304 #[test]
305 fn test_handoff_writer_creates_file() {
306 let (_temp, ctx) = setup_test_context();
307 let writer = HandoffWriter::new(ctx.clone());
308
309 let result = writer.write("Test prompt").unwrap();
310
311 assert!(result.path.exists());
312 assert_eq!(result.path, ctx.handoff_path());
313 }
314
315 #[test]
316 fn test_handoff_content_has_sections() {
317 let (_temp, ctx) = setup_test_context();
318 let writer = HandoffWriter::new(ctx.clone());
319
320 writer.write("Test prompt").unwrap();
321
322 let content = fs::read_to_string(ctx.handoff_path()).unwrap();
323
324 assert!(content.contains("# Session Handoff"));
325 assert!(content.contains("## Git Context"));
326 assert!(content.contains("## Tasks"));
327 assert!(content.contains("## Key Files"));
328 assert!(content.contains("## Next Session"));
329 }
330
331 #[test]
332 fn test_handoff_with_no_tasks() {
333 let (_temp, ctx) = setup_test_context();
334 let writer = HandoffWriter::new(ctx.clone());
335
336 let result = writer.write("Test prompt").unwrap();
337
338 assert_eq!(result.completed_tasks, 0);
339 assert_eq!(result.open_tasks, 0);
340 assert!(!result.has_continuation_prompt);
341 }
342
343 #[test]
344 fn test_handoff_with_tasks() {
345 let (_temp, ctx) = setup_test_context();
346
347 let mut store = TaskStore::load(&ctx.tasks_path()).unwrap();
349 let task1 = crate::task::Task::new("Completed task".to_string(), 1);
350 let id1 = task1.id.clone();
351 store.add(task1);
352 store.close(&id1);
353
354 let task2 = crate::task::Task::new("Open task".to_string(), 2);
355 store.add(task2);
356 store.save().unwrap();
357
358 let writer = HandoffWriter::new(ctx.clone());
359 let result = writer.write("Test prompt").unwrap();
360
361 assert_eq!(result.completed_tasks, 1);
362 assert_eq!(result.open_tasks, 1);
363 assert!(result.has_continuation_prompt);
364
365 let content = fs::read_to_string(ctx.handoff_path()).unwrap();
366 assert!(content.contains("[x] Completed task"));
367 assert!(content.contains("[ ] Open task"));
368 assert!(content.contains("Remaining tasks"));
369 }
370
371 #[test]
372 fn test_truncate_prompt_short() {
373 let result = truncate_prompt("short prompt", 100);
374 assert_eq!(result, "short prompt");
375 }
376
377 #[test]
378 fn test_truncate_prompt_long() {
379 let long_prompt = "a".repeat(200);
380 let result = truncate_prompt(&long_prompt, 50);
381 assert_eq!(result.len(), 53); assert!(result.ends_with("..."));
383 }
384
385 #[test]
386 fn test_truncate_prompt_with_emoji() {
387 let prompt = "✅rest";
390 let result = truncate_prompt(prompt, 1);
391 assert_eq!(result, "...");
392 }
393
394 #[test]
395 fn test_truncate_prompt_with_emoji_near_boundary() {
396 let prompt = "x✅rest";
398 assert_eq!(truncate_prompt(prompt, 1), "x...");
400 assert_eq!(truncate_prompt(prompt, 2), "x...");
402 assert_eq!(truncate_prompt(prompt, 3), "x...");
404 assert_eq!(truncate_prompt(prompt, 4), "x✅...");
406 }
407}