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
14#[allow(clippy::too_many_lines)] // 219 lines at #779; split tracked in none
15pub 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    // Filter signals to last 2 hours to avoid showing stale errors
23    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        // No edda commits — show event count without misleading "uncommitted" framing
62        out.push_str(&format!("- events: {}\n", snap.uncommitted_events));
63    }
64    // Session count and date span from digests
65    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    // Tiered session history rendering
86    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    // Decisions — no time cutoff (decisions are long-lived)
142    // Build superseded set: any event targeted by a "supersedes" provenance link is inactive
143    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        // GH-401: this signal-derived list has no ratified-state; binding
163        // status lives in the Ratified/Unratified decision pack. Keep the
164        // "## Decisions" prefix but qualify it so a truncation that drops the
165        // pack cannot leave this reading as authoritative bindings.
166        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    // Filter out decisions from signals (they have their own section)
178    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        // Aggregate CmdFail signals by command base; keep NoteTodo as-is
186        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 => {} // handled above
197            }
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        // Add a todo note (becomes a signal)
238        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(&note).unwrap();
241
242        // Add a commit
243        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        // Write a session_digest note to the workspace ledger
270        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        // Should contain Session History section
284        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        // Only a regular note, no session_digest
313        let note = new_note_event("main", None, "user", "hello", &[]).unwrap();
314        ledger.append_event(&note).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        // Add notes without any commit
331        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        // Should show "events: 2" instead of "uncommitted_events: 2"
339        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        // Add 3 "cargo check" failures and 1 "cargo test" failure
360        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        // "cargo check" should be aggregated as 3x
397        assert!(
398            ctx.contains("cargo check (3x)"),
399            "expected aggregated 'cargo check (3x)' in:\n{ctx}"
400        );
401        // "cargo test" should appear individually (only 1)
402        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        // Single failure — show full text, not aggregated
431        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        // Decisions section should exist
470        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        // Decisions should NOT appear in Recent Signals
484        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        // Decisions section should come before Recent Signals
491        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        // Create a decision note with an old timestamp (> 2 hours ago)
503        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(), // very old
514            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        // Old decision should still appear (no 2hr cutoff)
531        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        // Only a todo note, no decision
548        let tags = vec!["todo".to_string()];
549        let note = new_note_event("main", None, "user", "fix bug", &tags).unwrap();
550        ledger.append_event(&note).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        // First decision: db = mysql
570        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        // Second decision: db = postgres, supersedes d1
576        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        // Only d2 (postgres) should appear, not d1 (mysql)
609        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        // A: db = sqlite
630        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        // B: db = mysql, supersedes A
635        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        // C: db = postgres, supersedes B
646        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        // Old-format decision (no payload.decision field)
678        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        // Create 8 active decisions
708        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        // Render with depth=1 — decisions should still show up to 5
722        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        // At least 5 decisions visible even at depth=1
730        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(&note).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}