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