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