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!("## Decisions (last {})\n", active_decisions.len()));
162 for d in &active_decisions {
163 out.push_str(&format!("- {} ({})\n", d.text, d.event_id));
164 }
165 out.push('\n');
166 }
167
168 out.push_str(&format!("## Recent Signals (last {n})\n"));
169 let non_decision_sigs: Vec<_> = sigs
171 .iter()
172 .filter(|s| !matches!(s.kind, SignalKind::NoteDecision))
173 .collect();
174 if non_decision_sigs.is_empty() {
175 out.push_str("- (none)\n\n");
176 } else {
177 let mut cmd_groups: BTreeMap<String, Vec<&SignalEntry>> = BTreeMap::new();
179 let mut todos: Vec<&SignalEntry> = Vec::new();
180
181 for s in &non_decision_sigs {
182 match s.kind {
183 SignalKind::NoteTodo => todos.push(s),
184 SignalKind::CmdFail => {
185 let key = cmd_base_key(&s.text);
186 cmd_groups.entry(key).or_default().push(s);
187 }
188 SignalKind::NoteDecision => {} }
190 }
191
192 for s in &todos {
193 out.push_str(&format!("- NOTE(todo): {} ({})\n", s.text, s.event_id));
194 }
195 for (base, group) in &cmd_groups {
196 if group.len() == 1 {
197 out.push_str(&format!(
198 "- CMD fail: {} ({})\n",
199 group[0].text, group[0].event_id
200 ));
201 } else {
202 out.push_str(&format!("- CMD fail: {} ({}x)\n", base, group.len(),));
203 }
204 }
205 out.push('\n');
206 }
207
208 out.push_str("## How to cite evidence\n");
209 out.push_str("- Use event_id to locate raw trace in .edda/ledger/events.jsonl\n");
210 out.push_str("- Use blob:sha256:* to open stdout/stderr artifacts in .edda/ledger/blobs/\n");
211
212 Ok(out)
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::test_support::setup_workspace;
219 use edda_core::event::{
220 new_cmd_event, new_commit_event, new_note_event, CmdEventParams, CommitEventParams,
221 };
222
223 use super::session::digest_helpers::*;
224
225 #[test]
226 fn render_context_includes_commits_and_signals() {
227 let (tmp, ledger) = setup_workspace();
228
229 let todo_tags = vec!["todo".to_string()];
231 let note = new_note_event("main", None, "user", "fix the bug", &todo_tags).unwrap();
232 ledger.append_event(¬e).unwrap();
233
234 let mut params = CommitEventParams {
236 branch: "main",
237 parent_hash: None,
238 title: "implement feature X",
239 purpose: Some("deliver value"),
240 prev_summary: "",
241 contribution: "new feature",
242 evidence: vec![],
243 labels: vec![],
244 };
245 let commit = new_commit_event(&mut params).unwrap();
246 ledger.append_event(&commit).unwrap();
247
248 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
249
250 assert!(ctx.contains("CONTEXT SNAPSHOT"));
251 assert!(ctx.contains("main"));
252 assert!(ctx.contains("implement feature X"));
253
254 let _ = std::fs::remove_dir_all(&tmp);
255 }
256
257 #[test]
258 fn session_digest_surfaced_in_render_context() {
259 let (tmp, ledger) = setup_workspace();
260
261 let digest = make_digest_note(
263 "main",
264 "sess-abc1",
265 15,
266 &["/src/main.rs", "/src/lib.rs"],
267 &["fix: UTF-8 truncation", "feat: add digest"],
268 &["cargo check -p edda-mcp"],
269 45,
270 );
271 ledger.append_event(&digest).unwrap();
272
273 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
274
275 assert!(
277 ctx.contains("## Session History"),
278 "missing Session History section in:\n{ctx}"
279 );
280 assert!(ctx.contains("sess-abc"), "missing session id in:\n{ctx}");
281 assert!(ctx.contains("45 min"), "missing duration in:\n{ctx}");
282 assert!(
283 ctx.contains("15 tool calls"),
284 "missing tool calls in:\n{ctx}"
285 );
286 assert!(ctx.contains("main.rs"), "missing file name in:\n{ctx}");
287 assert!(ctx.contains("lib.rs"), "missing file name in:\n{ctx}");
288 assert!(
289 ctx.contains("fix: UTF-8 truncation"),
290 "missing commit msg in:\n{ctx}"
291 );
292 assert!(
293 ctx.contains("cargo check -p edda-mcp"),
294 "missing failed cmd in:\n{ctx}"
295 );
296
297 let _ = std::fs::remove_dir_all(&tmp);
298 }
299
300 #[test]
301 fn no_digests_no_section() {
302 let (tmp, ledger) = setup_workspace();
303
304 let note = new_note_event("main", None, "user", "hello", &[]).unwrap();
306 ledger.append_event(¬e).unwrap();
307
308 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
309
310 assert!(
311 !ctx.contains("Session History"),
312 "should not show Session History when none exist"
313 );
314
315 let _ = std::fs::remove_dir_all(&tmp);
316 }
317
318 #[test]
319 fn render_context_no_commits_shows_events_not_uncommitted() {
320 let (tmp, ledger) = setup_workspace();
321
322 let n1 = new_note_event("main", None, "user", "note 1", &[]).unwrap();
324 let n2 = new_note_event("main", None, "user", "note 2", &[]).unwrap();
325 ledger.append_event(&n1).unwrap();
326 ledger.append_event(&n2).unwrap();
327
328 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
329
330 assert!(
332 ctx.contains("- events: 2"),
333 "expected '- events: 2' in:\n{ctx}"
334 );
335 assert!(
336 !ctx.contains("uncommitted_events"),
337 "should not contain 'uncommitted_events'"
338 );
339 assert!(
340 !ctx.contains("last_commit: (none)"),
341 "should not contain 'last_commit: (none)'"
342 );
343
344 let _ = std::fs::remove_dir_all(&tmp);
345 }
346
347 #[test]
348 fn signals_aggregate_repeated_cmd_fails() {
349 let (tmp, ledger) = setup_workspace();
350
351 for _ in 0..3 {
353 let argv = vec![
354 "cargo".to_string(),
355 "check".to_string(),
356 "-p".to_string(),
357 "edda-mcp".to_string(),
358 ];
359 let cmd = new_cmd_event(&CmdEventParams {
360 branch: "main",
361 parent_hash: None,
362 argv: &argv,
363 cwd: ".",
364 exit_code: 1,
365 duration_ms: 500,
366 stdout_blob: "",
367 stderr_blob: "",
368 })
369 .unwrap();
370 ledger.append_event(&cmd).unwrap();
371 }
372 let argv2 = vec!["cargo".to_string(), "test".to_string()];
373 let cmd2 = new_cmd_event(&CmdEventParams {
374 branch: "main",
375 parent_hash: None,
376 argv: &argv2,
377 cwd: ".",
378 exit_code: 1,
379 duration_ms: 200,
380 stdout_blob: "",
381 stderr_blob: "",
382 })
383 .unwrap();
384 ledger.append_event(&cmd2).unwrap();
385
386 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
387
388 assert!(
390 ctx.contains("cargo check (3x)"),
391 "expected aggregated 'cargo check (3x)' in:\n{ctx}"
392 );
393 assert!(
395 ctx.contains("cargo test (exit=1)"),
396 "expected individual 'cargo test' in:\n{ctx}"
397 );
398
399 let _ = std::fs::remove_dir_all(&tmp);
400 }
401
402 #[test]
403 fn signals_single_cmd_not_aggregated() {
404 let (tmp, ledger) = setup_workspace();
405
406 let argv = vec!["npm".to_string(), "install".to_string()];
407 let cmd = new_cmd_event(&CmdEventParams {
408 branch: "main",
409 parent_hash: None,
410 argv: &argv,
411 cwd: ".",
412 exit_code: 127,
413 duration_ms: 100,
414 stdout_blob: "",
415 stderr_blob: "",
416 })
417 .unwrap();
418 ledger.append_event(&cmd).unwrap();
419
420 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
421
422 assert!(
424 ctx.contains("CMD fail: npm install (exit=127)"),
425 "expected individual signal in:\n{ctx}"
426 );
427 assert!(
428 !ctx.contains("(1x)"),
429 "should not show count for single failure"
430 );
431
432 let _ = std::fs::remove_dir_all(&tmp);
433 }
434
435 #[test]
436 fn decision_notes_rendered_in_own_section() {
437 let (tmp, ledger) = setup_workspace();
438
439 let tags = vec!["decision".to_string()];
440 let d1 = new_note_event(
441 "main",
442 None,
443 "user",
444 "Use PostgreSQL for concurrent writes",
445 &tags,
446 )
447 .unwrap();
448 let d2 = new_note_event(
449 "main",
450 None,
451 "user",
452 "REST over GraphQL for simplicity",
453 &tags,
454 )
455 .unwrap();
456 ledger.append_event(&d1).unwrap();
457 ledger.append_event(&d2).unwrap();
458
459 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
460
461 assert!(
463 ctx.contains("## Decisions"),
464 "missing Decisions section in:\n{ctx}"
465 );
466 assert!(
467 ctx.contains("Use PostgreSQL"),
468 "missing decision text in:\n{ctx}"
469 );
470 assert!(
471 ctx.contains("REST over GraphQL"),
472 "missing decision text in:\n{ctx}"
473 );
474
475 let signals_section = ctx.split("## Recent Signals").nth(1).unwrap_or("");
477 assert!(
478 !signals_section.contains("PostgreSQL"),
479 "decision leaked into Signals in:\n{ctx}"
480 );
481
482 let dec_pos = ctx.find("## Decisions").unwrap();
484 let sig_pos = ctx.find("## Recent Signals").unwrap();
485 assert!(dec_pos < sig_pos, "Decisions should come before Signals");
486
487 let _ = std::fs::remove_dir_all(&tmp);
488 }
489
490 #[test]
491 fn decision_notes_no_time_cutoff() {
492 let (tmp, ledger) = setup_workspace();
493
494 use edda_core::event::finalize_event;
496 use edda_core::types::SCHEMA_VERSION;
497
498 let payload = serde_json::json!({
499 "role": "user",
500 "text": "JWT for auth tokens",
501 "tags": ["decision"]
502 });
503 let mut event = edda_core::Event {
504 event_id: "evt_old_decision".to_string(),
505 ts: "2020-01-01T00:00:00Z".to_string(), event_type: "note".to_string(),
507 branch: "main".to_string(),
508 parent_hash: None,
509 hash: String::new(),
510 payload,
511 refs: Default::default(),
512 schema_version: SCHEMA_VERSION,
513 digests: Vec::new(),
514 event_family: None,
515 event_level: None,
516 };
517 finalize_event(&mut event).unwrap();
518 ledger.append_event(&event).unwrap();
519
520 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
521
522 assert!(
524 ctx.contains("JWT for auth tokens"),
525 "old decision should survive cutoff in:\n{ctx}"
526 );
527 assert!(
528 ctx.contains("## Decisions"),
529 "Decisions section missing in:\n{ctx}"
530 );
531
532 let _ = std::fs::remove_dir_all(&tmp);
533 }
534
535 #[test]
536 fn no_decisions_no_section() {
537 let (tmp, ledger) = setup_workspace();
538
539 let tags = vec!["todo".to_string()];
541 let note = new_note_event("main", None, "user", "fix bug", &tags).unwrap();
542 ledger.append_event(¬e).unwrap();
543
544 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
545
546 assert!(
547 !ctx.contains("## Decisions"),
548 "should not have Decisions section when none exist"
549 );
550
551 let _ = std::fs::remove_dir_all(&tmp);
552 }
553
554 #[test]
555 fn superseded_decision_hidden_in_context() {
556 use edda_core::event::finalize_event;
557 use edda_core::types::{Provenance, SCHEMA_VERSION};
558
559 let (tmp, ledger) = setup_workspace();
560
561 let tags = vec!["decision".to_string()];
563 let d1 = new_note_event("main", None, "system", "db: mysql", &tags).unwrap();
564 let d1_id = d1.event_id.clone();
565 ledger.append_event(&d1).unwrap();
566
567 let payload = serde_json::json!({
569 "role": "system",
570 "text": "db: postgres — need JSONB",
571 "tags": ["decision"],
572 "decision": {"key": "db", "value": "postgres", "reason": "need JSONB"}
573 });
574 let mut d2 = edda_core::Event {
575 event_id: "evt_d2".to_string(),
576 ts: "2026-02-17T12:00:00Z".to_string(),
577 event_type: "note".to_string(),
578 branch: "main".to_string(),
579 parent_hash: None,
580 hash: String::new(),
581 payload,
582 refs: edda_core::types::Refs {
583 provenance: vec![Provenance {
584 target: d1_id.clone(),
585 rel: "supersedes".to_string(),
586 note: Some("key 'db' re-decided".to_string()),
587 }],
588 ..Default::default()
589 },
590 schema_version: SCHEMA_VERSION,
591 digests: Vec::new(),
592 event_family: None,
593 event_level: None,
594 };
595 finalize_event(&mut d2).unwrap();
596 ledger.append_event(&d2).unwrap();
597
598 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
599
600 assert!(
602 ctx.contains("postgres"),
603 "active decision missing in:\n{ctx}"
604 );
605 assert!(
606 !ctx.contains("mysql"),
607 "superseded decision should be hidden in:\n{ctx}"
608 );
609
610 let _ = std::fs::remove_dir_all(&tmp);
611 }
612
613 #[test]
614 fn supersession_chain_resolves() {
615 use edda_core::event::finalize_event;
616 use edda_core::types::Provenance;
617
618 let (tmp, ledger) = setup_workspace();
619 let tags = vec!["decision".to_string()];
620
621 let a = new_note_event("main", None, "system", "db: sqlite", &tags).unwrap();
623 let a_id = a.event_id.clone();
624 ledger.append_event(&a).unwrap();
625
626 let mut b = new_note_event("main", None, "system", "db: mysql", &tags).unwrap();
628 let b_id = b.event_id.clone();
629 b.refs.provenance.push(Provenance {
630 target: a_id,
631 rel: "supersedes".to_string(),
632 note: None,
633 });
634 finalize_event(&mut b).unwrap();
635 ledger.append_event(&b).unwrap();
636
637 let mut c = new_note_event("main", None, "system", "db: postgres", &tags).unwrap();
639 c.refs.provenance.push(Provenance {
640 target: b_id,
641 rel: "supersedes".to_string(),
642 note: None,
643 });
644 finalize_event(&mut c).unwrap();
645 ledger.append_event(&c).unwrap();
646
647 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
648
649 assert!(
650 ctx.contains("postgres"),
651 "final decision missing in:\n{ctx}"
652 );
653 assert!(
654 !ctx.contains("mysql"),
655 "superseded B should be hidden in:\n{ctx}"
656 );
657 assert!(
658 !ctx.contains("sqlite"),
659 "superseded A should be hidden in:\n{ctx}"
660 );
661
662 let _ = std::fs::remove_dir_all(&tmp);
663 }
664
665 #[test]
666 fn old_decision_without_structured_fields_still_renders() {
667 let (tmp, ledger) = setup_workspace();
668
669 let tags = vec!["decision".to_string()];
671 let d = new_note_event(
672 "main",
673 None,
674 "system",
675 "orm: sqlx — compile-time checks",
676 &tags,
677 )
678 .unwrap();
679 ledger.append_event(&d).unwrap();
680
681 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
682
683 assert!(
684 ctx.contains("orm: sqlx"),
685 "old-format decision should render in:\n{ctx}"
686 );
687 assert!(
688 ctx.contains("## Decisions"),
689 "Decisions section missing in:\n{ctx}"
690 );
691
692 let _ = std::fs::remove_dir_all(&tmp);
693 }
694
695 #[test]
696 fn decisions_decoupled_from_depth() {
697 let (tmp, ledger) = setup_workspace();
698
699 let tags = vec!["decision".to_string()];
701 for i in 1..=8 {
702 let d = new_note_event(
703 "main",
704 None,
705 "user",
706 &format!("Decision {i}: choice {i}"),
707 &tags,
708 )
709 .unwrap();
710 ledger.append_event(&d).unwrap();
711 }
712
713 let opts = DeriveOptions { depth: 1 };
715 let ctx = render_context(&ledger, "main", opts).unwrap();
716
717 assert!(
718 ctx.contains("## Decisions"),
719 "missing Decisions section in:\n{ctx}"
720 );
721 let decision_count = (1..=8)
723 .filter(|i| ctx.contains(&format!("Decision {i}: choice {i}")))
724 .count();
725 assert!(
726 decision_count >= 5,
727 "expected at least 5 decisions visible at depth=1, got {decision_count} in:\n{ctx}"
728 );
729
730 let _ = std::fs::remove_dir_all(&tmp);
731 }
732
733 #[test]
734 fn project_header_shows_session_count_single() {
735 let (tmp, ledger) = setup_workspace();
736
737 let d = make_digest_note_with_ts("main", "sess-001", "2026-02-17T10:00:00Z", &[], &[]);
738 ledger.append_event(&d).unwrap();
739
740 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
741
742 assert!(
743 ctx.contains("- sessions: 1 (2026-02-17)"),
744 "missing single-session header in:\n{ctx}"
745 );
746
747 let _ = std::fs::remove_dir_all(&tmp);
748 }
749
750 #[test]
751 fn project_header_shows_session_count_range() {
752 let (tmp, ledger) = setup_workspace();
753
754 let d1 = make_digest_note_with_ts("main", "sess-001", "2026-02-12T10:00:00Z", &[], &["c1"]);
755 let d2 = make_digest_note_with_ts("main", "sess-002", "2026-02-14T10:00:00Z", &[], &["c2"]);
756 let d3 = make_digest_note_with_ts("main", "sess-003", "2026-02-17T10:00:00Z", &[], &["c3"]);
757 ledger.append_event(&d1).unwrap();
758 ledger.append_event(&d2).unwrap();
759 ledger.append_event(&d3).unwrap();
760
761 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
762
763 assert!(
764 ctx.contains("- sessions: 3 (2026-02-12 — 2026-02-17)"),
765 "missing session range header in:\n{ctx}"
766 );
767
768 let _ = std::fs::remove_dir_all(&tmp);
769 }
770
771 #[test]
772 fn project_header_no_sessions_without_digests() {
773 let (tmp, ledger) = setup_workspace();
774
775 let note = new_note_event("main", None, "user", "hello", &[]).unwrap();
776 ledger.append_event(¬e).unwrap();
777
778 let ctx = render_context(&ledger, "main", DeriveOptions::default()).unwrap();
779
780 assert!(
781 !ctx.contains("- sessions:"),
782 "should not show sessions line without digests:\n{ctx}"
783 );
784
785 let _ = std::fs::remove_dir_all(&tmp);
786 }
787}