Skip to main content

shiplog_render_md/
lib.rs

1//! Markdown packet renderer for shiplog.
2//!
3//! Converts canonical events, workstreams, and coverage metadata into an
4//! editable self-review packet with receipts and appendix sections.
5
6use anyhow::Result;
7use shiplog_ports::Renderer;
8use shiplog_schema::coverage::CoverageManifest;
9use shiplog_schema::event::{EventEnvelope, EventKind};
10use shiplog_schema::workstream::{Workstream, WorkstreamsFile};
11use shiplog_workstreams::WORKSTREAM_RECEIPT_RENDER_LIMIT;
12use std::collections::HashMap;
13
14pub mod receipt;
15
16pub use receipt::{format_receipt_markdown, manual_type_emoji};
17
18const WORKSTREAM_EVIDENCE_ANCHOR_LIMIT: usize = 3;
19
20/// Section ordering configuration
21///
22/// # Examples
23///
24/// ```
25/// use shiplog_render_md::SectionOrder;
26///
27/// let order = SectionOrder::default();
28/// assert_eq!(order, SectionOrder::Default);
29/// ```
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum SectionOrder {
32    /// Default order: Summary, Workstreams, Receipts, Coverage
33    #[default]
34    Default,
35    /// Alternative order: Coverage, Summary, Workstreams, Receipts
36    CoverageFirst,
37}
38
39/// Controls appendix density for rendered Markdown packets.
40///
41/// The default keeps the historical full appendix behavior.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum AppendixMode {
44    /// Render every assigned event in the appendix.
45    #[default]
46    Full,
47    /// Render per-workstream receipt counts instead of every event.
48    Summary,
49    /// Omit the appendix.
50    None,
51}
52
53/// Markdown packet density controls.
54///
55/// These options affect the human-facing packet shape without changing the
56/// canonical ledger, coverage manifest, or workstream files.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct MarkdownRenderOptions {
59    /// Maximum curated receipts shown per workstream in the main receipts section.
60    pub receipt_limit: usize,
61    /// Appendix detail level.
62    pub appendix_mode: AppendixMode,
63}
64
65impl Default for MarkdownRenderOptions {
66    fn default() -> Self {
67        Self {
68            receipt_limit: WORKSTREAM_RECEIPT_RENDER_LIMIT,
69            appendix_mode: AppendixMode::Full,
70        }
71    }
72}
73
74/// Minimal renderer that produces a copy-ready Markdown packet.
75///
76/// The output is intentionally low-magic:
77/// - headings
78/// - short bullets
79/// - receipts with URLs when available
80///
81/// # Examples
82///
83/// Use as a [`Renderer`] trait object to render events into Markdown:
84///
85/// ```rust,no_run
86/// use shiplog_render_md::MarkdownRenderer;
87/// use shiplog_ports::Renderer;
88/// use shiplog_schema::event::EventEnvelope;
89/// use shiplog_schema::workstream::WorkstreamsFile;
90/// use shiplog_schema::coverage::CoverageManifest;
91///
92/// # fn example(
93/// #     events: &[EventEnvelope],
94/// #     workstreams: &WorkstreamsFile,
95/// #     coverage: &CoverageManifest,
96/// # ) -> anyhow::Result<()> {
97/// let renderer = MarkdownRenderer::new();
98/// let markdown = renderer.render_packet_markdown(
99///     "octocat",
100///     "2025-01-01..2025-04-01",
101///     events,
102///     workstreams,
103///     coverage,
104/// )?;
105/// println!("{}", markdown);
106/// # Ok(())
107/// # }
108/// ```
109pub struct MarkdownRenderer {
110    /// Section ordering configuration
111    pub section_order: SectionOrder,
112}
113
114impl Default for MarkdownRenderer {
115    fn default() -> Self {
116        Self {
117            section_order: SectionOrder::Default,
118        }
119    }
120}
121
122impl MarkdownRenderer {
123    /// Create a new renderer with default section ordering.
124    ///
125    /// # Examples
126    ///
127    /// ```
128    /// use shiplog_render_md::MarkdownRenderer;
129    ///
130    /// let renderer = MarkdownRenderer::new();
131    /// ```
132    pub fn new() -> Self {
133        Self::default()
134    }
135
136    /// Create a new renderer with custom section ordering.
137    ///
138    /// # Examples
139    ///
140    /// ```
141    /// use shiplog_render_md::{MarkdownRenderer, SectionOrder};
142    ///
143    /// let renderer = MarkdownRenderer::new()
144    ///     .with_section_order(SectionOrder::CoverageFirst);
145    /// ```
146    pub fn with_section_order(mut self, order: SectionOrder) -> Self {
147        self.section_order = order;
148        self
149    }
150
151    /// Render a writing scaffold with coverage, workstream prompts, and evidence anchors.
152    pub fn render_scaffold_markdown(
153        &self,
154        user: &str,
155        window_label: &str,
156        events: &[EventEnvelope],
157        workstreams: &WorkstreamsFile,
158        coverage: &CoverageManifest,
159    ) -> Result<String> {
160        self.render_scaffold_markdown_with_options(
161            user,
162            window_label,
163            events,
164            workstreams,
165            coverage,
166            MarkdownRenderOptions::default(),
167        )
168    }
169
170    /// Render a writing scaffold with explicit packet density controls.
171    ///
172    /// Scaffold mode currently omits the full receipts and appendix sections,
173    /// so appendix options are accepted for API symmetry and future expansion.
174    pub fn render_scaffold_markdown_with_options(
175        &self,
176        user: &str,
177        window_label: &str,
178        events: &[EventEnvelope],
179        workstreams: &WorkstreamsFile,
180        coverage: &CoverageManifest,
181        _options: MarkdownRenderOptions,
182    ) -> Result<String> {
183        let mut out = String::new();
184        render_coverage(&mut out, coverage, events);
185        render_summary(&mut out, user, window_label, events, workstreams, coverage);
186        render_workstreams(&mut out, events, workstreams);
187        render_file_artifacts(&mut out);
188        Ok(out)
189    }
190
191    /// Render a dense receipts view for audit and appendix review.
192    pub fn render_receipts_markdown(
193        &self,
194        user: &str,
195        window_label: &str,
196        events: &[EventEnvelope],
197        workstreams: &WorkstreamsFile,
198        coverage: &CoverageManifest,
199    ) -> Result<String> {
200        self.render_receipts_markdown_with_options(
201            user,
202            window_label,
203            events,
204            workstreams,
205            coverage,
206            MarkdownRenderOptions::default(),
207        )
208    }
209
210    /// Render a dense receipts view with explicit packet density controls.
211    pub fn render_receipts_markdown_with_options(
212        &self,
213        user: &str,
214        window_label: &str,
215        events: &[EventEnvelope],
216        workstreams: &WorkstreamsFile,
217        coverage: &CoverageManifest,
218        options: MarkdownRenderOptions,
219    ) -> Result<String> {
220        let mut out = String::new();
221        render_summary(&mut out, user, window_label, events, workstreams, coverage);
222        render_coverage(&mut out, coverage, events);
223        render_receipts(&mut out, events, workstreams, options);
224        render_appendix(&mut out, events, workstreams, options.appendix_mode);
225        render_file_artifacts(&mut out);
226        Ok(out)
227    }
228
229    /// Render the default packet with explicit packet density controls.
230    pub fn render_packet_markdown_with_options(
231        &self,
232        user: &str,
233        window_label: &str,
234        events: &[EventEnvelope],
235        workstreams: &WorkstreamsFile,
236        coverage: &CoverageManifest,
237        options: MarkdownRenderOptions,
238    ) -> Result<String> {
239        let mut out = String::new();
240
241        // Render sections based on configured order
242        match self.section_order {
243            SectionOrder::Default => {
244                render_summary(&mut out, user, window_label, events, workstreams, coverage);
245                render_workstreams(&mut out, events, workstreams);
246                render_receipts(&mut out, events, workstreams, options);
247                render_coverage(&mut out, coverage, events);
248            }
249            SectionOrder::CoverageFirst => {
250                render_coverage(&mut out, coverage, events);
251                render_summary(&mut out, user, window_label, events, workstreams, coverage);
252                render_workstreams(&mut out, events, workstreams);
253                render_receipts(&mut out, events, workstreams, options);
254            }
255        }
256
257        render_appendix(&mut out, events, workstreams, options.appendix_mode);
258        render_file_artifacts(&mut out);
259
260        Ok(out)
261    }
262}
263
264impl Renderer for MarkdownRenderer {
265    fn render_packet_markdown(
266        &self,
267        user: &str,
268        window_label: &str,
269        events: &[EventEnvelope],
270        workstreams: &WorkstreamsFile,
271        coverage: &CoverageManifest,
272    ) -> Result<String> {
273        self.render_packet_markdown_with_options(
274            user,
275            window_label,
276            events,
277            workstreams,
278            coverage,
279            MarkdownRenderOptions::default(),
280        )
281    }
282}
283
284fn render_summary(
285    out: &mut String,
286    _user: &str,
287    window_label: &str,
288    events: &[EventEnvelope],
289    workstreams: &WorkstreamsFile,
290    coverage: &CoverageManifest,
291) {
292    out.push_str("# Summary\n\n");
293
294    // Window
295    out.push_str(&format!("**Window:** {}\n\n", window_label));
296
297    // Workstream count
298    out.push_str(&format!(
299        "**Workstreams:** {}\n\n",
300        workstreams.workstreams.len()
301    ));
302
303    // Event counts by type
304    let pr_count = events
305        .iter()
306        .filter(|e| matches!(e.kind, EventKind::PullRequest))
307        .count();
308    let review_count = events
309        .iter()
310        .filter(|e| matches!(e.kind, EventKind::Review))
311        .count();
312    let manual_count = events
313        .iter()
314        .filter(|e| matches!(e.kind, EventKind::Manual))
315        .count();
316    out.push_str(&format!(
317        "**Events:** {}, {}, {}\n\n",
318        count_label(pr_count, "PR", "PRs"),
319        count_label(review_count, "review", "reviews"),
320        count_label(manual_count, "manual event", "manual events")
321    ));
322
323    // Completeness
324    out.push_str(&format!("**Coverage:** {:?}\n\n", coverage.completeness));
325
326    // Sources
327    out.push_str(&format!(
328        "**Sources:** {}\n\n",
329        display_source_list(&coverage.sources)
330    ));
331
332    // Warnings
333    if !coverage.warnings.is_empty() {
334        out.push_str("**Warnings:**\n");
335        for w in &coverage.warnings {
336            out.push_str(&format!("  - ⚠️ {}\n", w));
337        }
338        out.push('\n');
339    }
340
341    render_executive_summary(out, events, workstreams, coverage);
342}
343
344/// Workstream-by-workstream one-liner overview. The doc contract
345/// (`docs/product/rapid-first-intake.md` § 4.1) calls for a 5–15 line
346/// executive summary block driven by workstream titles and event counts,
347/// with explicit gaps called out inline and a cross-reference to the
348/// skipped-items section when coverage warnings exist.
349fn render_executive_summary(
350    out: &mut String,
351    events: &[EventEnvelope],
352    workstreams: &WorkstreamsFile,
353    coverage: &CoverageManifest,
354) {
355    out.push_str("## Executive Summary\n\n");
356
357    if workstreams.workstreams.is_empty() {
358        out.push_str(
359            "_No workstreams yet — no evidence has been clustered into a workstream._\n\n",
360        );
361    } else {
362        let by_id: HashMap<&str, &EventEnvelope> =
363            events.iter().map(|e| (e.id.0.as_str(), e)).collect();
364
365        // Cap at 15 lines to honor the doc's 5–15 line ceiling: list the
366        // first 14 workstreams in full, then a "+ N more" line if needed.
367        const MAX_LINES: usize = 14;
368        let total = workstreams.workstreams.len();
369        let shown = total.min(MAX_LINES);
370
371        for ws in workstreams.workstreams.iter().take(shown) {
372            let ws_pr = ws
373                .events
374                .iter()
375                .filter(|id| {
376                    by_id
377                        .get(id.0.as_str())
378                        .is_some_and(|e| matches!(e.kind, EventKind::PullRequest))
379                })
380                .count();
381            let ws_review = ws
382                .events
383                .iter()
384                .filter(|id| {
385                    by_id
386                        .get(id.0.as_str())
387                        .is_some_and(|e| matches!(e.kind, EventKind::Review))
388                })
389                .count();
390            let ws_manual = ws
391                .events
392                .iter()
393                .filter(|id| {
394                    by_id
395                        .get(id.0.as_str())
396                        .is_some_and(|e| matches!(e.kind, EventKind::Manual))
397                })
398                .count();
399
400            let counts = format!(
401                "{}, {}, {}",
402                count_label(ws_pr, "PR", "PRs"),
403                count_label(ws_review, "review", "reviews"),
404                count_label(ws_manual, "manual event", "manual events"),
405            );
406
407            let mut gaps: Vec<&str> = Vec::new();
408            if ws.events.is_empty() {
409                gaps.push("no events");
410            }
411            if ws.receipts.is_empty() && !ws.events.is_empty() {
412                gaps.push("no anchor receipts");
413            }
414            let gap_suffix = if gaps.is_empty() {
415                String::new()
416            } else {
417                format!(" — _gap: {}_", gaps.join("; "))
418            };
419
420            out.push_str(&format!("- **{}** — {}{}\n", ws.title, counts, gap_suffix));
421        }
422
423        if total > shown {
424            out.push_str(&format!(
425                "- _+ {} more workstream{}; see `## Workstreams` below for the full list._\n",
426                total - shown,
427                if total - shown == 1 { "" } else { "s" }
428            ));
429        }
430        out.push('\n');
431    }
432
433    if !coverage.warnings.is_empty() {
434        out.push_str(
435            "_Skipped sources and gaps: see `## Coverage and Limits` for the receipted list._\n\n",
436        );
437    }
438}
439
440fn render_workstreams(out: &mut String, events: &[EventEnvelope], workstreams: &WorkstreamsFile) {
441    out.push_str("## Workstreams\n\n");
442
443    if workstreams.workstreams.is_empty() {
444        out.push_str("_No workstreams found_\n\n");
445        return;
446    }
447
448    let by_id: HashMap<String, &EventEnvelope> =
449        events.iter().map(|e| (e.id.0.clone(), e)).collect();
450
451    for ws in &workstreams.workstreams {
452        out.push_str(&format!("### {}\n\n", ws.title));
453
454        if let Some(s) = &ws.summary {
455            out.push_str(s);
456            out.push_str("\n\n");
457        }
458
459        render_evidence_anchors(out, &by_id, ws);
460        render_claim_prompts(out);
461
462        // Stats
463        out.push_str(&format!(
464            "_PRs: {}, Reviews: {}, Manual: {}_\n\n",
465            ws.stats.pull_requests, ws.stats.reviews, ws.stats.manual_events
466        ));
467    }
468}
469
470fn render_evidence_anchors(
471    out: &mut String,
472    by_id: &HashMap<String, &EventEnvelope>,
473    workstream: &Workstream,
474) {
475    out.push_str("**Evidence anchors**\n\n");
476
477    let available: Vec<_> = workstream
478        .receipts
479        .iter()
480        .filter_map(|id| by_id.get(&id.0).copied())
481        .collect();
482
483    if available.is_empty() {
484        out.push_str("- (none)\n\n");
485        return;
486    }
487
488    for event in available.iter().take(WORKSTREAM_EVIDENCE_ANCHOR_LIMIT) {
489        out.push_str(&format!("{}\n", format_receipt_markdown(event)));
490    }
491
492    let remaining = available
493        .len()
494        .saturating_sub(WORKSTREAM_EVIDENCE_ANCHOR_LIMIT);
495    if remaining > 0 {
496        out.push_str(&format!(
497            "- ... and {remaining} more in [Receipts](#receipts)\n"
498        ));
499    }
500    out.push('\n');
501}
502
503fn render_claim_prompts(out: &mut String) {
504    out.push_str("**Suggested claim prompts**\n\n");
505    out.push_str("- What changed for users, operators, or maintainers?\n");
506    out.push_str("- Which risk, delay, or repeated work did this reduce?\n");
507    out.push_str("- Which evidence anchor best proves the change?\n");
508    out.push_str("- What follow-up or gap should a reviewer know about?\n\n");
509}
510
511fn render_receipts(
512    out: &mut String,
513    events: &[EventEnvelope],
514    workstreams: &WorkstreamsFile,
515    options: MarkdownRenderOptions,
516) {
517    out.push_str("## Receipts\n\n");
518
519    if workstreams.workstreams.is_empty() {
520        out.push_str("_No workstreams, no receipts_\n\n");
521        return;
522    }
523
524    let by_id: HashMap<String, &EventEnvelope> =
525        events.iter().map(|e| (e.id.0.clone(), e)).collect();
526
527    for ws in &workstreams.workstreams {
528        out.push_str(&format!("### Workstream: {}\n\n", ws.title));
529
530        // Split receipts into main (top N) and appendix (remainder)
531        let (main_receipts, appendix_receipts): (Vec<_>, Vec<_>) = if ws.receipts.is_empty() {
532            (Vec::new(), Vec::new())
533        } else if ws.receipts.len() <= options.receipt_limit {
534            (ws.receipts.clone(), Vec::new())
535        } else if options.receipt_limit == 0 {
536            (Vec::new(), ws.receipts.clone())
537        } else {
538            let (main, appendix) = ws.receipts.split_at(options.receipt_limit);
539            (main.to_vec(), appendix.to_vec())
540        };
541
542        if main_receipts.is_empty() {
543            out.push_str("- (none)\n");
544        } else {
545            for id in &main_receipts {
546                if let Some(ev) = by_id.get(&id.0) {
547                    out.push_str(&format!("{}\n", format_receipt_markdown(ev)));
548                }
549            }
550        }
551
552        if !appendix_receipts.is_empty() {
553            out.push_str(&appendix_receipt_note(
554                appendix_receipts.len(),
555                options.appendix_mode,
556            ));
557        }
558        out.push('\n');
559    }
560}
561
562fn appendix_receipt_note(count: usize, mode: AppendixMode) -> String {
563    match mode {
564        AppendixMode::Full => {
565            format!("- *... and {count} more in [Appendix](#appendix-receipts)*\n")
566        }
567        AppendixMode::Summary => {
568            format!(
569                "- *... and {count} more summarized in [Appendix](#appendix-receipt-summary)*\n"
570            )
571        }
572        AppendixMode::None => format!("- *... and {count} more omitted by appendix settings*\n"),
573    }
574}
575
576fn render_coverage(out: &mut String, coverage: &CoverageManifest, events: &[EventEnvelope]) {
577    out.push_str("## Coverage and Limits\n\n");
578
579    out.push_str("Included:\n");
580    let skipped_sources = skipped_source_warnings(&coverage.warnings);
581    let included_sources = included_source_summary(coverage, events, &skipped_sources);
582    if included_sources.is_empty() {
583        out.push_str("- No completed sources recorded\n");
584    } else {
585        for source in &included_sources {
586            let count = source_event_count(events, source);
587            let noun = if count == 1 { "event" } else { "events" };
588            out.push_str(&format!(
589                "- {}: {} {}\n",
590                display_source_label(source),
591                count,
592                noun
593            ));
594        }
595    }
596
597    if coverage.slices.is_empty() {
598        out.push_str("- Fetched events: not reported by query slices\n");
599    } else {
600        let fetched: u64 = coverage.slices.iter().map(|slice| slice.fetched).sum();
601        let total: u64 = coverage.slices.iter().map(|slice| slice.total_count).sum();
602        let slice_label = if coverage.slices.len() == 1 {
603            "slice"
604        } else {
605            "slices"
606        };
607        out.push_str(&format!(
608            "- Query slices: {} {}, fetched {} of {} reported results\n",
609            coverage.slices.len(),
610            slice_label,
611            fetched,
612            total
613        ));
614    }
615    out.push('\n');
616
617    out.push_str("Skipped:\n");
618    if skipped_sources.is_empty() {
619        out.push_str("- None recorded\n");
620    } else {
621        for skipped in &skipped_sources {
622            out.push_str(&format!(
623                "- {}: {}\n",
624                display_source_label(skipped.source),
625                skipped.reason
626            ));
627        }
628    }
629    out.push('\n');
630
631    out.push_str("Known gaps:\n");
632    let mut has_gap = false;
633    if !matches!(
634        coverage.completeness,
635        shiplog_schema::coverage::Completeness::Complete
636    ) {
637        has_gap = true;
638        out.push_str(&format!(
639            "- Overall completeness is {}\n",
640            coverage.completeness
641        ));
642    }
643    for warning in &coverage.warnings {
644        if skipped_source_warning(warning).is_none() {
645            has_gap = true;
646            out.push_str(&format!("- {}\n", warning));
647        }
648    }
649
650    if source_present(&coverage.sources, "manual")
651        || events
652            .iter()
653            .any(|event| source_matches(event.source.system.as_str(), "manual"))
654    {
655        has_gap = true;
656        out.push_str("- Manual events are user-provided\n");
657    }
658
659    let incomplete_count = coverage
660        .slices
661        .iter()
662        .filter(|slice| slice.incomplete_results.unwrap_or(false))
663        .count();
664    if incomplete_count > 0 {
665        has_gap = true;
666        let slice_label = if incomplete_count == 1 {
667            "slice"
668        } else {
669            "slices"
670        };
671        out.push_str(&format!(
672            "- {} query {} reported incomplete results\n",
673            incomplete_count, slice_label
674        ));
675    }
676
677    let capped_count = coverage
678        .slices
679        .iter()
680        .filter(|slice| slice.total_count > slice.fetched)
681        .count();
682    if capped_count > 0 {
683        has_gap = true;
684        let slice_label = if capped_count == 1 { "slice" } else { "slices" };
685        out.push_str(&format!(
686            "- {} query {} fetched fewer results than reported\n",
687            capped_count, slice_label
688        ));
689    }
690
691    if !has_gap {
692        out.push_str("- None recorded\n");
693    }
694    out.push('\n');
695
696    out.push_str("Details:\n");
697
698    // Date window
699    out.push_str(&format!(
700        "- **Date window:** {} to {}\n",
701        coverage.window.since, coverage.window.until
702    ));
703
704    // Mode
705    out.push_str(&format!("- **Mode:** {}\n", coverage.mode));
706
707    // Sources
708    out.push_str(&format!(
709        "- **Sources:** {}\n",
710        display_source_list(&coverage.sources)
711    ));
712
713    // Completeness
714    out.push_str(&format!(
715        "- **Completeness:** {:?}\n",
716        coverage.completeness
717    ));
718
719    // Coverage slicing details
720    if !coverage.slices.is_empty() {
721        out.push_str(&format!("- **Query slices:** {}\n", coverage.slices.len()));
722
723        // Check for partial results or caps
724        let partial_count = coverage
725            .slices
726            .iter()
727            .filter(|s| s.incomplete_results.unwrap_or(false))
728            .count();
729        if partial_count > 0 {
730            out.push_str(&format!(
731                "  - ⚠️ {} slices had incomplete results\n",
732                partial_count
733            ));
734        }
735
736        // Show slices that hit caps
737        let capped_slices: Vec<_> = coverage
738            .slices
739            .iter()
740            .filter(|s| s.total_count > s.fetched)
741            .collect();
742        if !capped_slices.is_empty() {
743            out.push_str("  - **Slicing applied (API caps):**\n");
744            for slice in capped_slices.iter().take(3) {
745                let pct = if slice.total_count > 0 {
746                    (slice.fetched as f64 / slice.total_count as f64 * 100.0) as u64
747                } else {
748                    100
749                };
750                out.push_str(&format!(
751                    "    - {}: fetched {}/{} ({}%)\n",
752                    slice.query, slice.fetched, slice.total_count, pct
753                ));
754            }
755            if capped_slices.len() > 3 {
756                out.push_str(&format!("    - ... and {} more\n", capped_slices.len() - 3));
757            }
758        }
759    }
760    out.push('\n');
761}
762
763#[derive(Clone, Copy, Debug)]
764struct SkippedSource<'a> {
765    source: &'a str,
766    reason: &'a str,
767}
768
769fn skipped_source_warnings(warnings: &[String]) -> Vec<SkippedSource<'_>> {
770    warnings
771        .iter()
772        .filter_map(|warning| skipped_source_warning(warning))
773        .collect()
774}
775
776fn skipped_source_warning(warning: &str) -> Option<SkippedSource<'_>> {
777    const PREFIX: &str = "Configured source ";
778    const INFIX: &str = " was skipped: ";
779
780    let rest = warning.strip_prefix(PREFIX)?;
781    let (source, reason) = rest.split_once(INFIX)?;
782    Some(SkippedSource { source, reason })
783}
784
785fn included_source_summary(
786    coverage: &CoverageManifest,
787    events: &[EventEnvelope],
788    skipped_sources: &[SkippedSource<'_>],
789) -> Vec<String> {
790    let mut sources = Vec::new();
791    for source in &coverage.sources {
792        push_manifest_source(&mut sources, source, skipped_sources);
793    }
794    for event in events {
795        push_source(&mut sources, event.source.system.as_str());
796    }
797    sources
798}
799
800fn push_manifest_source(
801    sources: &mut Vec<String>,
802    candidate: &str,
803    skipped_sources: &[SkippedSource<'_>],
804) {
805    if skipped_sources
806        .iter()
807        .any(|skipped| source_matches(skipped.source, candidate))
808    {
809        return;
810    }
811
812    push_source(sources, candidate);
813}
814
815fn push_source(sources: &mut Vec<String>, candidate: &str) {
816    if sources
817        .iter()
818        .any(|source| source_matches(source, candidate))
819    {
820        return;
821    }
822
823    sources.push(candidate.to_string());
824}
825
826fn source_event_count(events: &[EventEnvelope], source: &str) -> usize {
827    events
828        .iter()
829        .filter(|event| source_matches(event.source.system.as_str(), source))
830        .count()
831}
832
833fn source_present(sources: &[String], needle: &str) -> bool {
834    sources.iter().any(|source| source_matches(source, needle))
835}
836
837fn source_matches(left: &str, right: &str) -> bool {
838    canonical_source_key(left) == canonical_source_key(right)
839}
840
841fn canonical_source_key(source: &str) -> String {
842    let key = source.trim().to_lowercase();
843
844    match key.as_str() {
845        "json" | "json_import" | "json import" | "json-import" => "json_import".to_string(),
846        "git" | "local_git" | "local git" | "local-git" => "local_git".to_string(),
847        _ => key,
848    }
849}
850
851fn display_source_label(source: &str) -> String {
852    if source.eq_ignore_ascii_case("json") {
853        return "JSON".to_string();
854    }
855
856    match canonical_source_key(source).as_str() {
857        "github" => "GitHub".to_string(),
858        "gitlab" => "GitLab".to_string(),
859        "jira" => "Jira".to_string(),
860        "linear" => "Linear".to_string(),
861        "json_import" => "JSON import".to_string(),
862        "local_git" => "Local git".to_string(),
863        "manual" => "Manual".to_string(),
864        "unknown" => "Unknown".to_string(),
865        _ => source.to_string(),
866    }
867}
868
869fn display_source_list(sources: &[String]) -> String {
870    if sources.is_empty() {
871        return "none recorded".to_string();
872    }
873
874    sources
875        .iter()
876        .map(|source| display_source_label(source))
877        .collect::<Vec<_>>()
878        .join(", ")
879}
880
881fn count_label(count: usize, singular: &str, plural: &str) -> String {
882    let noun = if count == 1 { singular } else { plural };
883    format!("{count} {noun}")
884}
885
886fn render_appendix(
887    out: &mut String,
888    events: &[EventEnvelope],
889    workstreams: &WorkstreamsFile,
890    mode: AppendixMode,
891) {
892    match mode {
893        AppendixMode::Full => render_full_appendix(out, events, workstreams),
894        AppendixMode::Summary => render_appendix_summary(out, workstreams),
895        AppendixMode::None => {}
896    }
897}
898
899fn render_full_appendix(out: &mut String, events: &[EventEnvelope], workstreams: &WorkstreamsFile) {
900    out.push_str("## Appendix: All Receipts\n\n");
901
902    if workstreams.workstreams.is_empty() {
903        return;
904    }
905
906    let by_id: HashMap<String, &EventEnvelope> =
907        events.iter().map(|e| (e.id.0.clone(), e)).collect();
908
909    for ws in &workstreams.workstreams {
910        if ws.events.is_empty() {
911            continue;
912        }
913
914        out.push_str(&format!("### {}\n\n", ws.title));
915
916        // Show all events for this workstream, not just receipts
917        for event_id in &ws.events {
918            if let Some(ev) = by_id.get(&event_id.0) {
919                out.push_str(&format!("{}\n", format_receipt_markdown(ev)));
920            }
921        }
922        out.push('\n');
923    }
924    out.push_str("---\n\n");
925}
926
927fn render_appendix_summary(out: &mut String, workstreams: &WorkstreamsFile) {
928    out.push_str("## Appendix: Receipt Summary\n\n");
929
930    if workstreams.workstreams.is_empty() {
931        return;
932    }
933
934    for ws in &workstreams.workstreams {
935        out.push_str(&format!("### {}\n\n", ws.title));
936        out.push_str(&format!("- Assigned events: {}\n", ws.events.len()));
937        out.push_str(&format!(
938            "- Curated receipt anchors: {}\n",
939            ws.receipts.len()
940        ));
941        out.push_str("- Full receipt detail omitted by appendix summary mode.\n\n");
942    }
943    out.push_str("---\n\n");
944}
945
946fn render_file_artifacts(out: &mut String) {
947    out.push_str("## File Artifacts\n\n");
948    out.push_str("- `packet.md` (this review packet)\n");
949    out.push_str("- `ledger.events.jsonl` (canonical events)\n");
950    out.push_str("- `coverage.manifest.json` (completeness + slicing)\n");
951    out.push_str("- `workstreams.suggested.yaml` (auto-generated workstream suggestions)\n");
952    out.push_str("- `workstreams.yaml` (curated workstreams, created after edits)\n");
953    out.push_str("- `bundle.manifest.json` (artifact manifest and checksums)\n");
954}
955
956#[cfg(test)]
957mod tests {
958    use super::*;
959    use chrono::{NaiveDate, TimeZone, Utc};
960    use shiplog_ids::{EventId, RunId, WorkstreamId};
961    use shiplog_schema::coverage::*;
962    use shiplog_schema::event::*;
963    use shiplog_schema::workstream::*;
964
965    fn create_test_pr(id: &str, number: u64, title: &str) -> EventEnvelope {
966        EventEnvelope {
967            id: EventId::from_parts(["pr", id]),
968            kind: EventKind::PullRequest,
969            occurred_at: Utc.timestamp_opt(0, 0).unwrap(),
970            actor: Actor {
971                login: "octo".into(),
972                id: None,
973            },
974            repo: RepoRef {
975                full_name: "owner/repo".into(),
976                html_url: None,
977                visibility: RepoVisibility::Public,
978            },
979            payload: EventPayload::PullRequest(PullRequestEvent {
980                number,
981                title: title.into(),
982                state: PullRequestState::Merged,
983                created_at: Utc.timestamp_opt(0, 0).unwrap(),
984                merged_at: Some(Utc.timestamp_opt(0, 0).unwrap()),
985                additions: Some(10),
986                deletions: Some(5),
987                changed_files: Some(2),
988                touched_paths_hint: vec![],
989                window: None,
990            }),
991            tags: vec![],
992            links: vec![Link {
993                label: "pr".into(),
994                url: format!("https://github.com/owner/repo/pull/{}", number),
995            }],
996            source: SourceRef {
997                system: SourceSystem::Github,
998                url: None,
999                opaque_id: Some(id.into()),
1000            },
1001        }
1002    }
1003
1004    fn create_test_manual(id: &str, event_type: ManualEventType, title: &str) -> EventEnvelope {
1005        EventEnvelope {
1006            id: EventId::from_parts(["manual", id]),
1007            kind: EventKind::Manual,
1008            occurred_at: Utc.timestamp_opt(0, 0).unwrap(),
1009            actor: Actor {
1010                login: "user".into(),
1011                id: None,
1012            },
1013            repo: RepoRef {
1014                full_name: "owner/repo".into(),
1015                html_url: None,
1016                visibility: RepoVisibility::Public,
1017            },
1018            payload: EventPayload::Manual(ManualEvent {
1019                event_type: event_type.clone(),
1020                title: title.into(),
1021                description: None,
1022                started_at: None,
1023                ended_at: None,
1024                impact: None,
1025            }),
1026            tags: vec![],
1027            links: vec![],
1028            source: SourceRef {
1029                system: SourceSystem::Manual,
1030                url: None,
1031                opaque_id: Some(id.into()),
1032            },
1033        }
1034    }
1035
1036    #[test]
1037    fn test_snapshot_empty_packet() {
1038        let renderer = MarkdownRenderer::new();
1039        let events: Vec<EventEnvelope> = vec![];
1040        let workstreams = WorkstreamsFile {
1041            version: 1,
1042            generated_at: Utc::now(),
1043            workstreams: vec![],
1044        };
1045        let coverage = CoverageManifest {
1046            run_id: RunId::now("test"),
1047            generated_at: Utc::now(),
1048            user: "test".into(),
1049            window: TimeWindow {
1050                since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1051                until: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1052            },
1053            mode: "test".into(),
1054            sources: vec![],
1055            slices: vec![],
1056            warnings: vec![],
1057            completeness: Completeness::Complete,
1058        };
1059
1060        let result = renderer
1061            .render_packet_markdown("test", "2024", &events, &workstreams, &coverage)
1062            .unwrap();
1063
1064        insta::assert_snapshot!(result);
1065    }
1066
1067    #[test]
1068    fn test_snapshot_full_packet() {
1069        let renderer = MarkdownRenderer::new();
1070        let events = vec![
1071            create_test_pr("1", 1, "Fix authentication bug"),
1072            create_test_manual("2", ManualEventType::Incident, "Handle production incident"),
1073        ];
1074        let workstreams = WorkstreamsFile {
1075            version: 1,
1076            generated_at: Utc::now(),
1077            workstreams: vec![Workstream {
1078                id: WorkstreamId::from_parts(["ws", "1"]),
1079                title: "Authentication".into(),
1080                summary: Some("Fixed auth bugs and improved security".into()),
1081                tags: vec![],
1082                receipts: vec![EventId::from_parts(["pr", "1"])],
1083                events: vec![EventId::from_parts(["pr", "1"])],
1084                stats: WorkstreamStats {
1085                    pull_requests: 1,
1086                    reviews: 0,
1087                    manual_events: 0,
1088                },
1089            }],
1090        };
1091        let coverage = CoverageManifest {
1092            run_id: RunId::now("test"),
1093            generated_at: Utc::now(),
1094            user: "test".into(),
1095            window: TimeWindow {
1096                since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1097                until: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1098            },
1099            mode: "test".into(),
1100            sources: vec!["github".into(), "manual".into()],
1101            slices: vec![],
1102            warnings: vec![],
1103            completeness: Completeness::Complete,
1104        };
1105
1106        let result = renderer
1107            .render_packet_markdown("test", "2024", &events, &workstreams, &coverage)
1108            .unwrap();
1109
1110        insta::assert_snapshot!(result);
1111    }
1112
1113    #[test]
1114    fn test_snapshot_partial_coverage() {
1115        let renderer = MarkdownRenderer::new();
1116        let events = vec![create_test_pr("1", 1, "Add feature")];
1117        let workstreams = WorkstreamsFile {
1118            version: 1,
1119            generated_at: Utc::now(),
1120            workstreams: vec![Workstream {
1121                id: WorkstreamId::from_parts(["ws", "1"]),
1122                title: "Feature".into(),
1123                summary: None,
1124                tags: vec![],
1125                receipts: vec![EventId::from_parts(["pr", "1"])],
1126                events: vec![EventId::from_parts(["pr", "1"])],
1127                stats: WorkstreamStats {
1128                    pull_requests: 1,
1129                    reviews: 0,
1130                    manual_events: 0,
1131                },
1132            }],
1133        };
1134        let coverage = CoverageManifest {
1135            run_id: RunId::now("test"),
1136            generated_at: Utc::now(),
1137            user: "test".into(),
1138            window: TimeWindow {
1139                since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1140                until: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1141            },
1142            mode: "test".into(),
1143            sources: vec!["github".into()],
1144            slices: vec![CoverageSlice {
1145                window: TimeWindow {
1146                    since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1147                    until: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1148                },
1149                query: "test".into(),
1150                total_count: 100,
1151                fetched: 50,
1152                incomplete_results: Some(true),
1153                notes: vec![],
1154            }],
1155            warnings: vec!["API rate limit hit".into()],
1156            completeness: Completeness::Partial,
1157        };
1158
1159        let result = renderer
1160            .render_packet_markdown("test", "2024", &events, &workstreams, &coverage)
1161            .unwrap();
1162
1163        insta::assert_snapshot!(result);
1164    }
1165
1166    #[test]
1167    fn test_snapshot_coverage_first_section_order() {
1168        let renderer = MarkdownRenderer::new().with_section_order(SectionOrder::CoverageFirst);
1169        let events = vec![
1170            create_test_pr("1", 1, "Fix bug"),
1171            create_test_pr("2", 2, "Add feature"),
1172        ];
1173        let workstreams = WorkstreamsFile {
1174            version: 1,
1175            generated_at: Utc::now(),
1176            workstreams: vec![Workstream {
1177                id: WorkstreamId::from_parts(["ws", "1"]),
1178                title: "Workstream 1".into(),
1179                summary: Some("Summary".into()),
1180                tags: vec![],
1181                receipts: vec![EventId::from_parts(["pr", "1"])],
1182                events: vec![EventId::from_parts(["pr", "1"])],
1183                stats: WorkstreamStats {
1184                    pull_requests: 1,
1185                    reviews: 0,
1186                    manual_events: 0,
1187                },
1188            }],
1189        };
1190        let coverage = CoverageManifest {
1191            run_id: RunId::now("test"),
1192            generated_at: Utc::now(),
1193            user: "test".into(),
1194            window: TimeWindow {
1195                since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1196                until: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1197            },
1198            mode: "merged".into(),
1199            sources: vec!["github".into()],
1200            slices: vec![],
1201            warnings: vec![],
1202            completeness: Completeness::Complete,
1203        };
1204
1205        let result = renderer
1206            .render_packet_markdown("testuser", "2024-W01", &events, &workstreams, &coverage)
1207            .unwrap();
1208
1209        // Coverage should appear first
1210        assert!(result.starts_with("## Coverage"));
1211    }
1212
1213    #[test]
1214    fn test_snapshot_events_with_reviews() {
1215        let renderer = MarkdownRenderer::new();
1216        let pr_event = create_test_pr("1", 1, "Add feature");
1217        let review_event = EventEnvelope {
1218            id: EventId::from_parts(["review", "1"]),
1219            kind: EventKind::Review,
1220            occurred_at: Utc.timestamp_opt(0, 0).unwrap(),
1221            actor: Actor {
1222                login: "reviewer".into(),
1223                id: None,
1224            },
1225            repo: RepoRef {
1226                full_name: "owner/repo".into(),
1227                html_url: None,
1228                visibility: RepoVisibility::Public,
1229            },
1230            payload: EventPayload::Review(ReviewEvent {
1231                pull_number: 1,
1232                pull_title: "Add feature".into(),
1233                submitted_at: Utc.timestamp_opt(0, 0).unwrap(),
1234                state: "approved".into(),
1235                window: None,
1236            }),
1237            tags: vec![],
1238            links: vec![Link {
1239                label: "pr".into(),
1240                url: "https://github.com/owner/repo/pull/1".into(),
1241            }],
1242            source: SourceRef {
1243                system: SourceSystem::Github,
1244                url: None,
1245                opaque_id: None,
1246            },
1247        };
1248        let events = vec![pr_event, review_event];
1249        let workstreams = WorkstreamsFile {
1250            version: 1,
1251            generated_at: Utc::now(),
1252            workstreams: vec![Workstream {
1253                id: WorkstreamId::from_parts(["ws", "1"]),
1254                title: "Feature Work".into(),
1255                summary: None,
1256                tags: vec![],
1257                receipts: vec![],
1258                events: vec![
1259                    EventId::from_parts(["pr", "1"]),
1260                    EventId::from_parts(["review", "1"]),
1261                ],
1262                stats: WorkstreamStats {
1263                    pull_requests: 1,
1264                    reviews: 1,
1265                    manual_events: 0,
1266                },
1267            }],
1268        };
1269        let coverage = CoverageManifest {
1270            run_id: RunId::now("test"),
1271            generated_at: Utc::now(),
1272            user: "test".into(),
1273            window: TimeWindow {
1274                since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1275                until: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1276            },
1277            mode: "test".into(),
1278            sources: vec!["github".into()],
1279            slices: vec![],
1280            warnings: vec![],
1281            completeness: Completeness::Complete,
1282        };
1283
1284        let result = renderer
1285            .render_packet_markdown("test", "2024", &events, &workstreams, &coverage)
1286            .unwrap();
1287
1288        // Should show both PRs and reviews in summary
1289        assert!(result.contains("1 PR, 1 review"));
1290    }
1291
1292    #[test]
1293    fn test_snapshot_multiple_workstreams() {
1294        let renderer = MarkdownRenderer::new();
1295        let events = vec![
1296            create_test_pr("1", 1, "Feature A"),
1297            create_test_pr("2", 2, "Feature B"),
1298            create_test_pr("3", 3, "Fix bug"),
1299        ];
1300        let workstreams = WorkstreamsFile {
1301            version: 1,
1302            generated_at: Utc::now(),
1303            workstreams: vec![
1304                Workstream {
1305                    id: WorkstreamId::from_parts(["ws", "a"]),
1306                    title: "Feature A".into(),
1307                    summary: Some("Work on feature A".into()),
1308                    tags: vec![],
1309                    receipts: vec![EventId::from_parts(["pr", "1"])],
1310                    events: vec![EventId::from_parts(["pr", "1"])],
1311                    stats: WorkstreamStats {
1312                        pull_requests: 1,
1313                        reviews: 0,
1314                        manual_events: 0,
1315                    },
1316                },
1317                Workstream {
1318                    id: WorkstreamId::from_parts(["ws", "b"]),
1319                    title: "Feature B & Bugfix".into(),
1320                    summary: Some("Work on feature B and bugfix".into()),
1321                    tags: vec![],
1322                    receipts: vec![
1323                        EventId::from_parts(["pr", "2"]),
1324                        EventId::from_parts(["pr", "3"]),
1325                    ],
1326                    events: vec![
1327                        EventId::from_parts(["pr", "2"]),
1328                        EventId::from_parts(["pr", "3"]),
1329                    ],
1330                    stats: WorkstreamStats {
1331                        pull_requests: 2,
1332                        reviews: 0,
1333                        manual_events: 0,
1334                    },
1335                },
1336            ],
1337        };
1338        let coverage = CoverageManifest {
1339            run_id: RunId::now("test"),
1340            generated_at: Utc::now(),
1341            user: "test".into(),
1342            window: TimeWindow {
1343                since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1344                until: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1345            },
1346            mode: "test".into(),
1347            sources: vec!["github".into()],
1348            slices: vec![],
1349            warnings: vec![],
1350            completeness: Completeness::Complete,
1351        };
1352
1353        let result = renderer
1354            .render_packet_markdown("test", "2024", &events, &workstreams, &coverage)
1355            .unwrap();
1356
1357        // Should show 2 workstreams
1358        assert!(result.contains("**Workstreams:** 2"));
1359    }
1360
1361    fn create_test_review(id: &str, state: &str, with_link: bool) -> EventEnvelope {
1362        let links = if with_link {
1363            vec![Link {
1364                label: "pr".into(),
1365                url: "https://github.com/owner/repo/pull/42".to_string(),
1366            }]
1367        } else {
1368            vec![]
1369        };
1370        EventEnvelope {
1371            id: EventId::from_parts(["review", id]),
1372            kind: EventKind::Review,
1373            occurred_at: Utc.timestamp_opt(0, 0).unwrap(),
1374            actor: Actor {
1375                login: "reviewer".into(),
1376                id: None,
1377            },
1378            repo: RepoRef {
1379                full_name: "owner/repo".into(),
1380                html_url: None,
1381                visibility: RepoVisibility::Public,
1382            },
1383            payload: EventPayload::Review(ReviewEvent {
1384                pull_number: 42,
1385                pull_title: "Some PR".into(),
1386                submitted_at: Utc.timestamp_opt(0, 0).unwrap(),
1387                state: state.into(),
1388                window: None,
1389            }),
1390            tags: vec![],
1391            links,
1392            source: SourceRef {
1393                system: SourceSystem::Github,
1394                url: None,
1395                opaque_id: Some(id.into()),
1396            },
1397        }
1398    }
1399
1400    fn make_coverage(slices: Vec<CoverageSlice>, warnings: Vec<String>) -> CoverageManifest {
1401        CoverageManifest {
1402            run_id: RunId::now("test"),
1403            generated_at: Utc::now(),
1404            user: "test".into(),
1405            window: TimeWindow {
1406                since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1407                until: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1408            },
1409            mode: "test".into(),
1410            sources: vec!["github".into()],
1411            slices,
1412            warnings,
1413            completeness: Completeness::Complete,
1414        }
1415    }
1416
1417    #[test]
1418    fn coverage_complete_slices_no_incomplete_message() {
1419        // All slices complete → output does NOT contain "incomplete results"
1420        // Kills >0 → >=0 mutation on partial_count check
1421        let coverage = make_coverage(
1422            vec![CoverageSlice {
1423                window: TimeWindow {
1424                    since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1425                    until: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1426                },
1427                query: "test".into(),
1428                total_count: 10,
1429                fetched: 10,
1430                incomplete_results: Some(false),
1431                notes: vec![],
1432            }],
1433            vec![],
1434        );
1435        let mut out = String::new();
1436        render_coverage(&mut out, &coverage, &[]);
1437        assert!(!out.contains("incomplete results"));
1438    }
1439
1440    #[test]
1441    fn coverage_with_total_equal_fetched_no_slicing_message() {
1442        // total_count == fetched → should NOT show "Slicing applied"
1443        // Kills > → >= mutation on `total_count > fetched`
1444        let coverage = make_coverage(
1445            vec![CoverageSlice {
1446                window: TimeWindow {
1447                    since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1448                    until: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1449                },
1450                query: "test".into(),
1451                total_count: 50,
1452                fetched: 50,
1453                incomplete_results: Some(false),
1454                notes: vec![],
1455            }],
1456            vec![],
1457        );
1458        let mut out = String::new();
1459        render_coverage(&mut out, &coverage, &[]);
1460        assert!(!out.contains("Slicing applied"));
1461    }
1462
1463    #[test]
1464    fn coverage_summary_complete_lists_no_known_gaps() {
1465        let coverage = make_coverage(vec![], vec![]);
1466        let mut out = String::new();
1467        render_coverage(&mut out, &coverage, &[]);
1468        assert!(out.contains("## Coverage and Limits"));
1469        assert!(out.contains("Included:\n- GitHub: 0 events\n"));
1470        assert!(out.contains("Skipped:\n- None recorded\n"));
1471        assert!(out.contains("Known gaps:\n- None recorded\n"));
1472    }
1473
1474    #[test]
1475    fn coverage_summary_lists_source_event_counts_and_manual_gap() {
1476        let events = vec![
1477            create_test_pr("1", 1, "Ship API"),
1478            create_test_manual("2", ManualEventType::Incident, "Incident follow-up"),
1479        ];
1480        let mut coverage = make_coverage(vec![], vec![]);
1481        coverage.sources = vec!["github".into(), "manual".into()];
1482
1483        let mut out = String::new();
1484        render_coverage(&mut out, &coverage, &events);
1485
1486        assert!(out.contains("Included:\n- GitHub: 1 event\n- Manual: 1 event\n"));
1487        assert!(out.contains("Skipped:\n- None recorded\n"));
1488        assert!(out.contains("Known gaps:\n- Manual events are user-provided\n"));
1489    }
1490
1491    #[test]
1492    fn coverage_summary_includes_event_provenance_not_only_manifest_sources() {
1493        let events = vec![
1494            create_test_pr("1", 1, "Ship API"),
1495            create_test_manual("2", ManualEventType::Incident, "Incident follow-up"),
1496        ];
1497        let mut coverage = make_coverage(vec![], vec![]);
1498        coverage.sources = vec!["github".into()];
1499
1500        let mut out = String::new();
1501        render_coverage(&mut out, &coverage, &events);
1502
1503        assert!(out.contains("Included:\n- GitHub: 1 event\n- Manual: 1 event\n"));
1504        assert!(out.contains("Known gaps:\n- Manual events are user-provided\n"));
1505    }
1506
1507    #[test]
1508    fn coverage_summary_lists_skipped_configured_sources() {
1509        let events = vec![create_test_manual(
1510            "manual-1",
1511            ManualEventType::Note,
1512            "Manual note",
1513        )];
1514        let mut coverage = make_coverage(
1515            vec![],
1516            vec!["Configured source json was skipped: missing coverage".into()],
1517        );
1518        coverage.sources = vec!["json".into(), "manual".into()];
1519        coverage.completeness = Completeness::Partial;
1520
1521        let mut out = String::new();
1522        render_coverage(&mut out, &coverage, &events);
1523
1524        assert!(out.contains("Included:\n- Manual: 1 event\n"));
1525        let included = out
1526            .split("Included:")
1527            .nth(1)
1528            .expect("coverage should include Included block")
1529            .split("Skipped:")
1530            .next()
1531            .expect("coverage should include Skipped after Included");
1532        assert!(!included.contains("JSON"));
1533        assert!(out.contains("Skipped:\n- JSON: missing coverage\n"));
1534        let known_gaps = out
1535            .split("Known gaps:")
1536            .nth(1)
1537            .expect("coverage should include Known gaps block")
1538            .split("Details:")
1539            .next()
1540            .expect("coverage should include Details after Known gaps");
1541        assert!(known_gaps.contains("- Overall completeness is Partial"));
1542        assert!(!known_gaps.contains("Configured source json was skipped"));
1543    }
1544
1545    #[test]
1546    fn coverage_summary_keeps_event_provenance_when_configured_source_skipped() {
1547        let events = vec![create_test_pr("1", 1, "Imported GitHub evidence")];
1548        let mut coverage = make_coverage(
1549            vec![],
1550            vec!["Configured source github was skipped: token missing".into()],
1551        );
1552        coverage.sources = vec!["github".into(), "json".into()];
1553        coverage.completeness = Completeness::Partial;
1554
1555        let mut out = String::new();
1556        render_coverage(&mut out, &coverage, &events);
1557
1558        let included = out
1559            .split("Included:")
1560            .nth(1)
1561            .expect("coverage should include Included block")
1562            .split("Skipped:")
1563            .next()
1564            .expect("coverage should include Skipped after Included");
1565        assert!(included.contains("- GitHub: 1 event\n"));
1566        assert!(out.contains("Skipped:\n- GitHub: token missing\n"));
1567    }
1568
1569    #[test]
1570    fn coverage_summary_does_not_collapse_distinct_custom_sources() {
1571        let mut custom_slash = create_test_manual("1", ManualEventType::Note, "Slash source");
1572        custom_slash.source.system = SourceSystem::Other("custom/system".into());
1573        let mut custom_dash = create_test_manual("2", ManualEventType::Note, "Dash source");
1574        custom_dash.source.system = SourceSystem::Other("custom-system".into());
1575        let mut custom_unicode = create_test_manual("3", ManualEventType::Note, "Unicode source");
1576        custom_unicode.source.system = SourceSystem::Other("日本語ソース".into());
1577        let events = vec![custom_slash, custom_dash, custom_unicode];
1578        let mut coverage = make_coverage(vec![], vec![]);
1579        coverage.sources = vec![
1580            "custom/system".into(),
1581            "custom-system".into(),
1582            "日本語ソース".into(),
1583        ];
1584
1585        let mut out = String::new();
1586        render_coverage(&mut out, &coverage, &events);
1587
1588        assert!(out.contains("- custom/system: 1 event\n"));
1589        assert!(out.contains("- custom-system: 1 event\n"));
1590        assert!(out.contains("- 日本語ソース: 1 event\n"));
1591    }
1592
1593    #[test]
1594    fn coverage_with_capped_slices_shows_slicing_applied() {
1595        // total_count > fetched → should show "Slicing applied"
1596        let coverage = make_coverage(
1597            vec![CoverageSlice {
1598                window: TimeWindow {
1599                    since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1600                    until: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1601                },
1602                query: "test".into(),
1603                total_count: 100,
1604                fetched: 50,
1605                incomplete_results: Some(false),
1606                notes: vec![],
1607            }],
1608            vec![],
1609        );
1610        let mut out = String::new();
1611        render_coverage(&mut out, &coverage, &[]);
1612        assert!(out.contains("Slicing applied"));
1613        assert!(out.contains("fetched 50/100"));
1614    }
1615
1616    #[test]
1617    fn coverage_summary_partial_lists_warnings_and_slice_limits() {
1618        let mut coverage = make_coverage(
1619            vec![CoverageSlice {
1620                window: TimeWindow {
1621                    since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1622                    until: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1623                },
1624                query: "test".into(),
1625                total_count: 100,
1626                fetched: 50,
1627                incomplete_results: Some(true),
1628                notes: vec![],
1629            }],
1630            vec!["API returned partial results".into()],
1631        );
1632        coverage.completeness = Completeness::Partial;
1633
1634        let mut out = String::new();
1635        render_coverage(&mut out, &coverage, &[]);
1636        assert!(out.contains("- Query slices: 1 slice, fetched 50 of 100 reported results"));
1637        assert!(out.contains("- Overall completeness is Partial"));
1638        assert!(out.contains("- API returned partial results"));
1639        assert!(out.contains("- 1 query slice reported incomplete results"));
1640        assert!(out.contains("- 1 query slice fetched fewer results than reported"));
1641    }
1642
1643    #[test]
1644    fn coverage_with_4_plus_capped_slices_shows_and_more() {
1645        // 4+ capped slices → shows first 3 then "... and N more"
1646        // Kills >3 → >=3 mutation
1647        let slices: Vec<CoverageSlice> = (0..5)
1648            .map(|i| CoverageSlice {
1649                window: TimeWindow {
1650                    since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1651                    until: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1652                },
1653                query: format!("query-{i}"),
1654                total_count: 100,
1655                fetched: 50,
1656                incomplete_results: Some(false),
1657                notes: vec![],
1658            })
1659            .collect();
1660        let coverage = make_coverage(slices, vec![]);
1661        let mut out = String::new();
1662        render_coverage(&mut out, &coverage, &[]);
1663        assert!(out.contains("... and 2 more"));
1664    }
1665
1666    #[test]
1667    fn coverage_with_exactly_3_capped_slices_no_and_more() {
1668        // Exactly 3 capped slices → no "... and N more"
1669        // Kills >3 → >=3 (with 3 slices, > 3 is false, so no "and more")
1670        let slices: Vec<CoverageSlice> = (0..3)
1671            .map(|i| CoverageSlice {
1672                window: TimeWindow {
1673                    since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1674                    until: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1675                },
1676                query: format!("query-{i}"),
1677                total_count: 100,
1678                fetched: 50,
1679                incomplete_results: Some(false),
1680                notes: vec![],
1681            })
1682            .collect();
1683        let coverage = make_coverage(slices, vec![]);
1684        let mut out = String::new();
1685        render_coverage(&mut out, &coverage, &[]);
1686        assert!(out.contains("Slicing applied"));
1687        assert!(!out.contains("... and"));
1688    }
1689
1690    #[test]
1691    fn coverage_empty_slices_no_incomplete_no_slicing() {
1692        // No slices at all → no "incomplete results" or "Slicing applied" messages.
1693        // Strengthens > → >= mutation coverage on partial_count and capped checks.
1694        let coverage = make_coverage(vec![], vec![]);
1695        let mut out = String::new();
1696        render_coverage(&mut out, &coverage, &[]);
1697        assert!(!out.contains("incomplete results"));
1698        assert!(!out.contains("Slicing applied"));
1699        assert!(!out.contains("Query slices"));
1700    }
1701
1702    #[test]
1703    fn coverage_none_incomplete_results_no_warning() {
1704        // incomplete_results: None → defaults to false → no warning.
1705        // Kills > → >= mutation on partial_count when None is present.
1706        let coverage = make_coverage(
1707            vec![CoverageSlice {
1708                window: TimeWindow {
1709                    since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1710                    until: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1711                },
1712                query: "test".into(),
1713                total_count: 10,
1714                fetched: 10,
1715                incomplete_results: None,
1716                notes: vec![],
1717            }],
1718            vec![],
1719        );
1720        let mut out = String::new();
1721        render_coverage(&mut out, &coverage, &[]);
1722        assert!(!out.contains("incomplete results"));
1723    }
1724
1725    #[test]
1726    fn review_event_shows_review_tag_and_state() {
1727        // Kills Review match arm deletion in format_receipt_markdown
1728        let ev = create_test_review("r1", "approved", false);
1729        let formatted = format_receipt_markdown(&ev);
1730        assert!(formatted.contains("[Review]"));
1731        assert!(formatted.contains("approved"));
1732    }
1733
1734    #[test]
1735    fn review_with_pr_link_shows_markdown_link() {
1736        // Kills == → != mutation on `l.label == "pr"` check
1737        let ev = create_test_review("r2", "changes_requested", true);
1738        let formatted = format_receipt_markdown(&ev);
1739        assert!(formatted.contains("[Review]"));
1740        assert!(formatted.contains("[owner/repo]"));
1741        assert!(formatted.contains("(https://github.com/owner/repo/pull/42)"));
1742    }
1743
1744    #[test]
1745    fn review_without_pr_link_shows_plain_repo() {
1746        // No "pr" link → repo name is shown without markdown link syntax
1747        let ev = create_test_review("r3", "approved", false);
1748        let formatted = format_receipt_markdown(&ev);
1749        assert!(formatted.contains("owner/repo"));
1750        assert!(!formatted.contains("]("));
1751    }
1752
1753    #[test]
1754    fn test_snapshot_events_with_all_manual_types() {
1755        let renderer = MarkdownRenderer::new();
1756        let events = vec![
1757            create_test_manual("1", ManualEventType::Note, "Take notes"),
1758            create_test_manual("2", ManualEventType::Incident, "Fix outage"),
1759            create_test_manual("3", ManualEventType::Design, "Design review"),
1760            create_test_manual("4", ManualEventType::Mentoring, "Mentor junior"),
1761            create_test_manual("5", ManualEventType::Launch, "Launch feature"),
1762            create_test_manual("6", ManualEventType::Migration, "Migrate data"),
1763            create_test_manual("7", ManualEventType::Review, "Code review"),
1764            create_test_manual("8", ManualEventType::Other, "Other work"),
1765        ];
1766        let workstreams = WorkstreamsFile {
1767            version: 1,
1768            generated_at: Utc::now(),
1769            workstreams: vec![Workstream {
1770                id: WorkstreamId::from_parts(["ws", "1"]),
1771                title: "Mixed Work".into(),
1772                summary: None,
1773                tags: vec![],
1774                receipts: vec![],
1775                events: events.iter().map(|e| e.id.clone()).collect(),
1776                stats: WorkstreamStats {
1777                    pull_requests: 0,
1778                    reviews: 0,
1779                    manual_events: 8,
1780                },
1781            }],
1782        };
1783        let coverage = CoverageManifest {
1784            run_id: RunId::now("test"),
1785            generated_at: Utc::now(),
1786            user: "test".into(),
1787            window: TimeWindow {
1788                since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1789                until: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1790            },
1791            mode: "test".into(),
1792            sources: vec!["manual".into()],
1793            slices: vec![],
1794            warnings: vec![],
1795            completeness: Completeness::Complete,
1796        };
1797
1798        let result = renderer
1799            .render_packet_markdown("test", "2024", &events, &workstreams, &coverage)
1800            .unwrap();
1801
1802        // Should show manual events in summary
1803        assert!(result.contains("0 PRs, 0 reviews, 8 manual"));
1804        // Should have all emoji types
1805        assert!(result.contains("📝")); // Note
1806        assert!(result.contains("🚨")); // Incident
1807        assert!(result.contains("🏗️")); // Design
1808        assert!(result.contains("👨‍🏫")); // Mentoring
1809        assert!(result.contains("🚀")); // Launch
1810        assert!(result.contains("🔄")); // Migration
1811        assert!(result.contains("👀")); // Review
1812        assert!(result.contains("📌")); // Other
1813    }
1814}