Skip to main content

git_paw/
summary.rs

1//! Session summary generation.
2//!
3//! Produces a human-readable Markdown summary of a completed supervisor
4//! session, sourcing data from [`BrokerState`] (per-agent records and the
5//! message log) and [`Session`] (project metadata). Written once at the end
6//! of the session to `.git-paw/session-summary.md`.
7
8use std::collections::HashMap;
9use std::fmt::Write as _;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, SystemTime};
13
14use crate::broker::{BrokerMessage, BrokerState};
15use crate::error::PawError;
16use crate::session::Session;
17
18/// Result of running a test command for a single agent.
19#[derive(Clone, Debug)]
20pub struct TestResult {
21    pub success: bool,
22    pub output: String,
23}
24
25/// Writes a Markdown session summary to `output_path`.
26///
27/// Sources per-agent details (status, modified files, exports, blocked time)
28/// from `state` and project metadata (name, start time) from `session`. The
29/// `merge_order` parameter provides the sequence in which feature branches
30/// were merged. The `test_results` parameter provides test execution results
31/// for each agent.
32///
33/// Overwrites any existing file at `output_path`. Returns
34/// [`PawError::SessionError`] if the write fails.
35pub fn write_session_summary<S: std::hash::BuildHasher>(
36    state: &BrokerState,
37    session: &Session,
38    merge_order: &[String],
39    test_results: &std::collections::HashMap<String, TestResult, S>,
40    output_path: &Path,
41) -> Result<(), PawError> {
42    let now = SystemTime::now();
43    let total_duration = now
44        .duration_since(session.created_at)
45        .unwrap_or(Duration::ZERO);
46
47    let inner = state.read();
48
49    // Map slugified branch -> cli for per-agent header lookup.
50    let cli_by_slug: HashMap<String, String> = session
51        .worktrees
52        .iter()
53        .map(|wt| {
54            (
55                crate::broker::messages::slugify_branch(&wt.branch),
56                wt.cli.clone(),
57            )
58        })
59        .collect();
60
61    let mut agent_ids: Vec<&String> = inner.agents.keys().collect();
62    agent_ids.sort();
63
64    let date = format_date(session.created_at);
65    let mut out = String::new();
66
67    let _ = writeln!(
68        out,
69        "# Session Summary \u{2014} {} \u{2014} {date}\n",
70        session.project_name
71    );
72
73    // Overview section
74    out.push_str("## Overview\n");
75    let _ = writeln!(out, "- **Duration:** {}", format_duration(total_duration));
76    let _ = writeln!(out, "- **Agents:** {}", agent_ids.len());
77    let merge_list = if merge_order.is_empty() {
78        "(none)".to_string()
79    } else {
80        merge_order.join(", ")
81    };
82    let _ = writeln!(out, "- **Merge order:** {merge_list}\n");
83
84    // Per-agent section
85    out.push_str("## Agents\n\n");
86    for agent_id in &agent_ids {
87        let record = &inner.agents[*agent_id];
88        let cli = cli_by_slug.get(*agent_id).map_or("unknown", String::as_str);
89
90        let _ = writeln!(out, "### {agent_id} ({cli})");
91        let _ = writeln!(out, "- **Status:** {}", record.status);
92
93        let (files, exports) = last_artifact_fields(&inner.message_log, agent_id);
94        let _ = writeln!(
95            out,
96            "- **Files modified:** {}",
97            format_list(files.as_deref())
98        );
99        let _ = writeln!(out, "- **Exports:** {}", format_list(exports.as_deref()));
100
101        let blocked = estimated_blocked_time(&inner.message_log, agent_id);
102        let blocked_str = if blocked.is_zero() {
103            "none".to_string()
104        } else {
105            format_duration(blocked)
106        };
107        let _ = writeln!(out, "- **Estimated blocked time:** {blocked_str}\n");
108    }
109
110    // Totals section
111    out.push_str("## Totals\n");
112    let _ = writeln!(out, "- Total agents: {}", agent_ids.len());
113    let _ = writeln!(out, "- Total time: {}", format_duration(total_duration));
114
115    // Test results section
116    if !test_results.is_empty() {
117        out.push_str("\n## Test Results\n");
118        for (branch, result) in test_results {
119            let status = if result.success {
120                "✓ PASS"
121            } else {
122                "✗ FAIL"
123            };
124            let _ = writeln!(out, "- **{branch}**: {status}");
125            if !result.output.is_empty() {
126                let _ = writeln!(out, "  ```\n{}\n  ```", result.output);
127            }
128        }
129    }
130
131    drop(inner);
132
133    fs::write(output_path, out).map_err(|e| {
134        PawError::SessionError(format!(
135            "failed to write session summary to {}: {e}",
136            output_path.display()
137        ))
138    })
139}
140
141/// Convenience wrapper that writes the session summary to a timestamped
142/// file under `<repo_root>/.git-paw/sessions/<UTC-timestamp>.md`, creating
143/// `.git-paw/sessions/` if it does not already exist.
144///
145/// Each supervisor run produces a fresh file so multiple sessions for the
146/// same repo can coexist on disk without overwriting each other. The
147/// timestamp format is `YYYY-MM-DDTHH-MM-SSZ` (filesystem-safe — colons in
148/// ISO 8601 are replaced with hyphens) and is taken from the system clock
149/// at write time.
150///
151/// Returns the path of the file that was written so callers can log it.
152pub fn write_supervisor_summary<S: std::hash::BuildHasher>(
153    state: &BrokerState,
154    session: &Session,
155    merge_order: &[String],
156    test_results: &std::collections::HashMap<String, TestResult, S>,
157    repo_root: &Path,
158) -> Result<PathBuf, PawError> {
159    let dir = repo_root.join(".git-paw").join("sessions");
160    fs::create_dir_all(&dir).map_err(|e| {
161        PawError::SessionError(format!(
162            "failed to create {} for session summary: {e}",
163            dir.display()
164        ))
165    })?;
166    let filename = format!("{}.md", filesystem_safe_utc_timestamp());
167    let path = dir.join(&filename);
168    write_session_summary(state, session, merge_order, test_results, &path)?;
169    Ok(path)
170}
171
172/// Returns the current UTC time in `YYYY-MM-DDTHH-MM-SSZ` format. Colons
173/// in canonical ISO 8601 are replaced with hyphens so the string is safe
174/// to use as a filename on every platform git-paw supports (macOS / Linux
175/// / WSL).
176fn filesystem_safe_utc_timestamp() -> String {
177    use chrono::{SecondsFormat, Utc};
178    let iso = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
179    iso.replace(':', "-")
180}
181
182/// Returns `(modified_files, exports)` from the most recent `agent.artifact`
183/// message published by `agent_id`. Returns `(None, None)` if no artifact
184/// message exists for that agent.
185fn last_artifact_fields(
186    log: &[(u64, SystemTime, BrokerMessage)],
187    agent_id: &str,
188) -> (Option<Vec<String>>, Option<Vec<String>>) {
189    for (_seq, _ts, msg) in log.iter().rev() {
190        if let BrokerMessage::Artifact {
191            agent_id: id,
192            payload,
193        } = msg
194            && id == agent_id
195        {
196            return (
197                Some(payload.modified_files.clone()),
198                Some(payload.exports.clone()),
199            );
200        }
201    }
202    (None, None)
203}
204
205/// Sums the time gaps between each `agent.blocked` message and the next
206/// `agent.status` or `agent.artifact` message from the same agent.
207fn estimated_blocked_time(log: &[(u64, SystemTime, BrokerMessage)], agent_id: &str) -> Duration {
208    let mut total = Duration::ZERO;
209    let mut blocked_at: Option<SystemTime> = None;
210
211    for (_seq, ts, msg) in log {
212        if msg.agent_id() != agent_id {
213            continue;
214        }
215        match msg {
216            BrokerMessage::Blocked { .. } if blocked_at.is_none() => {
217                blocked_at = Some(*ts);
218            }
219            BrokerMessage::Status { .. } | BrokerMessage::Artifact { .. } => {
220                if let Some(start) = blocked_at.take()
221                    && let Ok(gap) = ts.duration_since(start)
222                {
223                    total += gap;
224                }
225            }
226            _ => {}
227        }
228    }
229    total
230}
231
232/// Formats a list as a comma-separated string, or `(none)` if empty/missing.
233fn format_list(items: Option<&[String]>) -> String {
234    match items {
235        Some(list) if !list.is_empty() => list.join(", "),
236        _ => "(none)".to_string(),
237    }
238}
239
240/// Formats a duration as `XmYs` style (e.g. `2m 13s`, `45s`, `1h 5m`).
241fn format_duration(d: Duration) -> String {
242    let secs = d.as_secs();
243    let h = secs / 3600;
244    let m = (secs % 3600) / 60;
245    let s = secs % 60;
246    if h > 0 {
247        format!("{h}h {m}m")
248    } else if m > 0 {
249        format!("{m}m {s}s")
250    } else {
251        format!("{s}s")
252    }
253}
254
255/// Formats a `SystemTime` as `YYYY-MM-DD` (UTC) using the same civil-date
256/// algorithm as `session.rs`.
257fn format_date(time: SystemTime) -> String {
258    let secs = time
259        .duration_since(SystemTime::UNIX_EPOCH)
260        .map_or(0, |d| d.as_secs());
261
262    // Algorithm from Howard Hinnant (matches session.rs).
263    #[allow(clippy::cast_possible_wrap)]
264    let mut days = (secs / 86400) as i64;
265    days += 719_468;
266    let era = days.div_euclid(146_097);
267    let doe = days - era * 146_097;
268    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
269    let y = yoe + era * 400;
270    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
271    let mp = (5 * doy + 2) / 153;
272    let d = doy - (153 * mp + 2) / 5 + 1;
273    let m = if mp < 10 { mp + 3 } else { mp - 9 };
274    let y = if m <= 2 { y + 1 } else { y };
275    format!("{y:04}-{m:02}-{d:02}")
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::broker::messages::{ArtifactPayload, StatusPayload};
282    use crate::session::{SessionStatus, WorktreeEntry};
283    use std::path::PathBuf;
284    use std::time::UNIX_EPOCH;
285    use tempfile::TempDir;
286
287    fn sample_session() -> Session {
288        Session {
289            session_name: "paw-demo".to_string(),
290            repo_path: PathBuf::from("/tmp/demo"),
291            project_name: "demo".to_string(),
292            // Fixed unix epoch (2024-03-23 13:20:00 UTC); seconds is the
293            // canonical unit for unix timestamps so the literal stays
294            // human-readable as a date.
295            #[allow(clippy::duration_suboptimal_units)]
296            created_at: UNIX_EPOCH + Duration::from_secs(1_711_200_000),
297            status: SessionStatus::Active,
298            worktrees: vec![
299                WorktreeEntry {
300                    branch: "feat/config".to_string(),
301                    worktree_path: PathBuf::from("/tmp/demo-feat-config"),
302                    cli: "claude".to_string(),
303                    branch_created: true,
304                },
305                WorktreeEntry {
306                    branch: "feat/errors".to_string(),
307                    worktree_path: PathBuf::from("/tmp/demo-feat-errors"),
308                    cli: "gemini".to_string(),
309                    branch_created: true,
310                },
311            ],
312            broker_port: None,
313            broker_bind: None,
314            broker_log_path: None,
315        }
316    }
317
318    fn populate_state(state: &BrokerState, agent_id: &str, status: &str) {
319        use crate::broker::AgentRecord;
320        use std::time::Instant;
321
322        let mut inner = state.write();
323        inner.agents.insert(
324            agent_id.to_string(),
325            AgentRecord {
326                agent_id: agent_id.to_string(),
327                status: status.to_string(),
328                last_seen: Instant::now(),
329                last_message: None,
330            },
331        );
332    }
333
334    fn push_log(state: &BrokerState, seq: u64, ts: SystemTime, msg: BrokerMessage) {
335        let mut inner = state.write();
336        inner.message_log.push((seq, ts, msg));
337    }
338
339    #[test]
340    fn writes_file_at_specified_path() {
341        let tmp = TempDir::new().unwrap();
342        let path = tmp.path().join("session-summary.md");
343
344        let state = BrokerState::new(None);
345        populate_state(&state, "feat-config", "verified");
346        populate_state(&state, "feat-errors", "verified");
347
348        let session = sample_session();
349        write_session_summary(
350            &state,
351            &session,
352            &[],
353            &std::collections::HashMap::new(),
354            &path,
355        )
356        .unwrap();
357        assert!(path.exists());
358    }
359
360    #[test]
361    fn output_contains_project_name() {
362        let tmp = TempDir::new().unwrap();
363        let path = tmp.path().join("s.md");
364        let state = BrokerState::new(None);
365        let session = sample_session();
366        write_session_summary(
367            &state,
368            &session,
369            &[],
370            &std::collections::HashMap::new(),
371            &path,
372        )
373        .unwrap();
374        let content = fs::read_to_string(&path).unwrap();
375        assert!(content.contains("demo"));
376    }
377
378    #[test]
379    fn output_contains_agent_count() {
380        let tmp = TempDir::new().unwrap();
381        let path = tmp.path().join("s.md");
382
383        let state = BrokerState::new(None);
384        populate_state(&state, "a", "verified");
385        populate_state(&state, "b", "verified");
386        populate_state(&state, "c", "verified");
387
388        let session = sample_session();
389        write_session_summary(
390            &state,
391            &session,
392            &[],
393            &std::collections::HashMap::new(),
394            &path,
395        )
396        .unwrap();
397        let content = fs::read_to_string(&path).unwrap();
398        assert!(content.contains("**Agents:** 3"));
399    }
400
401    #[test]
402    fn output_lists_merge_order_in_sequence() {
403        let tmp = TempDir::new().unwrap();
404        let path = tmp.path().join("s.md");
405        let state = BrokerState::new(None);
406        let session = sample_session();
407        let merge_order = vec![
408            "feat-errors".to_string(),
409            "feat-config".to_string(),
410            "feat-detect".to_string(),
411        ];
412        write_session_summary(
413            &state,
414            &session,
415            &merge_order,
416            &std::collections::HashMap::new(),
417            &path,
418        )
419        .unwrap();
420        let content = fs::read_to_string(&path).unwrap();
421        let line = content
422            .lines()
423            .find(|l| l.contains("Merge order"))
424            .expect("merge order line");
425        let errors_pos = line.find("feat-errors").unwrap();
426        let config_pos = line.find("feat-config").unwrap();
427        let detect_pos = line.find("feat-detect").unwrap();
428        assert!(errors_pos < config_pos);
429        assert!(config_pos < detect_pos);
430    }
431
432    #[test]
433    fn agent_section_shows_none_when_no_artifact() {
434        let tmp = TempDir::new().unwrap();
435        let path = tmp.path().join("s.md");
436        let state = BrokerState::new(None);
437        populate_state(&state, "feat-config", "working");
438        let session = sample_session();
439        write_session_summary(
440            &state,
441            &session,
442            &[],
443            &std::collections::HashMap::new(),
444            &path,
445        )
446        .unwrap();
447        let content = fs::read_to_string(&path).unwrap();
448        assert!(content.contains("**Files modified:** (none)"));
449        assert!(content.contains("**Exports:** (none)"));
450    }
451
452    #[test]
453    fn agent_section_shows_modified_files_from_last_artifact() {
454        let tmp = TempDir::new().unwrap();
455        let path = tmp.path().join("s.md");
456        let state = BrokerState::new(None);
457        populate_state(&state, "feat-config", "verified");
458
459        push_log(
460            &state,
461            1,
462            SystemTime::now(),
463            BrokerMessage::Artifact {
464                agent_id: "feat-config".to_string(),
465                payload: ArtifactPayload {
466                    status: "done".to_string(),
467                    exports: vec!["SupervisorConfig".to_string()],
468                    modified_files: vec!["src/config.rs".to_string()],
469                },
470            },
471        );
472
473        let session = sample_session();
474        write_session_summary(
475            &state,
476            &session,
477            &[],
478            &std::collections::HashMap::new(),
479            &path,
480        )
481        .unwrap();
482        let content = fs::read_to_string(&path).unwrap();
483        assert!(content.contains("src/config.rs"));
484        assert!(content.contains("SupervisorConfig"));
485    }
486
487    #[test]
488    fn last_artifact_wins_when_multiple_present() {
489        let tmp = TempDir::new().unwrap();
490        let path = tmp.path().join("s.md");
491        let state = BrokerState::new(None);
492        populate_state(&state, "feat-config", "verified");
493
494        push_log(
495            &state,
496            1,
497            SystemTime::now(),
498            BrokerMessage::Artifact {
499                agent_id: "feat-config".to_string(),
500                payload: ArtifactPayload {
501                    status: "in-progress".to_string(),
502                    exports: vec![],
503                    modified_files: vec!["old.rs".to_string()],
504                },
505            },
506        );
507        push_log(
508            &state,
509            2,
510            SystemTime::now(),
511            BrokerMessage::Artifact {
512                agent_id: "feat-config".to_string(),
513                payload: ArtifactPayload {
514                    status: "done".to_string(),
515                    exports: vec![],
516                    modified_files: vec!["new.rs".to_string()],
517                },
518            },
519        );
520
521        let session = sample_session();
522        write_session_summary(
523            &state,
524            &session,
525            &[],
526            &std::collections::HashMap::new(),
527            &path,
528        )
529        .unwrap();
530        let content = fs::read_to_string(&path).unwrap();
531        assert!(content.contains("new.rs"));
532        assert!(!content.contains("old.rs"));
533    }
534
535    #[test]
536    fn existing_file_is_overwritten() {
537        let tmp = TempDir::new().unwrap();
538        let path = tmp.path().join("s.md");
539        fs::write(&path, "old garbage content that should be replaced").unwrap();
540
541        let state = BrokerState::new(None);
542        let session = sample_session();
543        write_session_summary(
544            &state,
545            &session,
546            &[],
547            &std::collections::HashMap::new(),
548            &path,
549        )
550        .unwrap();
551
552        let content = fs::read_to_string(&path).unwrap();
553        assert!(!content.contains("garbage"));
554        assert!(content.contains("Session Summary"));
555    }
556
557    #[test]
558    fn write_to_invalid_path_returns_err() {
559        let state = BrokerState::new(None);
560        let session = sample_session();
561        let bad = Path::new("/nonexistent-dir-xyz/sub/session-summary.md");
562        let result = write_session_summary(
563            &state,
564            &session,
565            &[],
566            &std::collections::HashMap::new(),
567            bad,
568        );
569        assert!(result.is_err());
570    }
571
572    #[test]
573    fn unused_status_payload_compiles() {
574        // Sanity-check: StatusPayload is part of the public broker API used
575        // when computing blocked time gaps.
576        let _ = StatusPayload {
577            status: "working".to_string(),
578            modified_files: vec![],
579            message: None,
580        };
581    }
582
583    #[test]
584    fn blocked_time_sums_gap_to_next_status() {
585        let state = BrokerState::new(None);
586        populate_state(&state, "feat-config", "working");
587
588        let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
589        let t1 = t0 + Duration::from_secs(30);
590
591        push_log(
592            &state,
593            1,
594            t0,
595            BrokerMessage::Blocked {
596                agent_id: "feat-config".to_string(),
597                payload: crate::broker::messages::BlockedPayload {
598                    needs: "types".to_string(),
599                    from: "feat-errors".to_string(),
600                },
601            },
602        );
603        push_log(
604            &state,
605            2,
606            t1,
607            BrokerMessage::Status {
608                agent_id: "feat-config".to_string(),
609                payload: StatusPayload {
610                    status: "working".to_string(),
611                    modified_files: vec![],
612                    message: None,
613                },
614            },
615        );
616
617        let inner = state.read();
618        let blocked = estimated_blocked_time(&inner.message_log, "feat-config");
619        assert_eq!(blocked, Duration::from_secs(30));
620    }
621
622    #[test]
623    fn output_contains_totals_section() {
624        let tmp = TempDir::new().unwrap();
625        let path = tmp.path().join("s.md");
626        let state = BrokerState::new(None);
627        let session = sample_session();
628        write_session_summary(
629            &state,
630            &session,
631            &[],
632            &std::collections::HashMap::new(),
633            &path,
634        )
635        .unwrap();
636        let content = fs::read_to_string(&path).unwrap();
637        assert!(content.contains("## Totals"));
638    }
639
640    #[test]
641    fn write_supervisor_summary_creates_timestamped_file_under_sessions_dir() {
642        let tmp = TempDir::new().unwrap();
643        let repo_root = tmp.path();
644
645        let state = BrokerState::new(None);
646        populate_state(&state, "feat-config", "verified");
647        let session = sample_session();
648
649        let written = write_supervisor_summary(
650            &state,
651            &session,
652            &[],
653            &std::collections::HashMap::new(),
654            repo_root,
655        )
656        .unwrap();
657
658        // The returned path is under .git-paw/sessions/ and has a .md suffix.
659        assert!(
660            written.starts_with(repo_root.join(".git-paw").join("sessions")),
661            "summary written outside .git-paw/sessions/: {}",
662            written.display()
663        );
664        assert_eq!(written.extension().and_then(|s| s.to_str()), Some("md"));
665        assert!(written.exists(), "summary file does not exist on disk");
666
667        // Filename is the UTC timestamp; should look like 2026-05-07T12-34-56Z.md
668        let filename = written.file_name().unwrap().to_string_lossy().to_string();
669        assert!(
670            filename.ends_with("Z.md"),
671            "expected ISO-8601 UTC suffix, got {filename}"
672        );
673        assert!(
674            !filename.contains(':'),
675            "filename must not contain colons: {filename}"
676        );
677
678        let content = fs::read_to_string(&written).unwrap();
679        assert!(content.contains("# Session Summary"));
680        assert!(content.contains("demo"));
681    }
682
683    #[test]
684    fn write_supervisor_summary_creates_sessions_dir_when_missing() {
685        let tmp = TempDir::new().unwrap();
686        let repo_root = tmp.path();
687        assert!(!repo_root.join(".git-paw").exists());
688
689        let state = BrokerState::new(None);
690        let session = sample_session();
691        let written = write_supervisor_summary(
692            &state,
693            &session,
694            &[],
695            &std::collections::HashMap::new(),
696            repo_root,
697        )
698        .unwrap();
699
700        assert!(repo_root.join(".git-paw").is_dir());
701        assert!(repo_root.join(".git-paw").join("sessions").is_dir());
702        assert!(written.exists());
703    }
704
705    #[test]
706    fn write_supervisor_summary_two_sequential_calls_produce_distinct_files() {
707        // Multiple supervisor runs against the same repo must coexist on disk.
708        // The second-resolution timestamp guarantees we may see the same name
709        // if both writes occur in the same second; sleep one second between
710        // calls so we exercise the "two sessions, two files" contract.
711        let tmp = TempDir::new().unwrap();
712        let repo_root = tmp.path();
713        let state = BrokerState::new(None);
714        let session = sample_session();
715
716        let first = write_supervisor_summary(
717            &state,
718            &session,
719            &[],
720            &std::collections::HashMap::new(),
721            repo_root,
722        )
723        .unwrap();
724        std::thread::sleep(Duration::from_secs(1));
725        let second = write_supervisor_summary(
726            &state,
727            &session,
728            &[],
729            &std::collections::HashMap::new(),
730            repo_root,
731        )
732        .unwrap();
733
734        assert_ne!(
735            first,
736            second,
737            "back-to-back supervisor runs must produce distinct summary files; both wrote to {}",
738            first.display()
739        );
740        assert!(first.exists() && second.exists());
741    }
742
743    #[test]
744    fn format_duration_examples() {
745        assert_eq!(format_duration(Duration::from_secs(45)), "45s");
746        assert_eq!(format_duration(Duration::from_secs(125)), "2m 5s");
747        assert_eq!(format_duration(Duration::from_secs(3700)), "1h 1m");
748    }
749
750    /// When `test_results` contains entries for one or more branches, the
751    /// rendered Markdown must include a `## Test Results` section and one
752    /// row per entry showing the branch name and a pass/fail status. Output
753    /// from the test command must also be included verbatim.
754    #[test]
755    fn test_results_in_summary() {
756        let tmp = TempDir::new().unwrap();
757        let path = tmp.path().join("s.md");
758
759        let state = BrokerState::new(None);
760        populate_state(&state, "feat-config", "verified");
761        populate_state(&state, "feat-errors", "verified");
762        let session = sample_session();
763
764        let mut test_results: std::collections::HashMap<String, TestResult> =
765            std::collections::HashMap::new();
766        test_results.insert(
767            "feat-config".to_string(),
768            TestResult {
769                success: true,
770                output: "all 42 tests passed".to_string(),
771            },
772        );
773        test_results.insert(
774            "feat-errors".to_string(),
775            TestResult {
776                success: false,
777                output: "thread 'main' panicked: oh no".to_string(),
778            },
779        );
780
781        write_session_summary(&state, &session, &[], &test_results, &path).unwrap();
782        let content = fs::read_to_string(&path).unwrap();
783
784        // Section header.
785        assert!(
786            content.contains("## Test Results"),
787            "summary should include Test Results section; got:\n{content}"
788        );
789
790        // Each branch appears with the right status marker.
791        assert!(
792            content.contains("**feat-config**: \u{2713} PASS"),
793            "passing branch must render with check mark; got:\n{content}"
794        );
795        assert!(
796            content.contains("**feat-errors**: \u{2717} FAIL"),
797            "failing branch must render with cross mark; got:\n{content}"
798        );
799
800        // Test command output is included verbatim for each branch.
801        assert!(
802            content.contains("all 42 tests passed"),
803            "passing output must appear in summary; got:\n{content}"
804        );
805        assert!(
806            content.contains("thread 'main' panicked: oh no"),
807            "failing output must appear in summary; got:\n{content}"
808        );
809    }
810
811    /// An empty `test_results` map must skip the Test Results section
812    /// entirely — no header, no rows.
813    #[test]
814    fn test_results_section_omitted_when_empty() {
815        let tmp = TempDir::new().unwrap();
816        let path = tmp.path().join("s.md");
817        let state = BrokerState::new(None);
818        let session = sample_session();
819        write_session_summary(
820            &state,
821            &session,
822            &[],
823            &std::collections::HashMap::new(),
824            &path,
825        )
826        .unwrap();
827        let content = fs::read_to_string(&path).unwrap();
828        assert!(
829            !content.contains("## Test Results"),
830            "Test Results section must be absent when no results provided; got:\n{content}"
831        );
832    }
833}