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        // GH-401: this signal-derived list has no ratified-state; binding
162        // status lives in the Ratified/Unratified decision pack. Keep the
163        // "## Decisions" prefix but qualify it so a truncation that drops the
164        // pack cannot leave this reading as authoritative bindings.
165        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    // Filter out decisions from signals (they have their own section)
177    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        // Aggregate CmdFail signals by command base; keep NoteTodo as-is
185        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 => {} // handled above
196            }
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        // Add a todo note (becomes a signal)
237        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(&note).unwrap();
240
241        // Add a commit
242        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        // Write a session_digest note to the workspace ledger
269        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        // Should contain Session History section
283        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        // Only a regular note, no session_digest
312        let note = new_note_event("main", None, "user", "hello", &[]).unwrap();
313        ledger.append_event(&note).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        // Add notes without any commit
330        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        // Should show "events: 2" instead of "uncommitted_events: 2"
338        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        // Add 3 "cargo check" failures and 1 "cargo test" failure
359        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        // "cargo check" should be aggregated as 3x
396        assert!(
397            ctx.contains("cargo check (3x)"),
398            "expected aggregated 'cargo check (3x)' in:\n{ctx}"
399        );
400        // "cargo test" should appear individually (only 1)
401        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        // Single failure — show full text, not aggregated
430        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        // Decisions section should exist
469        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        // Decisions should NOT appear in Recent Signals
483        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        // Decisions section should come before Recent Signals
490        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        // Create a decision note with an old timestamp (> 2 hours ago)
502        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(), // very old
513            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        // Old decision should still appear (no 2hr cutoff)
530        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        // Only a todo note, no decision
547        let tags = vec!["todo".to_string()];
548        let note = new_note_event("main", None, "user", "fix bug", &tags).unwrap();
549        ledger.append_event(&note).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        // First decision: db = mysql
569        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        // Second decision: db = postgres, supersedes d1
575        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        // Only d2 (postgres) should appear, not d1 (mysql)
608        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        // A: db = sqlite
629        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        // B: db = mysql, supersedes A
634        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        // C: db = postgres, supersedes B
645        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        // Old-format decision (no payload.decision field)
677        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        // Create 8 active decisions
707        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        // Render with depth=1 — decisions should still show up to 5
721        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        // At least 5 decisions visible even at depth=1
729        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(&note).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}