1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum SectionOrder {
32 #[default]
34 Default,
35 CoverageFirst,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum AppendixMode {
44 #[default]
46 Full,
47 Summary,
49 None,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct MarkdownRenderOptions {
59 pub receipt_limit: usize,
61 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
74pub struct MarkdownRenderer {
110 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 pub fn new() -> Self {
133 Self::default()
134 }
135
136 pub fn with_section_order(mut self, order: SectionOrder) -> Self {
147 self.section_order = order;
148 self
149 }
150
151 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 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 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 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 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 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 out.push_str(&format!("**Window:** {}\n\n", window_label));
296
297 out.push_str(&format!(
299 "**Workstreams:** {}\n\n",
300 workstreams.workstreams.len()
301 ));
302
303 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 out.push_str(&format!("**Coverage:** {:?}\n\n", coverage.completeness));
325
326 out.push_str(&format!(
328 "**Sources:** {}\n\n",
329 display_source_list(&coverage.sources)
330 ));
331
332 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
344fn 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 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 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 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 out.push_str(&format!(
700 "- **Date window:** {} to {}\n",
701 coverage.window.since, coverage.window.until
702 ));
703
704 out.push_str(&format!("- **Mode:** {}\n", coverage.mode));
706
707 out.push_str(&format!(
709 "- **Sources:** {}\n",
710 display_source_list(&coverage.sources)
711 ));
712
713 out.push_str(&format!(
715 "- **Completeness:** {:?}\n",
716 coverage.completeness
717 ));
718
719 if !coverage.slices.is_empty() {
721 out.push_str(&format!("- **Query slices:** {}\n", coverage.slices.len()));
722
723 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(result.contains("0 PRs, 0 reviews, 8 manual"));
1804 assert!(result.contains("📝")); assert!(result.contains("🚨")); assert!(result.contains("🏗️")); assert!(result.contains("👨🏫")); assert!(result.contains("🚀")); assert!(result.contains("🔄")); assert!(result.contains("👀")); assert!(result.contains("📌")); }
1814}