1mod helpers;
2mod session;
3
4use anyhow::Result;
5use edda_ledger::Ledger;
6use std::collections::{BTreeMap, HashSet};
7
8use crate::snapshot::build_branch_snapshot;
9use crate::types::*;
10
11use helpers::cmd_base_key;
12use session::render_session_history;
13
14pub fn render_context(ledger: &Ledger, branch: &str, opt: DeriveOptions) -> Result<String> {
15 let snap = build_branch_snapshot(ledger, branch)?;
16 let n = opt.depth.max(1);
17
18 let commits: Vec<_> = snap.commits.iter().rev().take(n).collect::<Vec<_>>();
19 let commits: Vec<_> = commits.into_iter().rev().collect();
20
21 let sig_cutoff = {
23 let now = time::OffsetDateTime::now_utc();
24 let cutoff = now - time::Duration::hours(2);
25 cutoff
26 .format(&time::format_description::well_known::Rfc3339)
27 .unwrap_or_default()
28 };
29 let recent_sigs: Vec<_> = snap
30 .signals
31 .iter()
32 .filter(|s| s.ts.as_str() >= sig_cutoff.as_str())
33 .collect();
34 let sigs: Vec<_> = recent_sigs
35 .iter()
36 .rev()
37 .take(n)
38 .copied()
39 .collect::<Vec<_>>();
40 let sigs: Vec<_> = sigs.into_iter().rev().collect();
41
42 let head = ledger.head_branch().unwrap_or_else(|_| "main".to_string());
43
44 let mut out = String::new();
45 out.push_str("# CONTEXT SNAPSHOT\n\n");
46
47 out.push_str("## Project (main)\n");
48 out.push_str(&format!("- head: {head}\n"));
49 out.push_str(&format!("- branch: {}\n", snap.branch));
50 if let Some(c) = &snap.last_commit {
51 out.push_str(&format!(
52 "- uncommitted_events: {}\n",
53 snap.uncommitted_events
54 ));
55 out.push_str(&format!(
56 "- last_commit: {} {} \"{}\"\n",
57 c.ts, c.event_id, c.title
58 ));
59 } else if snap.uncommitted_events > 0 {
60 out.push_str(&format!("- events: {}\n", snap.uncommitted_events));
62 }
63 if !snap.session_digests.is_empty() {
65 let count = snap.session_digests.len();
66 let dates: Vec<&str> = snap
67 .session_digests
68 .iter()
69 .filter_map(|d| d.ts.get(..10))
70 .collect();
71 if count == 1 {
72 if let Some(date) = dates.first() {
73 out.push_str(&format!("- sessions: 1 ({date})\n"));
74 }
75 } else if let (Some(oldest), Some(newest)) = (dates.first(), dates.last()) {
76 out.push_str(&format!("- sessions: {count} ({oldest} — {newest})\n"));
77 }
78 }
79 out.push('\n');
80
81 out.push_str("## Branch\n");
82 out.push_str(&format!("- name: {}\n\n", snap.branch));
83
84 let session_history = render_session_history(&snap.session_digests);
86 if !session_history.is_empty() {
87 out.push_str(&session_history);
88 }
89
90 out.push_str(&format!("## Recent Commits (last {n})\n"));
91 if commits.is_empty() {
92 out.push_str("- (none)\n\n");
93 } else {
94 for (i, c) in commits.iter().enumerate() {
95 out.push_str(&format!(
96 "{}. {} {} ({})\n",
97 i + 1,
98 c.ts,
99 c.title,
100 c.event_id
101 ));
102 out.push_str(&format!(
103 " - contribution: {}\n",
104 if c.contribution.is_empty() {
105 "(empty)"
106 } else {
107 &c.contribution
108 }
109 ));
110 if c.evidence_lines.is_empty() {
111 out.push_str(" - evidence: (none)\n");
112 } else {
113 out.push_str(&format!(" - evidence: {}\n", c.evidence_lines.join(", ")));
114 }
115 }
116 out.push('\n');
117 }
118
119 let merge_list: Vec<_> = snap.merges.iter().rev().take(n).collect::<Vec<_>>();
120 let merge_list: Vec<_> = merge_list.into_iter().rev().collect();
121
122 out.push_str(&format!("## Recent Merges (last {n})\n"));
123 if merge_list.is_empty() {
124 out.push_str("- (none)\n\n");
125 } else {
126 for m in merge_list {
127 out.push_str(&format!(
128 "- {} {} {}->{} adopted={} reason=\"{}\"\n",
129 m.ts,
130 m.event_id,
131 m.src,
132 m.dst,
133 m.adopted_commits.len(),
134 m.reason
135 ));
136 }
137 out.push('\n');
138 }
139
140 let all_decisions: Vec<_> = snap
143 .signals
144 .iter()
145 .filter(|s| matches!(s.kind, SignalKind::NoteDecision))
146 .collect();
147 let superseded: HashSet<&str> = all_decisions
148 .iter()
149 .filter_map(|d| d.supersedes.as_deref())
150 .collect();
151 let active_decisions: Vec<_> = all_decisions
152 .iter()
153 .filter(|d| !superseded.contains(d.event_id.as_str()))
154 .rev()
155 .take(n.max(5))
156 .copied()
157 .collect::<Vec<_>>();
158 let active_decisions: Vec<_> = active_decisions.into_iter().rev().collect();
159
160 if !active_decisions.is_empty() {
161 out.push_str(&format!(
166 "## Decisions (last {} — recorded; see the Ratified/Unratified pack for binding status)\n",
167 active_decisions.len()
168 ));
169 for d in &active_decisions {
170 out.push_str(&format!("- {} ({})\n", d.text, d.event_id));
171 }
172 out.push('\n');
173 }
174
175 out.push_str(&format!("## Recent Signals (last {n})\n"));
176 let non_decision_sigs: Vec<_> = sigs
178 .iter()
179 .filter(|s| !matches!(s.kind, SignalKind::NoteDecision))
180 .collect();
181 if non_decision_sigs.is_empty() {
182 out.push_str("- (none)\n\n");
183 } else {
184 let mut cmd_groups: BTreeMap<String, Vec<&SignalEntry>> = BTreeMap::new();
186 let mut todos: Vec<&SignalEntry> = Vec::new();
187
188 for s in &non_decision_sigs {
189 match s.kind {
190 SignalKind::NoteTodo => todos.push(s),
191 SignalKind::CmdFail => {
192 let key = cmd_base_key(&s.text);
193 cmd_groups.entry(key).or_default().push(s);
194 }
195 SignalKind::NoteDecision => {} }
197 }
198
199 for s in &todos {
200 out.push_str(&format!("- NOTE(todo): {} ({})\n", s.text, s.event_id));
201 }
202 for (base, group) in &cmd_groups {
203 if group.len() == 1 {
204 out.push_str(&format!(
205 "- CMD fail: {} ({})\n",
206 group[0].text, group[0].event_id
207 ));
208 } else {
209 out.push_str(&format!("- CMD fail: {} ({}x)\n", base, group.len(),));
210 }
211 }
212 out.push('\n');
213 }
214
215 out.push_str("## How to cite evidence\n");
216 out.push_str("- Use event_id to locate raw trace in .edda/ledger/events.jsonl\n");
217 out.push_str("- Use blob:sha256:* to open stdout/stderr artifacts in .edda/ledger/blobs/\n");
218
219 Ok(out)
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use crate::test_support::setup_workspace;
226 use edda_core::event::{
227 new_cmd_event, new_commit_event, new_note_event, CmdEventParams, CommitEventParams,
228 };
229
230 use super::session::digest_helpers::*;
231
232 #[test]
233 fn render_context_includes_commits_and_signals() {
234 let (tmp, ledger) = setup_workspace();
235
236 let todo_tags = vec!["todo".to_string()];
238 let note = new_note_event("main", None, "user", "fix the bug", &todo_tags).unwrap();
239 ledger.append_event(¬e).unwrap();
240
241 let mut params = CommitEventParams {
243 branch: "main",
244 parent_hash: None,
245 title: "implement feature X",
246 purpose: Some("deliver value"),
247 prev_summary: "",
248 contribution: "new feature",
249 evidence: vec![],
250 labels: vec![],
251 };
252 let commit = new_commit_event(&mut params).unwrap();
253 ledger.append_event(&commit).unwrap();
254
255 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
256
257 assert!(ctx.contains("CONTEXT SNAPSHOT"));
258 assert!(ctx.contains("main"));
259 assert!(ctx.contains("implement feature X"));
260
261 let _ = std::fs::remove_dir_all(&tmp);
262 }
263
264 #[test]
265 fn session_digest_surfaced_in_render_context() {
266 let (tmp, ledger) = setup_workspace();
267
268 let digest = make_digest_note(
270 "main",
271 "sess-abc1",
272 15,
273 &["/src/main.rs", "/src/lib.rs"],
274 &["fix: UTF-8 truncation", "feat: add digest"],
275 &["cargo check -p edda-mcp"],
276 45,
277 );
278 ledger.append_event(&digest).unwrap();
279
280 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
281
282 assert!(
284 ctx.contains("## Session History"),
285 "missing Session History section in:\n{ctx}"
286 );
287 assert!(ctx.contains("sess-abc"), "missing session id in:\n{ctx}");
288 assert!(ctx.contains("45 min"), "missing duration in:\n{ctx}");
289 assert!(
290 ctx.contains("15 tool calls"),
291 "missing tool calls in:\n{ctx}"
292 );
293 assert!(ctx.contains("main.rs"), "missing file name in:\n{ctx}");
294 assert!(ctx.contains("lib.rs"), "missing file name in:\n{ctx}");
295 assert!(
296 ctx.contains("fix: UTF-8 truncation"),
297 "missing commit msg in:\n{ctx}"
298 );
299 assert!(
300 ctx.contains("cargo check -p edda-mcp"),
301 "missing failed cmd in:\n{ctx}"
302 );
303
304 let _ = std::fs::remove_dir_all(&tmp);
305 }
306
307 #[test]
308 fn no_digests_no_section() {
309 let (tmp, ledger) = setup_workspace();
310
311 let note = new_note_event("main", None, "user", "hello", &[]).unwrap();
313 ledger.append_event(¬e).unwrap();
314
315 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
316
317 assert!(
318 !ctx.contains("Session History"),
319 "should not show Session History when none exist"
320 );
321
322 let _ = std::fs::remove_dir_all(&tmp);
323 }
324
325 #[test]
326 fn render_context_no_commits_shows_events_not_uncommitted() {
327 let (tmp, ledger) = setup_workspace();
328
329 let n1 = new_note_event("main", None, "user", "note 1", &[]).unwrap();
331 let n2 = new_note_event("main", None, "user", "note 2", &[]).unwrap();
332 ledger.append_event(&n1).unwrap();
333 ledger.append_event(&n2).unwrap();
334
335 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
336
337 assert!(
339 ctx.contains("- events: 2"),
340 "expected '- events: 2' in:\n{ctx}"
341 );
342 assert!(
343 !ctx.contains("uncommitted_events"),
344 "should not contain 'uncommitted_events'"
345 );
346 assert!(
347 !ctx.contains("last_commit: (none)"),
348 "should not contain 'last_commit: (none)'"
349 );
350
351 let _ = std::fs::remove_dir_all(&tmp);
352 }
353
354 #[test]
355 fn signals_aggregate_repeated_cmd_fails() {
356 let (tmp, ledger) = setup_workspace();
357
358 for _ in 0..3 {
360 let argv = vec![
361 "cargo".to_string(),
362 "check".to_string(),
363 "-p".to_string(),
364 "edda-mcp".to_string(),
365 ];
366 let cmd = new_cmd_event(&CmdEventParams {
367 branch: "main",
368 parent_hash: None,
369 argv: &argv,
370 cwd: ".",
371 exit_code: 1,
372 duration_ms: 500,
373 stdout_blob: "",
374 stderr_blob: "",
375 })
376 .unwrap();
377 ledger.append_event(&cmd).unwrap();
378 }
379 let argv2 = vec!["cargo".to_string(), "test".to_string()];
380 let cmd2 = new_cmd_event(&CmdEventParams {
381 branch: "main",
382 parent_hash: None,
383 argv: &argv2,
384 cwd: ".",
385 exit_code: 1,
386 duration_ms: 200,
387 stdout_blob: "",
388 stderr_blob: "",
389 })
390 .unwrap();
391 ledger.append_event(&cmd2).unwrap();
392
393 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
394
395 assert!(
397 ctx.contains("cargo check (3x)"),
398 "expected aggregated 'cargo check (3x)' in:\n{ctx}"
399 );
400 assert!(
402 ctx.contains("cargo test (exit=1)"),
403 "expected individual 'cargo test' in:\n{ctx}"
404 );
405
406 let _ = std::fs::remove_dir_all(&tmp);
407 }
408
409 #[test]
410 fn signals_single_cmd_not_aggregated() {
411 let (tmp, ledger) = setup_workspace();
412
413 let argv = vec!["npm".to_string(), "install".to_string()];
414 let cmd = new_cmd_event(&CmdEventParams {
415 branch: "main",
416 parent_hash: None,
417 argv: &argv,
418 cwd: ".",
419 exit_code: 127,
420 duration_ms: 100,
421 stdout_blob: "",
422 stderr_blob: "",
423 })
424 .unwrap();
425 ledger.append_event(&cmd).unwrap();
426
427 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
428
429 assert!(
431 ctx.contains("CMD fail: npm install (exit=127)"),
432 "expected individual signal in:\n{ctx}"
433 );
434 assert!(
435 !ctx.contains("(1x)"),
436 "should not show count for single failure"
437 );
438
439 let _ = std::fs::remove_dir_all(&tmp);
440 }
441
442 #[test]
443 fn decision_notes_rendered_in_own_section() {
444 let (tmp, ledger) = setup_workspace();
445
446 let tags = vec!["decision".to_string()];
447 let d1 = new_note_event(
448 "main",
449 None,
450 "user",
451 "Use PostgreSQL for concurrent writes",
452 &tags,
453 )
454 .unwrap();
455 let d2 = new_note_event(
456 "main",
457 None,
458 "user",
459 "REST over GraphQL for simplicity",
460 &tags,
461 )
462 .unwrap();
463 ledger.append_event(&d1).unwrap();
464 ledger.append_event(&d2).unwrap();
465
466 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
467
468 assert!(
470 ctx.contains("## Decisions"),
471 "missing Decisions section in:\n{ctx}"
472 );
473 assert!(
474 ctx.contains("Use PostgreSQL"),
475 "missing decision text in:\n{ctx}"
476 );
477 assert!(
478 ctx.contains("REST over GraphQL"),
479 "missing decision text in:\n{ctx}"
480 );
481
482 let signals_section = ctx.split("## Recent Signals").nth(1).unwrap_or("");
484 assert!(
485 !signals_section.contains("PostgreSQL"),
486 "decision leaked into Signals in:\n{ctx}"
487 );
488
489 let dec_pos = ctx.find("## Decisions").unwrap();
491 let sig_pos = ctx.find("## Recent Signals").unwrap();
492 assert!(dec_pos < sig_pos, "Decisions should come before Signals");
493
494 let _ = std::fs::remove_dir_all(&tmp);
495 }
496
497 #[test]
498 fn decision_notes_no_time_cutoff() {
499 let (tmp, ledger) = setup_workspace();
500
501 use edda_core::event::finalize_event;
503 use edda_core::types::SCHEMA_VERSION;
504
505 let payload = serde_json::json!({
506 "role": "user",
507 "text": "JWT for auth tokens",
508 "tags": ["decision"]
509 });
510 let mut event = edda_core::Event {
511 event_id: "evt_old_decision".to_string(),
512 ts: "2020-01-01T00:00:00Z".to_string(), event_type: "note".to_string(),
514 branch: "main".to_string(),
515 parent_hash: None,
516 hash: String::new(),
517 payload,
518 refs: Default::default(),
519 schema_version: SCHEMA_VERSION,
520 digests: Vec::new(),
521 event_family: None,
522 event_level: None,
523 };
524 finalize_event(&mut event).unwrap();
525 ledger.append_event(&event).unwrap();
526
527 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
528
529 assert!(
531 ctx.contains("JWT for auth tokens"),
532 "old decision should survive cutoff in:\n{ctx}"
533 );
534 assert!(
535 ctx.contains("## Decisions"),
536 "Decisions section missing in:\n{ctx}"
537 );
538
539 let _ = std::fs::remove_dir_all(&tmp);
540 }
541
542 #[test]
543 fn no_decisions_no_section() {
544 let (tmp, ledger) = setup_workspace();
545
546 let tags = vec!["todo".to_string()];
548 let note = new_note_event("main", None, "user", "fix bug", &tags).unwrap();
549 ledger.append_event(¬e).unwrap();
550
551 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
552
553 assert!(
554 !ctx.contains("## Decisions"),
555 "should not have Decisions section when none exist"
556 );
557
558 let _ = std::fs::remove_dir_all(&tmp);
559 }
560
561 #[test]
562 fn superseded_decision_hidden_in_context() {
563 use edda_core::event::finalize_event;
564 use edda_core::types::{Provenance, SCHEMA_VERSION};
565
566 let (tmp, ledger) = setup_workspace();
567
568 let tags = vec!["decision".to_string()];
570 let d1 = new_note_event("main", None, "system", "db: mysql", &tags).unwrap();
571 let d1_id = d1.event_id.clone();
572 ledger.append_event(&d1).unwrap();
573
574 let payload = serde_json::json!({
576 "role": "system",
577 "text": "db: postgres — need JSONB",
578 "tags": ["decision"],
579 "decision": {"key": "db", "value": "postgres", "reason": "need JSONB"}
580 });
581 let mut d2 = edda_core::Event {
582 event_id: "evt_d2".to_string(),
583 ts: "2026-02-17T12:00:00Z".to_string(),
584 event_type: "note".to_string(),
585 branch: "main".to_string(),
586 parent_hash: None,
587 hash: String::new(),
588 payload,
589 refs: edda_core::types::Refs {
590 provenance: vec![Provenance {
591 target: d1_id.clone(),
592 rel: "supersedes".to_string(),
593 note: Some("key 'db' re-decided".to_string()),
594 }],
595 ..Default::default()
596 },
597 schema_version: SCHEMA_VERSION,
598 digests: Vec::new(),
599 event_family: None,
600 event_level: None,
601 };
602 finalize_event(&mut d2).unwrap();
603 ledger.append_event(&d2).unwrap();
604
605 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
606
607 assert!(
609 ctx.contains("postgres"),
610 "active decision missing in:\n{ctx}"
611 );
612 assert!(
613 !ctx.contains("mysql"),
614 "superseded decision should be hidden in:\n{ctx}"
615 );
616
617 let _ = std::fs::remove_dir_all(&tmp);
618 }
619
620 #[test]
621 fn supersession_chain_resolves() {
622 use edda_core::event::finalize_event;
623 use edda_core::types::Provenance;
624
625 let (tmp, ledger) = setup_workspace();
626 let tags = vec!["decision".to_string()];
627
628 let a = new_note_event("main", None, "system", "db: sqlite", &tags).unwrap();
630 let a_id = a.event_id.clone();
631 ledger.append_event(&a).unwrap();
632
633 let mut b = new_note_event("main", None, "system", "db: mysql", &tags).unwrap();
635 let b_id = b.event_id.clone();
636 b.refs.provenance.push(Provenance {
637 target: a_id,
638 rel: "supersedes".to_string(),
639 note: None,
640 });
641 finalize_event(&mut b).unwrap();
642 ledger.append_event(&b).unwrap();
643
644 let mut c = new_note_event("main", None, "system", "db: postgres", &tags).unwrap();
646 c.refs.provenance.push(Provenance {
647 target: b_id,
648 rel: "supersedes".to_string(),
649 note: None,
650 });
651 finalize_event(&mut c).unwrap();
652 ledger.append_event(&c).unwrap();
653
654 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
655
656 assert!(
657 ctx.contains("postgres"),
658 "final decision missing in:\n{ctx}"
659 );
660 assert!(
661 !ctx.contains("mysql"),
662 "superseded B should be hidden in:\n{ctx}"
663 );
664 assert!(
665 !ctx.contains("sqlite"),
666 "superseded A should be hidden in:\n{ctx}"
667 );
668
669 let _ = std::fs::remove_dir_all(&tmp);
670 }
671
672 #[test]
673 fn old_decision_without_structured_fields_still_renders() {
674 let (tmp, ledger) = setup_workspace();
675
676 let tags = vec!["decision".to_string()];
678 let d = new_note_event(
679 "main",
680 None,
681 "system",
682 "orm: sqlx — compile-time checks",
683 &tags,
684 )
685 .unwrap();
686 ledger.append_event(&d).unwrap();
687
688 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
689
690 assert!(
691 ctx.contains("orm: sqlx"),
692 "old-format decision should render in:\n{ctx}"
693 );
694 assert!(
695 ctx.contains("## Decisions"),
696 "Decisions section missing in:\n{ctx}"
697 );
698
699 let _ = std::fs::remove_dir_all(&tmp);
700 }
701
702 #[test]
703 fn decisions_decoupled_from_depth() {
704 let (tmp, ledger) = setup_workspace();
705
706 let tags = vec!["decision".to_string()];
708 for i in 1..=8 {
709 let d = new_note_event(
710 "main",
711 None,
712 "user",
713 &format!("Decision {i}: choice {i}"),
714 &tags,
715 )
716 .unwrap();
717 ledger.append_event(&d).unwrap();
718 }
719
720 let opts = DeriveOptions { depth: 1 };
722 let ctx = render_context(&ledger, "main", opts).unwrap();
723
724 assert!(
725 ctx.contains("## Decisions"),
726 "missing Decisions section in:\n{ctx}"
727 );
728 let decision_count = (1..=8)
730 .filter(|i| ctx.contains(&format!("Decision {i}: choice {i}")))
731 .count();
732 assert!(
733 decision_count >= 5,
734 "expected at least 5 decisions visible at depth=1, got {decision_count} in:\n{ctx}"
735 );
736
737 let _ = std::fs::remove_dir_all(&tmp);
738 }
739
740 #[test]
741 fn project_header_shows_session_count_single() {
742 let (tmp, ledger) = setup_workspace();
743
744 let d = make_digest_note_with_ts("main", "sess-001", "2026-02-17T10:00:00Z", &[], &[]);
745 ledger.append_event(&d).unwrap();
746
747 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
748
749 assert!(
750 ctx.contains("- sessions: 1 (2026-02-17)"),
751 "missing single-session header in:\n{ctx}"
752 );
753
754 let _ = std::fs::remove_dir_all(&tmp);
755 }
756
757 #[test]
758 fn project_header_shows_session_count_range() {
759 let (tmp, ledger) = setup_workspace();
760
761 let d1 = make_digest_note_with_ts("main", "sess-001", "2026-02-12T10:00:00Z", &[], &["c1"]);
762 let d2 = make_digest_note_with_ts("main", "sess-002", "2026-02-14T10:00:00Z", &[], &["c2"]);
763 let d3 = make_digest_note_with_ts("main", "sess-003", "2026-02-17T10:00:00Z", &[], &["c3"]);
764 ledger.append_event(&d1).unwrap();
765 ledger.append_event(&d2).unwrap();
766 ledger.append_event(&d3).unwrap();
767
768 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
769
770 assert!(
771 ctx.contains("- sessions: 3 (2026-02-12 — 2026-02-17)"),
772 "missing session range header in:\n{ctx}"
773 );
774
775 let _ = std::fs::remove_dir_all(&tmp);
776 }
777
778 #[test]
779 fn project_header_no_sessions_without_digests() {
780 let (tmp, ledger) = setup_workspace();
781
782 let note = new_note_event("main", None, "user", "hello", &[]).unwrap();
783 ledger.append_event(¬e).unwrap();
784
785 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
786
787 assert!(
788 !ctx.contains("- sessions:"),
789 "should not show sessions line without digests:\n{ctx}"
790 );
791
792 let _ = std::fs::remove_dir_all(&tmp);
793 }
794}