Skip to main content

batty_cli/team/
retrospective.rs

1//! Pure event-log analysis and markdown report generation for retrospectives.
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8
9use super::events::{TeamEvent, read_events};
10use crate::task;
11
12#[derive(Debug, Clone, PartialEq)]
13pub struct RunStats {
14    pub run_start: u64,
15    pub run_end: u64,
16    pub total_duration_secs: u64,
17    pub task_stats: Vec<TaskStats>,
18    pub average_cycle_time_secs: Option<u64>,
19    pub fastest_task_id: Option<String>,
20    pub fastest_cycle_time_secs: Option<u64>,
21    pub longest_task_id: Option<String>,
22    pub longest_cycle_time_secs: Option<u64>,
23    pub idle_time_pct: f64,
24    pub escalation_count: u32,
25    pub message_count: u32,
26    // Review pipeline metrics
27    pub auto_merge_count: u32,
28    pub manual_merge_count: u32,
29    pub rework_count: u32,
30    pub review_nudge_count: u32,
31    pub review_escalation_count: u32,
32    /// Average time (seconds) tasks spent in review before merge.
33    pub avg_review_stall_secs: Option<u64>,
34    /// Longest review stall and the associated task.
35    pub max_review_stall_secs: Option<u64>,
36    pub max_review_stall_task: Option<String>,
37    /// Per-task rework cycle counts (task_id → rework count).
38    pub task_rework_counts: Vec<(String, u32)>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct TaskStats {
43    pub task_id: String,
44    pub assigned_to: String,
45    pub assigned_at: u64,
46    pub completed_at: Option<u64>,
47    pub cycle_time_secs: Option<u64>,
48    pub retry_count: u32,
49    pub was_escalated: bool,
50}
51
52#[derive(Debug, Clone)]
53struct TaskAccumulator {
54    task_id: String,
55    assigned_to: String,
56    assigned_at: u64,
57    completed_at: Option<u64>,
58    cycle_time_secs: Option<u64>,
59    retry_count: u32,
60    was_escalated: bool,
61}
62
63impl TaskAccumulator {
64    fn new(task_id: String, assigned_to: String, assigned_at: u64, retry_count: u32) -> Self {
65        Self {
66            task_id,
67            assigned_to,
68            assigned_at,
69            completed_at: None,
70            cycle_time_secs: None,
71            retry_count,
72            was_escalated: false,
73        }
74    }
75
76    fn into_stats(self) -> TaskStats {
77        TaskStats {
78            task_id: self.task_id,
79            assigned_to: self.assigned_to,
80            assigned_at: self.assigned_at,
81            completed_at: self.completed_at,
82            cycle_time_secs: self.cycle_time_secs,
83            retry_count: self.retry_count,
84            was_escalated: self.was_escalated,
85        }
86    }
87}
88
89fn task_reference(task: &str) -> String {
90    let line = task
91        .lines()
92        .map(str::trim)
93        .find(|line| !line.is_empty())
94        .unwrap_or_else(|| task.trim());
95
96    task_id_from_assignment_line(line).unwrap_or_else(|| line.to_string())
97}
98
99fn task_id_from_assignment_line(line: &str) -> Option<String> {
100    let suffix = line.strip_prefix("Task #")?;
101    let digits: String = suffix
102        .chars()
103        .take_while(|ch| ch.is_ascii_digit())
104        .collect();
105    if digits.is_empty() {
106        None
107    } else {
108        Some(digits)
109    }
110}
111
112type CycleTimeMetrics = (
113    Option<u64>,
114    Option<String>,
115    Option<u64>,
116    Option<String>,
117    Option<u64>,
118);
119
120fn cycle_time_metrics(task_stats: &[TaskStats]) -> CycleTimeMetrics {
121    let completed: Vec<(&TaskStats, u64)> = task_stats
122        .iter()
123        .filter_map(|task| task.cycle_time_secs.map(|cycle| (task, cycle)))
124        .collect();
125    if completed.is_empty() {
126        return (None, None, None, None, None);
127    }
128
129    let total_cycle_secs: u64 = completed.iter().map(|(_, cycle)| *cycle).sum();
130    let average_cycle_time_secs = Some(total_cycle_secs / completed.len() as u64);
131    let (fastest_task, fastest_cycle_time_secs) = completed
132        .iter()
133        .min_by_key(|(_, cycle)| *cycle)
134        .map(|(task, cycle)| (task.task_id.clone(), *cycle))
135        .expect("completed is not empty");
136    let (longest_task, longest_cycle_time_secs) = completed
137        .iter()
138        .max_by_key(|(_, cycle)| *cycle)
139        .map(|(task, cycle)| (task.task_id.clone(), *cycle))
140        .expect("completed is not empty");
141
142    (
143        average_cycle_time_secs,
144        Some(fastest_task),
145        Some(fastest_cycle_time_secs),
146        Some(longest_task),
147        Some(longest_cycle_time_secs),
148    )
149}
150
151/// Analyze a single run (events between consecutive daemon_started events).
152/// Returns the last run's stats.
153pub fn analyze_events(events: &[TeamEvent]) -> Option<RunStats> {
154    if events.is_empty() {
155        return None;
156    }
157
158    let last_run_start = events
159        .iter()
160        .rposition(|event| event.event == "daemon_started")
161        .unwrap_or(0);
162    let run_events = &events[last_run_start..];
163    if run_events.is_empty() {
164        return None;
165    }
166
167    let run_start = run_events[0].ts;
168    let run_end = run_events
169        .iter()
170        .rev()
171        .find(|event| event.event == "daemon_stopped")
172        .map(|event| event.ts)
173        .unwrap_or_else(|| run_events.last().map(|event| event.ts).unwrap_or(run_start));
174
175    let mut tasks: HashMap<String, TaskAccumulator> = HashMap::new();
176    let mut active_task_by_role: HashMap<String, String> = HashMap::new();
177    let mut idle_samples = Vec::new();
178    let mut escalation_count = 0u32;
179    let mut message_count = 0u32;
180    let mut auto_merge_count = 0u32;
181    let mut manual_merge_count = 0u32;
182    let mut rework_count = 0u32;
183    let mut review_nudge_count = 0u32;
184    let mut review_escalation_count = 0u32;
185    // Track per-task: completion timestamp (for stall calc) and rework counts.
186    let mut task_completed_at: HashMap<String, u64> = HashMap::new();
187    let mut review_stall_durations: Vec<(String, u64)> = Vec::new();
188    let mut per_task_rework: HashMap<String, u32> = HashMap::new();
189
190    for event in run_events {
191        match event.event.as_str() {
192            "task_assigned" => {
193                let Some(role) = event.role.as_deref() else {
194                    continue;
195                };
196                let Some(task) = event.task.as_deref() else {
197                    continue;
198                };
199                let task_id = task_reference(task);
200
201                let entry = tasks.entry(task_id.clone()).or_insert_with(|| {
202                    TaskAccumulator::new(task_id.clone(), role.to_string(), event.ts, 0)
203                });
204                entry.retry_count += 1;
205                entry.assigned_to = role.to_string();
206                active_task_by_role.insert(role.to_string(), task_id);
207            }
208            // The completion event does not include a task id, so completion is
209            // attributed to the role's currently active assignment in this run.
210            "task_completed" => {
211                let Some(role) = event.role.as_deref() else {
212                    continue;
213                };
214                let Some(task_id) = active_task_by_role.remove(role) else {
215                    continue;
216                };
217                let Some(task) = tasks.get_mut(&task_id) else {
218                    continue;
219                };
220                if task.completed_at.is_none() {
221                    task.completed_at = Some(event.ts);
222                    task.cycle_time_secs = Some(event.ts.saturating_sub(task.assigned_at));
223                }
224                // Record completion time for review stall calculation.
225                task_completed_at.insert(task_id, event.ts);
226            }
227            "task_escalated" => {
228                escalation_count += 1;
229                let Some(task_id) = event.task.as_deref() else {
230                    continue;
231                };
232                let role = event.role.clone().unwrap_or_default();
233                let entry = tasks.entry(task_id.to_string()).or_insert_with(|| {
234                    TaskAccumulator::new(task_id.to_string(), role, event.ts, 0)
235                });
236                entry.was_escalated = true;
237            }
238            "message_routed" => {
239                message_count += 1;
240            }
241            "task_auto_merged" => {
242                auto_merge_count += 1;
243                if let Some(task) = event.task.as_deref() {
244                    let task_id = task_reference(task);
245                    if let Some(completed_ts) = task_completed_at.get(&task_id) {
246                        review_stall_durations
247                            .push((task_id, event.ts.saturating_sub(*completed_ts)));
248                    }
249                }
250            }
251            "task_manual_merged" => {
252                manual_merge_count += 1;
253                if let Some(task) = event.task.as_deref() {
254                    let task_id = task_reference(task);
255                    if let Some(completed_ts) = task_completed_at.get(&task_id) {
256                        review_stall_durations
257                            .push((task_id, event.ts.saturating_sub(*completed_ts)));
258                    }
259                }
260            }
261            "task_reworked" => {
262                rework_count += 1;
263                if let Some(task) = event.task.as_deref() {
264                    let task_id = task_reference(task);
265                    *per_task_rework.entry(task_id).or_insert(0) += 1;
266                }
267            }
268            "review_nudge_sent" => {
269                review_nudge_count += 1;
270            }
271            "review_escalated" => {
272                review_escalation_count += 1;
273            }
274            "load_snapshot" => {
275                let Some(working_members) = event.working_members else {
276                    continue;
277                };
278                let Some(total_members) = event.total_members else {
279                    continue;
280                };
281                let idle_pct = if total_members == 0 {
282                    1.0
283                } else {
284                    1.0 - (working_members as f64 / total_members as f64)
285                };
286                idle_samples.push(idle_pct);
287            }
288            _ => {}
289        }
290    }
291
292    let mut task_stats: Vec<TaskStats> =
293        tasks.into_values().map(|task| task.into_stats()).collect();
294    task_stats.sort_by(|left, right| {
295        left.assigned_at
296            .cmp(&right.assigned_at)
297            .then_with(|| left.task_id.cmp(&right.task_id))
298    });
299
300    let idle_time_pct = if idle_samples.is_empty() {
301        0.0
302    } else {
303        idle_samples.iter().sum::<f64>() / idle_samples.len() as f64
304    };
305    let (
306        average_cycle_time_secs,
307        fastest_task_id,
308        fastest_cycle_time_secs,
309        longest_task_id,
310        longest_cycle_time_secs,
311    ) = cycle_time_metrics(&task_stats);
312
313    // Compute review stall metrics.
314    let (avg_review_stall_secs, max_review_stall_secs, max_review_stall_task) =
315        if review_stall_durations.is_empty() {
316            (None, None, None)
317        } else {
318            let total: u64 = review_stall_durations.iter().map(|(_, d)| *d).sum();
319            let avg = total / review_stall_durations.len() as u64;
320            let (max_task, max_dur) = review_stall_durations
321                .iter()
322                .max_by_key(|(_, d)| *d)
323                .map(|(t, d)| (t.clone(), *d))
324                .expect("non-empty");
325            (Some(avg), Some(max_dur), Some(max_task))
326        };
327
328    // Collect per-task rework counts, sorted by count descending.
329    let mut task_rework_counts: Vec<(String, u32)> = per_task_rework.into_iter().collect();
330    task_rework_counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
331
332    Some(RunStats {
333        run_start,
334        run_end,
335        total_duration_secs: run_end.saturating_sub(run_start),
336        task_stats,
337        average_cycle_time_secs,
338        fastest_task_id,
339        fastest_cycle_time_secs,
340        longest_task_id,
341        longest_cycle_time_secs,
342        idle_time_pct,
343        escalation_count,
344        message_count,
345        auto_merge_count,
346        manual_merge_count,
347        rework_count,
348        review_nudge_count,
349        review_escalation_count,
350        avg_review_stall_secs,
351        max_review_stall_secs,
352        max_review_stall_task,
353        task_rework_counts,
354    })
355}
356
357/// Parse the events file and analyze.
358pub fn analyze_event_log(path: &Path) -> Result<Option<RunStats>> {
359    let events = read_events(path)?;
360    Ok(analyze_events(&events))
361}
362
363pub fn should_generate_retro(
364    project_root: &Path,
365    retro_generated: bool,
366    min_duration_secs: u64,
367) -> Result<Option<RunStats>> {
368    if retro_generated {
369        return Ok(None);
370    }
371
372    let board_dir = project_root
373        .join(".batty")
374        .join("team_config")
375        .join("board");
376    let tasks_dir = board_dir.join("tasks");
377    if !tasks_dir.is_dir() {
378        return Ok(None);
379    }
380
381    let tasks = task::load_tasks_from_dir(&tasks_dir)?;
382    let active_tasks: Vec<&task::Task> = tasks
383        .iter()
384        .filter(|task| task.status != "archived")
385        .collect();
386    if active_tasks.is_empty() || active_tasks.iter().any(|task| task.status != "done") {
387        return Ok(None);
388    }
389
390    let events_path = project_root
391        .join(".batty")
392        .join("team_config")
393        .join("events.jsonl");
394    let stats = analyze_event_log(&events_path)?;
395
396    // Suppress trivial retrospectives: short runs with zero completions.
397    // Completions override the duration check — a short run that finished
398    // tasks is still worth reporting.
399    if let Some(ref stats) = stats {
400        let completed = stats
401            .task_stats
402            .iter()
403            .filter(|t| t.completed_at.is_some())
404            .count();
405        if stats.total_duration_secs < min_duration_secs && completed == 0 {
406            tracing::debug!(
407                duration_secs = stats.total_duration_secs,
408                completed_tasks = completed,
409                "Skipping trivial retrospective: {}s, {} tasks",
410                stats.total_duration_secs,
411                completed,
412            );
413            return Ok(None);
414        }
415    }
416
417    Ok(stats)
418}
419
420pub fn generate_retrospective(project_root: &Path, stats: &RunStats) -> Result<PathBuf> {
421    let retrospectives_dir = project_root.join(".batty").join("retrospectives");
422    fs::create_dir_all(&retrospectives_dir).with_context(|| {
423        format!(
424            "failed to create retrospectives directory: {}",
425            retrospectives_dir.display()
426        )
427    })?;
428
429    let report_path = retrospectives_dir.join(format!("{}.md", stats.run_end));
430    let report = render_retrospective(stats);
431    fs::write(&report_path, report)
432        .with_context(|| format!("failed to write retrospective: {}", report_path.display()))?;
433
434    Ok(report_path)
435}
436
437pub fn format_duration(secs: u64) -> String {
438    let hours = secs / 3_600;
439    let minutes = (secs % 3_600) / 60;
440    let seconds = secs % 60;
441
442    if hours > 0 {
443        format!("{hours}h {minutes:02}m {seconds:02}s")
444    } else if minutes > 0 {
445        format!("{minutes}m {seconds:02}s")
446    } else {
447        format!("{seconds}s")
448    }
449}
450
451fn render_retrospective(stats: &RunStats) -> String {
452    let completed_tasks = stats
453        .task_stats
454        .iter()
455        .filter(|task| task.completed_at.is_some())
456        .count();
457    let average_cycle_time = stats
458        .average_cycle_time_secs
459        .map(format_duration)
460        .unwrap_or_else(|| "-".to_string());
461    let fastest_cycle_time = stats
462        .fastest_cycle_time_secs
463        .map(|cycle| {
464            format!(
465                "{} ({})",
466                format_duration(cycle),
467                stats.fastest_task_id.as_deref().unwrap_or("-")
468            )
469        })
470        .unwrap_or_else(|| "-".to_string());
471    let longest_cycle_time = stats
472        .longest_cycle_time_secs
473        .map(|cycle| {
474            format!(
475                "{} ({})",
476                format_duration(cycle),
477                stats.longest_task_id.as_deref().unwrap_or("-")
478            )
479        })
480        .unwrap_or_else(|| "-".to_string());
481
482    let task_cycle_rows = render_task_cycle_rows(&stats.task_stats);
483    let bottlenecks = render_bottlenecks(&stats.task_stats);
484    let recommendations = render_recommendations(stats);
485    let review_section = render_review_performance(stats);
486
487    format!(
488        "# Batty Retrospective\n\n\
489## Summary\n\n\
490- Duration: {}\n\
491- Tasks completed: {}\n\
492- Average cycle time: {}\n\
493- Fastest task: {}\n\
494- Longest task: {}\n\
495- Messages: {}\n\
496- Escalations: {}\n\
497- Idle: {:.1}%\n\n\
498## Task Cycle Times\n\n\
499| Task | Assignee | Status | Cycle Time | Retries | Escalated |\n\
500| --- | --- | --- | --- | --- | --- |\n\
501{}\
502\n\
503{}\
504## Bottlenecks\n\n\
505{}\
506\n\
507## Recommendations\n\n\
508{}",
509        format_duration(stats.total_duration_secs),
510        completed_tasks,
511        average_cycle_time,
512        fastest_cycle_time,
513        longest_cycle_time,
514        stats.message_count,
515        stats.escalation_count,
516        stats.idle_time_pct * 100.0,
517        task_cycle_rows,
518        review_section,
519        bottlenecks,
520        recommendations
521    )
522}
523
524fn render_review_performance(stats: &RunStats) -> String {
525    let total_merges = stats.auto_merge_count + stats.manual_merge_count;
526    if total_merges == 0 && stats.rework_count == 0 && stats.review_nudge_count == 0 {
527        return String::new();
528    }
529
530    let auto_rate = if total_merges > 0 {
531        format!(
532            "{:.0}%",
533            stats.auto_merge_count as f64 / total_merges as f64 * 100.0
534        )
535    } else {
536        "-".to_string()
537    };
538    let total_reviewed = total_merges + stats.rework_count;
539    let rework_rate = if total_reviewed > 0 {
540        format!(
541            "{:.0}%",
542            stats.rework_count as f64 / total_reviewed as f64 * 100.0
543        )
544    } else {
545        "-".to_string()
546    };
547
548    let avg_stall = stats
549        .avg_review_stall_secs
550        .map(format_duration)
551        .unwrap_or_else(|| "-".to_string());
552    let max_stall = stats
553        .max_review_stall_secs
554        .map(|secs| {
555            format!(
556                "{} ({})",
557                format_duration(secs),
558                stats.max_review_stall_task.as_deref().unwrap_or("-")
559            )
560        })
561        .unwrap_or_else(|| "-".to_string());
562
563    let mut section = format!(
564        "## Review Pipeline\n\n\
565- Auto-merged: {}\n\
566- Manually merged: {}\n\
567- Auto-merge rate: {}\n\
568- Avg review stall: {}\n\
569- Max review stall: {}\n\
570- Rework cycles: {}\n\
571- Rework rate: {}\n\
572- Review nudges: {}\n\
573- Review escalations: {}\n",
574        stats.auto_merge_count,
575        stats.manual_merge_count,
576        auto_rate,
577        avg_stall,
578        max_stall,
579        stats.rework_count,
580        rework_rate,
581        stats.review_nudge_count,
582        stats.review_escalation_count,
583    );
584
585    if !stats.task_rework_counts.is_empty() {
586        section.push_str(
587            "\n### Rework by Task\n\n\
588| Task | Rework Cycles |\n\
589| --- | --- |\n",
590        );
591        for (task_id, count) in &stats.task_rework_counts {
592            section.push_str(&format!("| {} | {} |\n", task_id, count));
593        }
594    }
595
596    section.push('\n');
597    section
598}
599
600fn render_task_cycle_rows(tasks: &[TaskStats]) -> String {
601    if tasks.is_empty() {
602        return "| No tasks recorded | - | - | - | - | - |\n".to_string();
603    }
604
605    let mut rows = String::new();
606    for task in tasks {
607        let status = if task.completed_at.is_some() {
608            "completed"
609        } else {
610            "incomplete"
611        };
612        let cycle_time = task
613            .cycle_time_secs
614            .map(format_duration)
615            .unwrap_or_else(|| "-".to_string());
616        let escalated = if task.was_escalated { "yes" } else { "no" };
617        rows.push_str(&format!(
618            "| {} | {} | {} | {} | {} | {} |\n",
619            task.task_id, task.assigned_to, status, cycle_time, task.retry_count, escalated
620        ));
621    }
622    rows
623}
624
625fn render_bottlenecks(tasks: &[TaskStats]) -> String {
626    let longest_task = tasks
627        .iter()
628        .filter_map(|task| task.cycle_time_secs.map(|cycle| (task, cycle)))
629        .max_by_key(|(_, cycle)| *cycle);
630
631    let most_retried = tasks.iter().max_by_key(|task| task.retry_count);
632
633    let mut lines = Vec::new();
634    match longest_task {
635        Some((task, cycle)) => lines.push(format!(
636            "- Longest task: `{}` owned by `{}` at {}.",
637            task.task_id,
638            task.assigned_to,
639            format_duration(cycle)
640        )),
641        None => lines.push("- Longest task: no completed tasks recorded.".to_string()),
642    }
643
644    match most_retried {
645        Some(task) if task.retry_count > 1 => lines.push(format!(
646            "- Most retried: `{}` retried {} times.",
647            task.task_id, task.retry_count
648        )),
649        _ => lines.push("- Most retried: no task needed multiple attempts.".to_string()),
650    }
651
652    format!("{}\n", lines.join("\n"))
653}
654
655fn render_recommendations(stats: &RunStats) -> String {
656    let mut lines = Vec::new();
657    let max_retry_count = stats
658        .task_stats
659        .iter()
660        .map(|task| task.retry_count)
661        .max()
662        .unwrap_or(0);
663
664    if stats.idle_time_pct >= 0.5 {
665        lines.push(
666            "- Idle time stayed high. Queue more ready tasks so engineers are not waiting on assignment."
667                .to_string(),
668        );
669    }
670
671    if max_retry_count >= 3 {
672        lines.push(
673            "- Several retries were needed. Break work into smaller tasks with clearer acceptance criteria."
674                .to_string(),
675        );
676    }
677
678    if lines.is_empty() {
679        lines.push(
680            "- No major bottlenecks stood out. Keep the current task sizing and routing cadence."
681                .to_string(),
682        );
683    }
684
685    format!("{}\n", lines.join("\n"))
686}
687
688#[cfg(test)]
689mod tests {
690    use tempfile::tempdir;
691
692    use super::*;
693
694    fn at(mut event: TeamEvent, ts: u64) -> TeamEvent {
695        event.ts = ts;
696        event
697    }
698
699    #[test]
700    fn test_analyze_events_basic_run() {
701        let events = vec![
702            at(TeamEvent::daemon_started(), 100),
703            at(TeamEvent::task_assigned("eng-1", "42"), 110),
704            at(TeamEvent::message_routed("manager", "eng-1"), 115),
705            at(TeamEvent::task_completed("eng-1", None), 150),
706            at(TeamEvent::daemon_stopped_with_reason("signal", 50), 160),
707        ];
708
709        let stats = analyze_events(&events).unwrap();
710
711        assert_eq!(stats.run_start, 100);
712        assert_eq!(stats.run_end, 160);
713        assert_eq!(stats.total_duration_secs, 60);
714        assert_eq!(stats.escalation_count, 0);
715        assert_eq!(stats.message_count, 1);
716        assert_eq!(stats.task_stats.len(), 1);
717        assert_eq!(stats.average_cycle_time_secs, Some(40));
718        assert_eq!(stats.fastest_task_id.as_deref(), Some("42"));
719        assert_eq!(stats.fastest_cycle_time_secs, Some(40));
720        assert_eq!(stats.longest_task_id.as_deref(), Some("42"));
721        assert_eq!(stats.longest_cycle_time_secs, Some(40));
722        assert_eq!(
723            stats.task_stats[0],
724            TaskStats {
725                task_id: "42".to_string(),
726                assigned_to: "eng-1".to_string(),
727                assigned_at: 110,
728                completed_at: Some(150),
729                cycle_time_secs: Some(40),
730                retry_count: 1,
731                was_escalated: false,
732            }
733        );
734    }
735
736    #[test]
737    fn test_analyze_events_with_retries() {
738        let events = vec![
739            at(TeamEvent::daemon_started(), 100),
740            at(
741                TeamEvent::task_assigned("eng-1", "Task #42: retry task"),
742                110,
743            ),
744            at(
745                TeamEvent::task_assigned("eng-1", "Task #42: retry task"),
746                130,
747            ),
748            at(TeamEvent::task_completed("eng-1", None), 170),
749            at(TeamEvent::daemon_stopped_with_reason("signal", 70), 180),
750        ];
751
752        let stats = analyze_events(&events).unwrap();
753
754        assert_eq!(stats.task_stats.len(), 1);
755        assert_eq!(stats.task_stats[0].retry_count, 2);
756        assert_eq!(stats.task_stats[0].assigned_at, 110);
757        assert_eq!(stats.task_stats[0].cycle_time_secs, Some(60));
758        assert_eq!(stats.task_stats[0].task_id, "42");
759    }
760
761    #[test]
762    fn test_analyze_events_with_escalation() {
763        let events = vec![
764            at(TeamEvent::daemon_started(), 100),
765            at(TeamEvent::task_assigned("eng-1", "42"), 110),
766            at(TeamEvent::task_escalated("eng-1", "42", None), 125),
767            at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
768        ];
769
770        let stats = analyze_events(&events).unwrap();
771
772        assert_eq!(stats.escalation_count, 1);
773        assert_eq!(stats.task_stats.len(), 1);
774        assert!(stats.task_stats[0].was_escalated);
775        assert_eq!(stats.task_stats[0].completed_at, None);
776    }
777
778    #[test]
779    fn test_analyze_events_idle_time() {
780        let events = vec![
781            at(TeamEvent::daemon_started(), 100),
782            at(TeamEvent::load_snapshot(1, 4, true), 110),
783            at(TeamEvent::load_snapshot(3, 4, true), 120),
784            at(TeamEvent::daemon_stopped_with_reason("signal", 25), 125),
785        ];
786
787        let stats = analyze_events(&events).unwrap();
788
789        assert!((stats.idle_time_pct - 0.5).abs() < 1e-9);
790    }
791
792    #[test]
793    fn test_analyze_events_empty() {
794        assert_eq!(analyze_events(&[]), None);
795    }
796
797    #[test]
798    fn test_analyze_events_multiple_runs() {
799        let events = vec![
800            at(TeamEvent::daemon_started(), 100),
801            at(TeamEvent::task_assigned("eng-1", "old-task"), 105),
802            at(TeamEvent::daemon_stopped_with_reason("signal", 10), 110),
803            at(TeamEvent::daemon_started(), 200),
804            at(
805                TeamEvent::task_assigned("eng-2", "Task #12: new-task\n\nTask details."),
806                210,
807            ),
808            at(TeamEvent::task_completed("eng-2", None), 240),
809            at(TeamEvent::daemon_stopped_with_reason("signal", 45), 245),
810        ];
811
812        let stats = analyze_events(&events).unwrap();
813
814        assert_eq!(stats.run_start, 200);
815        assert_eq!(stats.run_end, 245);
816        assert_eq!(stats.task_stats.len(), 1);
817        assert_eq!(stats.task_stats[0].task_id, "12");
818        assert_eq!(stats.task_stats[0].assigned_to, "eng-2");
819        assert_eq!(stats.task_stats[0].cycle_time_secs, Some(30));
820        assert_eq!(stats.average_cycle_time_secs, Some(30));
821        assert_eq!(stats.fastest_task_id.as_deref(), Some("12"));
822        assert_eq!(stats.longest_task_id.as_deref(), Some("12"));
823    }
824
825    #[test]
826    fn test_analyze_events_computes_average_fastest_and_longest_cycle_times() {
827        let events = vec![
828            at(TeamEvent::daemon_started(), 100),
829            at(
830                TeamEvent::task_assigned("eng-1", "Task #11: short task\n\nBody."),
831                110,
832            ),
833            at(TeamEvent::task_completed("eng-1", None), 140),
834            at(
835                TeamEvent::task_assigned("eng-2", "Task #12: long task\n\nBody."),
836                150,
837            ),
838            at(TeamEvent::task_completed("eng-2", None), 240),
839            at(TeamEvent::daemon_stopped_with_reason("signal", 150), 250),
840        ];
841
842        let stats = analyze_events(&events).unwrap();
843
844        assert_eq!(stats.average_cycle_time_secs, Some(60));
845        assert_eq!(stats.fastest_task_id.as_deref(), Some("11"));
846        assert_eq!(stats.fastest_cycle_time_secs, Some(30));
847        assert_eq!(stats.longest_task_id.as_deref(), Some("12"));
848        assert_eq!(stats.longest_cycle_time_secs, Some(90));
849    }
850
851    fn sample_task(task_id: &str, cycle_time_secs: Option<u64>, retry_count: u32) -> TaskStats {
852        TaskStats {
853            task_id: task_id.to_string(),
854            assigned_to: "eng-1".to_string(),
855            assigned_at: 100,
856            completed_at: cycle_time_secs.map(|cycle| 100 + cycle),
857            cycle_time_secs,
858            retry_count,
859            was_escalated: retry_count > 2,
860        }
861    }
862
863    #[test]
864    fn format_duration_variants() {
865        assert_eq!(format_duration(45), "45s");
866        assert_eq!(format_duration(65), "1m 05s");
867        assert_eq!(format_duration(3_665), "1h 01m 05s");
868    }
869
870    #[test]
871    fn generate_retrospective_writes_report_with_sections() {
872        let tmp = tempdir().unwrap();
873        let stats = RunStats {
874            run_start: 1_700_000_000,
875            run_end: 1_700_000_123,
876            total_duration_secs: 123,
877            task_stats: vec![
878                sample_task("T-101", Some(90), 1),
879                sample_task("T-102", Some(30), 2),
880            ],
881            average_cycle_time_secs: Some(60),
882            fastest_task_id: Some("T-102".to_string()),
883            fastest_cycle_time_secs: Some(30),
884            longest_task_id: Some("T-101".to_string()),
885            longest_cycle_time_secs: Some(90),
886            idle_time_pct: 0.25,
887            escalation_count: 1,
888            message_count: 6,
889            auto_merge_count: 0,
890            manual_merge_count: 0,
891            rework_count: 0,
892            review_nudge_count: 0,
893            review_escalation_count: 0,
894            avg_review_stall_secs: None,
895            max_review_stall_secs: None,
896            max_review_stall_task: None,
897            task_rework_counts: Vec::new(),
898        };
899
900        let path = generate_retrospective(tmp.path(), &stats).unwrap();
901        let content = fs::read_to_string(&path).unwrap();
902
903        assert_eq!(
904            path,
905            tmp.path()
906                .join(".batty")
907                .join("retrospectives")
908                .join("1700000123.md")
909        );
910        assert!(content.contains("## Summary"));
911        assert!(content.contains("## Task Cycle Times"));
912        assert!(content.contains("## Bottlenecks"));
913        assert!(content.contains("## Recommendations"));
914        assert!(content.contains("| T-101 | eng-1 | completed | 1m 30s | 1 | no |"));
915        assert!(content.contains("- Tasks completed: 2"));
916        assert!(content.contains("- Average cycle time: 1m 00s"));
917        assert!(content.contains("- Fastest task: 30s (T-102)"));
918        assert!(content.contains("- Longest task: 1m 30s (T-101)"));
919    }
920
921    #[test]
922    fn generate_retrospective_handles_empty_tasks() {
923        let tmp = tempdir().unwrap();
924        let stats = RunStats {
925            run_start: 10,
926            run_end: 20,
927            total_duration_secs: 10,
928            task_stats: Vec::new(),
929            average_cycle_time_secs: None,
930            fastest_task_id: None,
931            fastest_cycle_time_secs: None,
932            longest_task_id: None,
933            longest_cycle_time_secs: None,
934            idle_time_pct: 0.0,
935            escalation_count: 0,
936            message_count: 0,
937            auto_merge_count: 0,
938            manual_merge_count: 0,
939            rework_count: 0,
940            review_nudge_count: 0,
941            review_escalation_count: 0,
942            avg_review_stall_secs: None,
943            max_review_stall_secs: None,
944            max_review_stall_task: None,
945            task_rework_counts: Vec::new(),
946        };
947
948        let path = generate_retrospective(tmp.path(), &stats).unwrap();
949        let content = fs::read_to_string(path).unwrap();
950
951        assert!(content.contains("| No tasks recorded | - | - | - | - | - |"));
952        assert!(content.contains("- Average cycle time: -"));
953        assert!(content.contains("- Fastest task: -"));
954        assert!(content.contains("- Longest task: -"));
955        assert!(content.contains("- Longest task: no completed tasks recorded."));
956        assert!(content.contains("- Most retried: no task needed multiple attempts."));
957    }
958
959    #[test]
960    fn generate_retrospective_adds_high_idle_recommendation() {
961        let tmp = tempdir().unwrap();
962        let stats = RunStats {
963            run_start: 10,
964            run_end: 30,
965            total_duration_secs: 20,
966            task_stats: vec![sample_task("T-201", Some(20), 1)],
967            average_cycle_time_secs: Some(20),
968            fastest_task_id: Some("T-201".to_string()),
969            fastest_cycle_time_secs: Some(20),
970            longest_task_id: Some("T-201".to_string()),
971            longest_cycle_time_secs: Some(20),
972            idle_time_pct: 0.75,
973            escalation_count: 0,
974            message_count: 1,
975            auto_merge_count: 0,
976            manual_merge_count: 0,
977            rework_count: 0,
978            review_nudge_count: 0,
979            review_escalation_count: 0,
980            avg_review_stall_secs: None,
981            max_review_stall_secs: None,
982            max_review_stall_task: None,
983            task_rework_counts: Vec::new(),
984        };
985
986        let path = generate_retrospective(tmp.path(), &stats).unwrap();
987        let content = fs::read_to_string(path).unwrap();
988
989        assert!(content.contains("Idle time stayed high"));
990        assert!(content.contains("Queue more ready tasks"));
991    }
992
993    #[test]
994    fn generate_retrospective_adds_high_retry_recommendation() {
995        let tmp = tempdir().unwrap();
996        let stats = RunStats {
997            run_start: 10,
998            run_end: 40,
999            total_duration_secs: 30,
1000            task_stats: vec![sample_task("T-301", Some(25), 3)],
1001            average_cycle_time_secs: Some(25),
1002            fastest_task_id: Some("T-301".to_string()),
1003            fastest_cycle_time_secs: Some(25),
1004            longest_task_id: Some("T-301".to_string()),
1005            longest_cycle_time_secs: Some(25),
1006            idle_time_pct: 0.1,
1007            escalation_count: 0,
1008            message_count: 2,
1009            auto_merge_count: 0,
1010            manual_merge_count: 0,
1011            rework_count: 0,
1012            review_nudge_count: 0,
1013            review_escalation_count: 0,
1014            avg_review_stall_secs: None,
1015            max_review_stall_secs: None,
1016            max_review_stall_task: None,
1017            task_rework_counts: Vec::new(),
1018        };
1019
1020        let path = generate_retrospective(tmp.path(), &stats).unwrap();
1021        let content = fs::read_to_string(path).unwrap();
1022
1023        assert!(content.contains("Several retries were needed"));
1024        assert!(content.contains("smaller tasks"));
1025    }
1026
1027    fn write_owned_task_file(
1028        project_root: &Path,
1029        task_id: u32,
1030        title: &str,
1031        status: &str,
1032        claimed_by: &str,
1033    ) {
1034        let board_dir = project_root
1035            .join(".batty")
1036            .join("team_config")
1037            .join("board");
1038        let tasks_dir = board_dir.join("tasks");
1039        fs::create_dir_all(&tasks_dir).unwrap();
1040        let slug = title.replace(' ', "-");
1041        let task_path = tasks_dir.join(format!("{task_id:03}-{slug}.md"));
1042        let content = format!(
1043            r#"---
1044id: {task_id}
1045title: "{title}"
1046status: {status}
1047claimed_by: {claimed_by}
1048---
1049
1050Task body.
1051"#
1052        );
1053        fs::write(task_path, content).unwrap();
1054    }
1055
1056    fn write_event_log(project_root: &Path, events: &[TeamEvent]) {
1057        let events_path = project_root
1058            .join(".batty")
1059            .join("team_config")
1060            .join("events.jsonl");
1061        fs::create_dir_all(events_path.parent().unwrap()).unwrap();
1062        let body = events
1063            .iter()
1064            .map(|event| serde_json::to_string(event).unwrap())
1065            .collect::<Vec<_>>()
1066            .join("\n");
1067        fs::write(events_path, format!("{body}\n")).unwrap();
1068    }
1069
1070    #[test]
1071    fn should_generate_retro_when_all_active_tasks_are_done() {
1072        let tmp = tempdir().unwrap();
1073        write_owned_task_file(tmp.path(), 45, "retro-task", "done", "eng-1");
1074        write_event_log(
1075            tmp.path(),
1076            &[
1077                at(TeamEvent::daemon_started(), 100),
1078                at(TeamEvent::task_assigned("eng-1", "45"), 110),
1079                at(TeamEvent::task_completed("eng-1", None), 150),
1080                at(TeamEvent::daemon_stopped(), 160),
1081            ],
1082        );
1083
1084        let stats = should_generate_retro(tmp.path(), false, 60)
1085            .unwrap()
1086            .unwrap();
1087        assert_eq!(stats.run_start, 100);
1088        assert_eq!(stats.run_end, 160);
1089        assert_eq!(stats.task_stats.len(), 1);
1090        assert_eq!(stats.task_stats[0].task_id, "45");
1091    }
1092
1093    #[test]
1094    fn should_not_generate_retro_when_task_is_not_done() {
1095        let tmp = tempdir().unwrap();
1096        write_owned_task_file(tmp.path(), 45, "retro-task", "in-progress", "eng-1");
1097        write_event_log(tmp.path(), &[at(TeamEvent::daemon_started(), 100)]);
1098
1099        let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
1100        assert_eq!(stats, None);
1101    }
1102
1103    #[test]
1104    fn should_not_generate_retro_twice() {
1105        let tmp = tempdir().unwrap();
1106        write_owned_task_file(tmp.path(), 45, "retro-task", "done", "eng-1");
1107        write_event_log(
1108            tmp.path(),
1109            &[
1110                at(TeamEvent::daemon_started(), 100),
1111                at(TeamEvent::task_assigned("eng-1", "45"), 110),
1112                at(TeamEvent::task_completed("eng-1", None), 150),
1113                at(TeamEvent::daemon_stopped(), 160),
1114            ],
1115        );
1116
1117        let stats = should_generate_retro(tmp.path(), true, 60).unwrap();
1118        assert_eq!(stats, None);
1119    }
1120
1121    #[test]
1122    fn skip_retro_for_short_run() {
1123        let tmp = tempdir().unwrap();
1124        write_owned_task_file(tmp.path(), 50, "short-task", "done", "eng-1");
1125        write_event_log(
1126            tmp.path(),
1127            &[
1128                at(TeamEvent::daemon_started(), 100),
1129                at(TeamEvent::daemon_stopped(), 104),
1130            ],
1131        );
1132
1133        // 4-second run, 0 completions -> suppressed
1134        let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
1135        assert_eq!(stats, None);
1136    }
1137
1138    #[test]
1139    fn generate_retro_for_long_run() {
1140        let tmp = tempdir().unwrap();
1141        write_owned_task_file(tmp.path(), 51, "long-task", "done", "eng-1");
1142        write_event_log(
1143            tmp.path(),
1144            &[
1145                at(TeamEvent::daemon_started(), 100),
1146                at(TeamEvent::task_assigned("eng-1", "51"), 110),
1147                at(TeamEvent::task_completed("eng-1", None), 200),
1148                at(TeamEvent::task_assigned("eng-1", "52"), 210),
1149                at(TeamEvent::task_completed("eng-1", None), 300),
1150                at(TeamEvent::task_assigned("eng-1", "53"), 310),
1151                at(TeamEvent::task_completed("eng-1", None), 380),
1152                at(TeamEvent::daemon_stopped(), 400),
1153            ],
1154        );
1155
1156        // 300-second run, 3 completions -> generates
1157        let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
1158        assert!(stats.is_some());
1159        let stats = stats.unwrap();
1160        assert_eq!(stats.total_duration_secs, 300);
1161    }
1162
1163    #[test]
1164    fn skip_retro_for_short_run_with_completions() {
1165        let tmp = tempdir().unwrap();
1166        write_owned_task_file(tmp.path(), 55, "quick-task", "done", "eng-1");
1167        write_event_log(
1168            tmp.path(),
1169            &[
1170                at(TeamEvent::daemon_started(), 100),
1171                at(TeamEvent::task_assigned("eng-1", "55"), 105),
1172                at(TeamEvent::task_completed("eng-1", None), 115),
1173                at(TeamEvent::task_assigned("eng-1", "56"), 118),
1174                at(TeamEvent::task_completed("eng-1", None), 125),
1175                at(TeamEvent::daemon_stopped(), 130),
1176            ],
1177        );
1178
1179        // 30-second run but 2 completions -> generates (completions override)
1180        let stats = should_generate_retro(tmp.path(), false, 60).unwrap();
1181        assert!(stats.is_some());
1182        let stats = stats.unwrap();
1183        assert_eq!(stats.total_duration_secs, 30);
1184    }
1185
1186    #[test]
1187    fn analyze_events_computes_review_stall_duration() {
1188        let events = vec![
1189            at(TeamEvent::daemon_started(), 100),
1190            at(
1191                TeamEvent::task_assigned("eng-1", "Task #10: fast task"),
1192                110,
1193            ),
1194            at(TeamEvent::task_completed("eng-1", None), 150),
1195            // 30s stall before auto-merge
1196            at(
1197                TeamEvent::task_auto_merged("eng-1", "Task #10: fast task", 0.9, 2, 10),
1198                180,
1199            ),
1200            at(
1201                TeamEvent::task_assigned("eng-2", "Task #20: slow task"),
1202                120,
1203            ),
1204            at(TeamEvent::task_completed("eng-2", None), 200),
1205            // 100s stall before manual merge
1206            at(TeamEvent::task_manual_merged("Task #20: slow task"), 300),
1207            at(TeamEvent::daemon_stopped_with_reason("signal", 210), 310),
1208        ];
1209
1210        let stats = analyze_events(&events).unwrap();
1211
1212        assert_eq!(stats.auto_merge_count, 1);
1213        assert_eq!(stats.manual_merge_count, 1);
1214        // avg of 30s and 100s = 65s
1215        assert_eq!(stats.avg_review_stall_secs, Some(65));
1216        // max is 100s for task 20
1217        assert_eq!(stats.max_review_stall_secs, Some(100));
1218        assert_eq!(stats.max_review_stall_task.as_deref(), Some("20"));
1219    }
1220
1221    #[test]
1222    fn analyze_events_no_stall_without_merges() {
1223        let events = vec![
1224            at(TeamEvent::daemon_started(), 100),
1225            at(TeamEvent::task_assigned("eng-1", "42"), 110),
1226            at(TeamEvent::task_completed("eng-1", None), 150),
1227            at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
1228        ];
1229
1230        let stats = analyze_events(&events).unwrap();
1231
1232        assert_eq!(stats.avg_review_stall_secs, None);
1233        assert_eq!(stats.max_review_stall_secs, None);
1234        assert_eq!(stats.max_review_stall_task, None);
1235    }
1236
1237    #[test]
1238    fn analyze_events_tracks_per_task_rework() {
1239        let events = vec![
1240            at(TeamEvent::daemon_started(), 100),
1241            at(TeamEvent::task_assigned("eng-1", "Task #10: reworked"), 110),
1242            at(TeamEvent::task_reworked("eng-1", "Task #10: reworked"), 120),
1243            at(TeamEvent::task_reworked("eng-1", "Task #10: reworked"), 130),
1244            at(TeamEvent::task_assigned("eng-2", "Task #20: once"), 115),
1245            at(TeamEvent::task_reworked("eng-2", "Task #20: once"), 140),
1246            at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
1247        ];
1248
1249        let stats = analyze_events(&events).unwrap();
1250
1251        assert_eq!(stats.rework_count, 3);
1252        // Sorted by count descending: task 10 (2), task 20 (1)
1253        assert_eq!(stats.task_rework_counts.len(), 2);
1254        assert_eq!(stats.task_rework_counts[0], ("10".to_string(), 2));
1255        assert_eq!(stats.task_rework_counts[1], ("20".to_string(), 1));
1256    }
1257
1258    #[test]
1259    fn analyze_events_empty_rework_list() {
1260        let events = vec![
1261            at(TeamEvent::daemon_started(), 100),
1262            at(TeamEvent::task_assigned("eng-1", "42"), 110),
1263            at(TeamEvent::task_completed("eng-1", None), 150),
1264            at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
1265        ];
1266
1267        let stats = analyze_events(&events).unwrap();
1268
1269        assert!(stats.task_rework_counts.is_empty());
1270    }
1271
1272    #[test]
1273    fn render_review_pipeline_section_includes_stall_and_rework() {
1274        let tmp = tempdir().unwrap();
1275        let stats = RunStats {
1276            run_start: 100,
1277            run_end: 500,
1278            total_duration_secs: 400,
1279            task_stats: Vec::new(),
1280            average_cycle_time_secs: None,
1281            fastest_task_id: None,
1282            fastest_cycle_time_secs: None,
1283            longest_task_id: None,
1284            longest_cycle_time_secs: None,
1285            idle_time_pct: 0.0,
1286            escalation_count: 0,
1287            message_count: 0,
1288            auto_merge_count: 3,
1289            manual_merge_count: 1,
1290            rework_count: 2,
1291            review_nudge_count: 1,
1292            review_escalation_count: 0,
1293            avg_review_stall_secs: Some(90),
1294            max_review_stall_secs: Some(180),
1295            max_review_stall_task: Some("T-5".to_string()),
1296            task_rework_counts: vec![("T-5".to_string(), 2)],
1297        };
1298
1299        let path = generate_retrospective(tmp.path(), &stats).unwrap();
1300        let content = fs::read_to_string(path).unwrap();
1301
1302        assert!(content.contains("## Review Pipeline"));
1303        assert!(content.contains("Auto-merged: 3"));
1304        assert!(content.contains("Manually merged: 1"));
1305        assert!(content.contains("Auto-merge rate: 75%"));
1306        assert!(content.contains("Avg review stall: 1m 30s"));
1307        assert!(content.contains("Max review stall: 3m 00s (T-5)"));
1308        assert!(content.contains("Rework cycles: 2"));
1309        assert!(content.contains("### Rework by Task"));
1310        assert!(content.contains("| T-5 | 2 |"));
1311    }
1312
1313    #[test]
1314    fn render_review_pipeline_no_stall_data() {
1315        let tmp = tempdir().unwrap();
1316        let stats = RunStats {
1317            run_start: 100,
1318            run_end: 300,
1319            total_duration_secs: 200,
1320            task_stats: Vec::new(),
1321            average_cycle_time_secs: None,
1322            fastest_task_id: None,
1323            fastest_cycle_time_secs: None,
1324            longest_task_id: None,
1325            longest_cycle_time_secs: None,
1326            idle_time_pct: 0.0,
1327            escalation_count: 0,
1328            message_count: 0,
1329            auto_merge_count: 2,
1330            manual_merge_count: 0,
1331            rework_count: 0,
1332            review_nudge_count: 0,
1333            review_escalation_count: 0,
1334            avg_review_stall_secs: None,
1335            max_review_stall_secs: None,
1336            max_review_stall_task: None,
1337            task_rework_counts: Vec::new(),
1338        };
1339
1340        let path = generate_retrospective(tmp.path(), &stats).unwrap();
1341        let content = fs::read_to_string(path).unwrap();
1342
1343        assert!(content.contains("## Review Pipeline"));
1344        assert!(content.contains("Avg review stall: -"));
1345        assert!(content.contains("Max review stall: -"));
1346        assert!(!content.contains("### Rework by Task"));
1347    }
1348
1349    // --- New tests for content generation and event analysis ---
1350
1351    #[test]
1352    fn task_reference_extracts_id_from_task_prefix() {
1353        assert_eq!(task_reference("Task #42: build feature"), "42");
1354    }
1355
1356    #[test]
1357    fn task_reference_returns_full_line_when_no_prefix() {
1358        assert_eq!(task_reference("build feature"), "build feature");
1359    }
1360
1361    #[test]
1362    fn task_reference_skips_blank_lines() {
1363        assert_eq!(task_reference("\n\n  Task #99: test\nbody"), "99");
1364    }
1365
1366    #[test]
1367    fn task_reference_handles_whitespace_only_input() {
1368        assert_eq!(task_reference("   "), "");
1369    }
1370
1371    #[test]
1372    fn task_id_from_assignment_line_valid() {
1373        assert_eq!(
1374            task_id_from_assignment_line("Task #123: some task"),
1375            Some("123".to_string())
1376        );
1377    }
1378
1379    #[test]
1380    fn task_id_from_assignment_line_no_prefix() {
1381        assert_eq!(task_id_from_assignment_line("no prefix here"), None);
1382    }
1383
1384    #[test]
1385    fn task_id_from_assignment_line_empty_digits() {
1386        assert_eq!(task_id_from_assignment_line("Task #abc: letters"), None);
1387    }
1388
1389    #[test]
1390    fn cycle_time_metrics_no_completed_tasks() {
1391        let tasks = vec![sample_task("T-1", None, 1)];
1392        let (avg, fastest, fastest_time, longest, longest_time) = cycle_time_metrics(&tasks);
1393        assert_eq!(avg, None);
1394        assert_eq!(fastest, None);
1395        assert_eq!(fastest_time, None);
1396        assert_eq!(longest, None);
1397        assert_eq!(longest_time, None);
1398    }
1399
1400    #[test]
1401    fn cycle_time_metrics_single_completed_task() {
1402        let tasks = vec![sample_task("T-1", Some(60), 1)];
1403        let (avg, fastest, fastest_time, longest, longest_time) = cycle_time_metrics(&tasks);
1404        assert_eq!(avg, Some(60));
1405        assert_eq!(fastest, Some("T-1".to_string()));
1406        assert_eq!(fastest_time, Some(60));
1407        assert_eq!(longest, Some("T-1".to_string()));
1408        assert_eq!(longest_time, Some(60));
1409    }
1410
1411    #[test]
1412    fn cycle_time_metrics_multiple_tasks_picks_extremes() {
1413        let tasks = vec![
1414            sample_task("T-fast", Some(10), 1),
1415            sample_task("T-mid", Some(50), 1),
1416            sample_task("T-slow", Some(90), 1),
1417            sample_task("T-incomplete", None, 1),
1418        ];
1419        let (avg, fastest, fastest_time, longest, longest_time) = cycle_time_metrics(&tasks);
1420        assert_eq!(avg, Some(50)); // (10 + 50 + 90) / 3
1421        assert_eq!(fastest, Some("T-fast".to_string()));
1422        assert_eq!(fastest_time, Some(10));
1423        assert_eq!(longest, Some("T-slow".to_string()));
1424        assert_eq!(longest_time, Some(90));
1425    }
1426
1427    #[test]
1428    fn format_duration_zero() {
1429        assert_eq!(format_duration(0), "0s");
1430    }
1431
1432    #[test]
1433    fn format_duration_exact_minute() {
1434        assert_eq!(format_duration(60), "1m 00s");
1435    }
1436
1437    #[test]
1438    fn format_duration_exact_hour() {
1439        assert_eq!(format_duration(3600), "1h 00m 00s");
1440    }
1441
1442    #[test]
1443    fn format_duration_large() {
1444        assert_eq!(format_duration(7322), "2h 02m 02s");
1445    }
1446
1447    #[test]
1448    fn render_task_cycle_rows_empty() {
1449        let rows = render_task_cycle_rows(&[]);
1450        assert!(rows.contains("No tasks recorded"));
1451    }
1452
1453    #[test]
1454    fn render_task_cycle_rows_completed_and_incomplete() {
1455        let tasks = vec![
1456            sample_task("T-1", Some(120), 1),
1457            sample_task("T-2", None, 2),
1458        ];
1459        let rows = render_task_cycle_rows(&tasks);
1460        assert!(rows.contains("| T-1 | eng-1 | completed | 2m 00s | 1 | no |"));
1461        assert!(rows.contains("| T-2 | eng-1 | incomplete | - | 2 | no |"));
1462    }
1463
1464    #[test]
1465    fn render_task_cycle_rows_escalated_task() {
1466        let tasks = vec![sample_task("T-esc", Some(200), 4)]; // retry > 2 → escalated
1467        let rows = render_task_cycle_rows(&tasks);
1468        assert!(rows.contains("| T-esc | eng-1 | completed | 3m 20s | 4 | yes |"));
1469    }
1470
1471    #[test]
1472    fn render_bottlenecks_no_completed_tasks() {
1473        let tasks = vec![sample_task("T-1", None, 1)];
1474        let output = render_bottlenecks(&tasks);
1475        assert!(output.contains("no completed tasks recorded"));
1476        assert!(output.contains("no task needed multiple attempts"));
1477    }
1478
1479    #[test]
1480    fn render_bottlenecks_with_retries() {
1481        let tasks = vec![
1482            sample_task("T-1", Some(100), 1),
1483            sample_task("T-2", Some(200), 3),
1484        ];
1485        let output = render_bottlenecks(&tasks);
1486        assert!(output.contains("Longest task: `T-2`"));
1487        assert!(output.contains("Most retried: `T-2` retried 3 times"));
1488    }
1489
1490    #[test]
1491    fn render_bottlenecks_single_retry_shows_no_retries_message() {
1492        let tasks = vec![sample_task("T-1", Some(60), 1)];
1493        let output = render_bottlenecks(&tasks);
1494        assert!(output.contains("no task needed multiple attempts"));
1495    }
1496
1497    #[test]
1498    fn render_recommendations_low_idle_low_retries() {
1499        let stats = RunStats {
1500            run_start: 0,
1501            run_end: 100,
1502            total_duration_secs: 100,
1503            task_stats: vec![sample_task("T-1", Some(50), 1)],
1504            average_cycle_time_secs: Some(50),
1505            fastest_task_id: Some("T-1".to_string()),
1506            fastest_cycle_time_secs: Some(50),
1507            longest_task_id: Some("T-1".to_string()),
1508            longest_cycle_time_secs: Some(50),
1509            idle_time_pct: 0.1,
1510            escalation_count: 0,
1511            message_count: 1,
1512            auto_merge_count: 0,
1513            manual_merge_count: 0,
1514            rework_count: 0,
1515            review_nudge_count: 0,
1516            review_escalation_count: 0,
1517            avg_review_stall_secs: None,
1518            max_review_stall_secs: None,
1519            max_review_stall_task: None,
1520            task_rework_counts: Vec::new(),
1521        };
1522        let output = render_recommendations(&stats);
1523        assert!(output.contains("No major bottlenecks"));
1524    }
1525
1526    #[test]
1527    fn render_recommendations_both_high_idle_and_high_retries() {
1528        let stats = RunStats {
1529            run_start: 0,
1530            run_end: 100,
1531            total_duration_secs: 100,
1532            task_stats: vec![sample_task("T-1", Some(50), 5)],
1533            average_cycle_time_secs: Some(50),
1534            fastest_task_id: Some("T-1".to_string()),
1535            fastest_cycle_time_secs: Some(50),
1536            longest_task_id: Some("T-1".to_string()),
1537            longest_cycle_time_secs: Some(50),
1538            idle_time_pct: 0.8,
1539            escalation_count: 0,
1540            message_count: 1,
1541            auto_merge_count: 0,
1542            manual_merge_count: 0,
1543            rework_count: 0,
1544            review_nudge_count: 0,
1545            review_escalation_count: 0,
1546            avg_review_stall_secs: None,
1547            max_review_stall_secs: None,
1548            max_review_stall_task: None,
1549            task_rework_counts: Vec::new(),
1550        };
1551        let output = render_recommendations(&stats);
1552        assert!(output.contains("Idle time stayed high"));
1553        assert!(output.contains("Several retries were needed"));
1554    }
1555
1556    #[test]
1557    fn render_review_performance_empty_when_no_merges() {
1558        let stats = RunStats {
1559            run_start: 0,
1560            run_end: 100,
1561            total_duration_secs: 100,
1562            task_stats: Vec::new(),
1563            average_cycle_time_secs: None,
1564            fastest_task_id: None,
1565            fastest_cycle_time_secs: None,
1566            longest_task_id: None,
1567            longest_cycle_time_secs: None,
1568            idle_time_pct: 0.0,
1569            escalation_count: 0,
1570            message_count: 0,
1571            auto_merge_count: 0,
1572            manual_merge_count: 0,
1573            rework_count: 0,
1574            review_nudge_count: 0,
1575            review_escalation_count: 0,
1576            avg_review_stall_secs: None,
1577            max_review_stall_secs: None,
1578            max_review_stall_task: None,
1579            task_rework_counts: Vec::new(),
1580        };
1581        let section = render_review_performance(&stats);
1582        assert!(section.is_empty());
1583    }
1584
1585    #[test]
1586    fn render_review_performance_100_percent_auto_merge_rate() {
1587        let stats = RunStats {
1588            run_start: 0,
1589            run_end: 100,
1590            total_duration_secs: 100,
1591            task_stats: Vec::new(),
1592            average_cycle_time_secs: None,
1593            fastest_task_id: None,
1594            fastest_cycle_time_secs: None,
1595            longest_task_id: None,
1596            longest_cycle_time_secs: None,
1597            idle_time_pct: 0.0,
1598            escalation_count: 0,
1599            message_count: 0,
1600            auto_merge_count: 5,
1601            manual_merge_count: 0,
1602            rework_count: 0,
1603            review_nudge_count: 0,
1604            review_escalation_count: 0,
1605            avg_review_stall_secs: None,
1606            max_review_stall_secs: None,
1607            max_review_stall_task: None,
1608            task_rework_counts: Vec::new(),
1609        };
1610        let section = render_review_performance(&stats);
1611        assert!(section.contains("Auto-merge rate: 100%"));
1612        assert!(section.contains("Auto-merged: 5"));
1613        assert!(section.contains("Manually merged: 0"));
1614    }
1615
1616    #[test]
1617    fn analyze_events_multiple_tasks_different_engineers() {
1618        let events = vec![
1619            at(TeamEvent::daemon_started(), 100),
1620            at(TeamEvent::task_assigned("eng-1", "Task #10: task-a"), 110),
1621            at(TeamEvent::task_assigned("eng-2", "Task #20: task-b"), 115),
1622            at(TeamEvent::task_completed("eng-1", None), 160),
1623            at(TeamEvent::task_completed("eng-2", None), 200),
1624            at(TeamEvent::daemon_stopped_with_reason("signal", 110), 210),
1625        ];
1626
1627        let stats = analyze_events(&events).unwrap();
1628        assert_eq!(stats.task_stats.len(), 2);
1629
1630        let t10 = stats.task_stats.iter().find(|t| t.task_id == "10").unwrap();
1631        assert_eq!(t10.assigned_to, "eng-1");
1632        assert_eq!(t10.cycle_time_secs, Some(50));
1633
1634        let t20 = stats.task_stats.iter().find(|t| t.task_id == "20").unwrap();
1635        assert_eq!(t20.assigned_to, "eng-2");
1636        assert_eq!(t20.cycle_time_secs, Some(85));
1637    }
1638
1639    #[test]
1640    fn analyze_events_tracks_review_nudges_and_escalations() {
1641        let events = vec![
1642            at(TeamEvent::daemon_started(), 100),
1643            at(
1644                TeamEvent::review_nudge_sent("manager", "Task #5: reviewed"),
1645                120,
1646            ),
1647            at(
1648                TeamEvent::review_nudge_sent("manager", "Task #5: reviewed"),
1649                140,
1650            ),
1651            at(
1652                TeamEvent::review_escalated("Task #5: reviewed", "stale"),
1653                160,
1654            ),
1655            at(TeamEvent::daemon_stopped_with_reason("signal", 80), 180),
1656        ];
1657
1658        let stats = analyze_events(&events).unwrap();
1659        assert_eq!(stats.review_nudge_count, 2);
1660        assert_eq!(stats.review_escalation_count, 1);
1661    }
1662
1663    #[test]
1664    fn analyze_events_completion_without_assignment_is_ignored() {
1665        let events = vec![
1666            at(TeamEvent::daemon_started(), 100),
1667            // Completion without prior assignment
1668            at(TeamEvent::task_completed("eng-1", None), 150),
1669            at(TeamEvent::daemon_stopped_with_reason("signal", 60), 160),
1670        ];
1671
1672        let stats = analyze_events(&events).unwrap();
1673        assert!(stats.task_stats.is_empty());
1674        assert_eq!(stats.average_cycle_time_secs, None);
1675    }
1676
1677    #[test]
1678    fn analyze_events_escalation_without_prior_assignment_creates_task() {
1679        let events = vec![
1680            at(TeamEvent::daemon_started(), 100),
1681            at(
1682                TeamEvent::task_escalated("eng-1", "Task #99: escalated-only", None),
1683                120,
1684            ),
1685            at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
1686        ];
1687
1688        let stats = analyze_events(&events).unwrap();
1689        assert_eq!(stats.escalation_count, 1);
1690        assert_eq!(stats.task_stats.len(), 1);
1691        assert!(stats.task_stats[0].was_escalated);
1692        // task_escalated stores the raw task string, not parsed through task_reference
1693        assert_eq!(stats.task_stats[0].task_id, "Task #99: escalated-only");
1694    }
1695
1696    #[test]
1697    fn analyze_events_daemon_started_only() {
1698        let events = vec![at(TeamEvent::daemon_started(), 100)];
1699        let stats = analyze_events(&events).unwrap();
1700        assert_eq!(stats.run_start, 100);
1701        assert_eq!(stats.run_end, 100);
1702        assert_eq!(stats.total_duration_secs, 0);
1703        assert!(stats.task_stats.is_empty());
1704    }
1705
1706    #[test]
1707    fn analyze_events_load_snapshot_all_working() {
1708        let events = vec![
1709            at(TeamEvent::daemon_started(), 100),
1710            at(TeamEvent::load_snapshot(4, 4, true), 110),
1711            at(TeamEvent::load_snapshot(4, 4, true), 120),
1712            at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
1713        ];
1714
1715        let stats = analyze_events(&events).unwrap();
1716        assert!((stats.idle_time_pct - 0.0).abs() < 1e-9);
1717    }
1718
1719    #[test]
1720    fn analyze_events_load_snapshot_all_idle() {
1721        let events = vec![
1722            at(TeamEvent::daemon_started(), 100),
1723            at(TeamEvent::load_snapshot(0, 4, true), 110),
1724            at(TeamEvent::load_snapshot(0, 4, true), 120),
1725            at(TeamEvent::daemon_stopped_with_reason("signal", 30), 130),
1726        ];
1727
1728        let stats = analyze_events(&events).unwrap();
1729        assert!((stats.idle_time_pct - 1.0).abs() < 1e-9);
1730    }
1731
1732    #[test]
1733    fn analyze_events_load_snapshot_zero_members() {
1734        let events = vec![
1735            at(TeamEvent::daemon_started(), 100),
1736            at(TeamEvent::load_snapshot(0, 0, true), 110),
1737            at(TeamEvent::daemon_stopped_with_reason("signal", 20), 120),
1738        ];
1739
1740        let stats = analyze_events(&events).unwrap();
1741        assert!((stats.idle_time_pct - 1.0).abs() < 1e-9);
1742    }
1743
1744    #[test]
1745    fn should_generate_retro_no_board_dir_returns_none() {
1746        let tmp = tempdir().unwrap();
1747        // No board dir at all
1748        let result = should_generate_retro(tmp.path(), false, 60).unwrap();
1749        assert_eq!(result, None);
1750    }
1751
1752    #[test]
1753    fn should_generate_retro_empty_board_returns_none() {
1754        let tmp = tempdir().unwrap();
1755        let tasks_dir = tmp
1756            .path()
1757            .join(".batty")
1758            .join("team_config")
1759            .join("board")
1760            .join("tasks");
1761        fs::create_dir_all(&tasks_dir).unwrap();
1762        // tasks dir exists but is empty
1763        let result = should_generate_retro(tmp.path(), false, 60).unwrap();
1764        assert_eq!(result, None);
1765    }
1766}