Skip to main content

edda_derive/context/
mod.rs

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    // Filter signals to last 2 hours to avoid showing stale errors
22    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        // No edda commits — show event count without misleading "uncommitted" framing
61        out.push_str(&format!("- events: {}\n", snap.uncommitted_events));
62    }
63    // Session count and date span from digests
64    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    // Tiered session history rendering
85    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    // Decisions — no time cutoff (decisions are long-lived)
141    // Build superseded set: any event targeted by a "supersedes" provenance link is inactive
142    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    // Filter out decisions from signals (they have their own section)
170    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        // Aggregate CmdFail signals by command base; keep NoteTodo as-is
178        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 => {} // handled above
189            }
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        // Add a todo note (becomes a signal)
230        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(&note).unwrap();
233
234        // Add a commit
235        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        // Write a session_digest note to the workspace ledger
262        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        // Should contain Session History section
276        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        // Only a regular note, no session_digest
305        let note = new_note_event("main", None, "user", "hello", &[]).unwrap();
306        ledger.append_event(&note).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        // Add notes without any commit
323        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        // Should show "events: 2" instead of "uncommitted_events: 2"
331        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        // Add 3 "cargo check" failures and 1 "cargo test" failure
352        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        // "cargo check" should be aggregated as 3x
389        assert!(
390            ctx.contains("cargo check (3x)"),
391            "expected aggregated 'cargo check (3x)' in:\n{ctx}"
392        );
393        // "cargo test" should appear individually (only 1)
394        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        // Single failure — show full text, not aggregated
423        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        // Decisions section should exist
462        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        // Decisions should NOT appear in Recent Signals
476        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        // Decisions section should come before Recent Signals
483        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        // Create a decision note with an old timestamp (> 2 hours ago)
495        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(), // very old
506            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        // Old decision should still appear (no 2hr cutoff)
523        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        // Only a todo note, no decision
540        let tags = vec!["todo".to_string()];
541        let note = new_note_event("main", None, "user", "fix bug", &tags).unwrap();
542        ledger.append_event(&note).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        // First decision: db = mysql
562        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        // Second decision: db = postgres, supersedes d1
568        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        // Only d2 (postgres) should appear, not d1 (mysql)
601        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        // A: db = sqlite
622        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        // B: db = mysql, supersedes A
627        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        // C: db = postgres, supersedes B
638        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        // Old-format decision (no payload.decision field)
670        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        // Create 8 active decisions
700        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        // Render with depth=1 — decisions should still show up to 5
714        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        // At least 5 decisions visible even at depth=1
722        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(&note).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}