1use crate::event_logger::EventHistory;
7use crate::event_loop::{LoopState, TerminationReason};
8use crate::landing::LandingResult;
9use crate::loop_context::LoopContext;
10use std::collections::HashMap;
11use std::fs;
12use std::io;
13use std::path::{Path, PathBuf};
14use std::time::Duration;
15
16#[derive(Debug)]
41pub struct SummaryWriter {
42 path: PathBuf,
43 events_path: Option<PathBuf>,
46}
47
48impl Default for SummaryWriter {
49 fn default() -> Self {
50 Self::new(".ralph/agent/summary.md")
51 }
52}
53
54impl SummaryWriter {
55 pub fn new(path: impl Into<PathBuf>) -> Self {
57 Self {
58 path: path.into(),
59 events_path: None,
60 }
61 }
62
63 pub fn from_context(context: &LoopContext) -> Self {
69 Self {
70 path: context.summary_path(),
71 events_path: Some(context.events_path()),
72 }
73 }
74
75 pub fn write(
79 &self,
80 reason: &TerminationReason,
81 state: &LoopState,
82 scratchpad_path: Option<&Path>,
83 final_commit: Option<&str>,
84 ) -> io::Result<()> {
85 self.write_with_landing(reason, state, scratchpad_path, final_commit, None)
86 }
87
88 pub fn write_with_landing(
92 &self,
93 reason: &TerminationReason,
94 state: &LoopState,
95 scratchpad_path: Option<&Path>,
96 final_commit: Option<&str>,
97 landing: Option<&LandingResult>,
98 ) -> io::Result<()> {
99 if let Some(parent) = self.path.parent() {
101 fs::create_dir_all(parent)?;
102 }
103
104 let content = self.generate_content_with_landing(
105 reason,
106 state,
107 scratchpad_path,
108 final_commit,
109 landing,
110 );
111 fs::write(&self.path, content)
112 }
113
114 fn generate_content_with_landing(
116 &self,
117 reason: &TerminationReason,
118 state: &LoopState,
119 scratchpad_path: Option<&Path>,
120 final_commit: Option<&str>,
121 landing: Option<&LandingResult>,
122 ) -> String {
123 let mut content = String::new();
124
125 content.push_str("# Loop Summary\n\n");
127
128 let status = self.status_text(reason);
130 content.push_str(&format!("**Status:** {status}\n"));
131 content.push_str(&format!("**Iterations:** {}\n", state.iteration));
132 content.push_str(&format!(
133 "**Duration:** {}\n",
134 format_duration(state.elapsed())
135 ));
136
137 if state.cumulative_cost > 0.0 {
139 content.push_str(&format!("**Est. cost:** ${:.2}\n", state.cumulative_cost));
140 }
141
142 content.push('\n');
144 content.push_str("## Tasks\n\n");
145 if let Some(tasks) = scratchpad_path.and_then(|p| self.extract_tasks(p)) {
146 content.push_str(&tasks);
147 } else {
148 content.push_str("_No scratchpad found._\n");
149 }
150
151 content.push('\n');
153 content.push_str("## Events\n\n");
154 content.push_str(&self.summarize_events());
155
156 if let Some(commit) = final_commit {
158 content.push('\n');
159 content.push_str("## Final Commit\n\n");
160 content.push_str(commit);
161 content.push('\n');
162 }
163
164 if let Some(landing_result) = landing {
166 content.push('\n');
167 content.push_str("## Landing\n\n");
168
169 if landing_result.committed {
170 content.push_str(&format!(
171 "- **Auto-committed:** Yes ({})\n",
172 landing_result.commit_sha.as_deref().unwrap_or("unknown")
173 ));
174 } else {
175 content.push_str("- **Auto-committed:** No (working tree was clean)\n");
176 }
177
178 content.push_str(&format!(
179 "- **Handoff:** `{}`\n",
180 landing_result.handoff_path.display()
181 ));
182
183 if !landing_result.open_tasks.is_empty() {
184 content.push_str(&format!(
185 "- **Open tasks:** {}\n",
186 landing_result.open_tasks.len()
187 ));
188 }
189
190 if landing_result.stashes_cleared > 0 {
191 content.push_str(&format!(
192 "- **Stashes cleared:** {}\n",
193 landing_result.stashes_cleared
194 ));
195 }
196
197 content.push_str(&format!(
198 "- **Working tree clean:** {}\n",
199 if landing_result.working_tree_clean {
200 "Yes"
201 } else {
202 "No"
203 }
204 ));
205 }
206
207 content
208 }
209
210 fn status_text(&self, reason: &TerminationReason) -> &'static str {
212 match reason {
213 TerminationReason::CompletionPromise => "Completed successfully",
214 TerminationReason::MaxIterations => "Stopped: max iterations reached",
215 TerminationReason::MaxRuntime => "Stopped: max runtime exceeded",
216 TerminationReason::MaxCost => "Stopped: max cost exceeded",
217 TerminationReason::ConsecutiveFailures => "Failed: too many consecutive failures",
218 TerminationReason::LoopThrashing => "Failed: loop thrashing detected",
219 TerminationReason::ValidationFailure => "Failed: too many malformed JSONL events",
220 TerminationReason::Stopped => "Stopped manually",
221 TerminationReason::Interrupted => "Interrupted by signal",
222 TerminationReason::ChaosModeComplete => "Chaos mode: exploration complete",
223 TerminationReason::ChaosModeMaxIterations => "Chaos mode: max iterations reached",
224 }
225 }
226
227 fn extract_tasks(&self, scratchpad_path: &Path) -> Option<String> {
231 let content = fs::read_to_string(scratchpad_path).ok()?;
232 let mut tasks = String::new();
233
234 for line in content.lines() {
235 let trimmed = line.trim();
236 if trimmed.starts_with("- [ ]")
237 || trimmed.starts_with("- [x]")
238 || trimmed.starts_with("- [~]")
239 {
240 tasks.push_str(trimmed);
241 tasks.push('\n');
242 }
243 }
244
245 if tasks.is_empty() { None } else { Some(tasks) }
246 }
247
248 fn summarize_events(&self) -> String {
250 let history = match &self.events_path {
251 Some(path) => EventHistory::new(path),
252 None => EventHistory::default_path(),
253 };
254
255 let records = match history.read_all() {
256 Ok(r) => r,
257 Err(_) => return "_No event history found._\n".to_string(),
258 };
259
260 if records.is_empty() {
261 return "_No events recorded._\n".to_string();
262 }
263
264 let mut topic_counts: HashMap<String, usize> = HashMap::new();
266 for record in &records {
267 *topic_counts.entry(record.topic.clone()).or_insert(0) += 1;
268 }
269
270 let mut summary = format!("- {} total events\n", records.len());
271
272 let mut sorted: Vec<_> = topic_counts.into_iter().collect();
274 sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
275
276 for (topic, count) in sorted {
277 summary.push_str(&format!("- {} {}\n", count, topic));
278 }
279
280 summary
281 }
282}
283
284fn format_duration(d: Duration) -> String {
286 let total_secs = d.as_secs();
287 let hours = total_secs / 3600;
288 let minutes = (total_secs % 3600) / 60;
289 let seconds = total_secs % 60;
290
291 if hours > 0 {
292 format!("{}h {}m {}s", hours, minutes, seconds)
293 } else if minutes > 0 {
294 format!("{}m {}s", minutes, seconds)
295 } else {
296 format!("{}s", seconds)
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use std::time::Instant;
304 use tempfile::TempDir;
305
306 fn test_state() -> LoopState {
307 LoopState {
308 iteration: 12,
309 consecutive_failures: 0,
310 cumulative_cost: 1.50,
311 started_at: Instant::now(),
312 last_hat: None,
313 consecutive_blocked: 0,
314 last_blocked_hat: None,
315 task_block_counts: std::collections::HashMap::new(),
316 abandoned_tasks: Vec::new(),
317 abandoned_task_redispatches: 0,
318 consecutive_malformed_events: 0,
319 hat_activation_counts: std::collections::HashMap::new(),
320 exhausted_hats: std::collections::HashSet::new(),
321 }
322 }
323
324 #[test]
325 fn test_status_text() {
326 let writer = SummaryWriter::default();
327
328 assert_eq!(
329 writer.status_text(&TerminationReason::CompletionPromise),
330 "Completed successfully"
331 );
332 assert_eq!(
333 writer.status_text(&TerminationReason::MaxIterations),
334 "Stopped: max iterations reached"
335 );
336 assert_eq!(
337 writer.status_text(&TerminationReason::ConsecutiveFailures),
338 "Failed: too many consecutive failures"
339 );
340 assert_eq!(
341 writer.status_text(&TerminationReason::Interrupted),
342 "Interrupted by signal"
343 );
344 }
345
346 #[test]
347 fn test_format_duration() {
348 assert_eq!(format_duration(Duration::from_secs(45)), "45s");
349 assert_eq!(format_duration(Duration::from_secs(125)), "2m 5s");
350 assert_eq!(format_duration(Duration::from_secs(3725)), "1h 2m 5s");
351 }
352
353 #[test]
354 fn test_extract_tasks() {
355 let tmp = TempDir::new().unwrap();
356 let scratchpad = tmp.path().join("scratchpad.md");
357
358 let content = r"# Tasks
359
360Some intro text.
361
362- [x] Implement feature A
363- [ ] Implement feature B
364- [~] Feature C (cancelled: not needed)
365
366## Notes
367
368More text here.
369";
370 fs::write(&scratchpad, content).unwrap();
371
372 let writer = SummaryWriter::default();
373 let tasks = writer.extract_tasks(&scratchpad).unwrap();
374
375 assert!(tasks.contains("- [x] Implement feature A"));
376 assert!(tasks.contains("- [ ] Implement feature B"));
377 assert!(tasks.contains("- [~] Feature C"));
378 }
379
380 #[test]
381 fn test_generate_content_basic() {
382 let writer = SummaryWriter::default();
383 let state = test_state();
384
385 let content = writer.generate_content_with_landing(
386 &TerminationReason::CompletionPromise,
387 &state,
388 None,
389 Some("abc1234: feat(auth): add tokens"),
390 None,
391 );
392
393 assert!(content.contains("# Loop Summary"));
394 assert!(content.contains("**Status:** Completed successfully"));
395 assert!(content.contains("**Iterations:** 12"));
396 assert!(content.contains("**Est. cost:** $1.50"));
397 assert!(content.contains("## Tasks"));
398 assert!(content.contains("## Events"));
399 assert!(content.contains("## Final Commit"));
400 assert!(content.contains("abc1234: feat(auth): add tokens"));
401 }
402
403 #[test]
404 fn test_write_creates_directory() {
405 let tmp = TempDir::new().unwrap();
406 let path = tmp.path().join("nested/dir/summary.md");
407
408 let writer = SummaryWriter::new(&path);
409 let state = test_state();
410
411 writer
412 .write(&TerminationReason::CompletionPromise, &state, None, None)
413 .unwrap();
414
415 assert!(path.exists());
416 let content = fs::read_to_string(path).unwrap();
417 assert!(content.contains("# Loop Summary"));
418 }
419
420 #[test]
421 fn test_write_with_landing() {
422 let tmp = TempDir::new().unwrap();
423 let path = tmp.path().join("summary.md");
424
425 let writer = SummaryWriter::new(&path);
426 let state = test_state();
427
428 let landing = LandingResult {
429 committed: true,
430 commit_sha: Some("abc1234".to_string()),
431 handoff_path: tmp.path().join("handoff.md"),
432 open_tasks: vec!["task-1".to_string(), "task-2".to_string()],
433 stashes_cleared: 2,
434 working_tree_clean: true,
435 };
436
437 writer
438 .write_with_landing(
439 &TerminationReason::CompletionPromise,
440 &state,
441 None,
442 None,
443 Some(&landing),
444 )
445 .unwrap();
446
447 let content = fs::read_to_string(path).unwrap();
448 assert!(content.contains("## Landing"));
449 assert!(content.contains("**Auto-committed:** Yes (abc1234)"));
450 assert!(content.contains("**Handoff:**"));
451 assert!(content.contains("**Open tasks:** 2"));
452 assert!(content.contains("**Stashes cleared:** 2"));
453 assert!(content.contains("**Working tree clean:** Yes"));
454 }
455}