1use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12
13use crate::check::{CheckOutcome, MatchKind, UncertainReason};
14use crate::confidence::{ConfidenceLabel, ConfidenceReason, ConfidenceScore};
15use crate::escalation::TransportTier;
16use crate::identity::IdentityCluster;
17use crate::profile::{EvidenceSource, ProfileEvidence, ProfileEvidenceKind};
18
19pub const INVESTIGATION_REPORT_SCHEMA_VERSION: u16 = 3;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct InvestigationReport {
25 pub schema_version: u16,
27 pub username: String,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub generated_at_ms: Option<u64>,
33 pub summary: ReportSummary,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub found_accounts: Vec<ReportAccount>,
38 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub high_confidence_accounts: Vec<ReportAccount>,
42 #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 pub uncertain_accounts: Vec<ReportUncertainAccount>,
46 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub evidence_table: Vec<ReportEvidence>,
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub identity_clusters: Vec<IdentityCluster>,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 pub timeline: Vec<ReportTimelineEvent>,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub disabled_sites: Vec<ReportDisabledSite>,
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub limitations: Vec<ReportLimitation>,
61}
62
63impl InvestigationReport {
64 #[must_use]
67 pub fn from_scan(
68 username: impl Into<String>,
69 outcomes: &[CheckOutcome],
70 identity_clusters: Vec<IdentityCluster>,
71 ) -> Self {
72 Self::builder(username, outcomes)
73 .identity_clusters(identity_clusters)
74 .build()
75 }
76
77 #[must_use]
79 pub fn builder(
80 username: impl Into<String>,
81 outcomes: &[CheckOutcome],
82 ) -> InvestigationReportBuilder {
83 InvestigationReportBuilder {
84 username: username.into(),
85 outcomes: outcomes.to_vec(),
86 identity_clusters: Vec::new(),
87 timeline: Vec::new(),
88 disabled_sites: Vec::new(),
89 generated_at_ms: None,
90 }
91 }
92}
93
94#[derive(Debug, Clone)]
96pub struct InvestigationReportBuilder {
97 username: String,
98 outcomes: Vec<CheckOutcome>,
99 identity_clusters: Vec<IdentityCluster>,
100 timeline: Vec<ReportTimelineEvent>,
101 disabled_sites: Vec<ReportDisabledSite>,
102 generated_at_ms: Option<u64>,
103}
104
105impl InvestigationReportBuilder {
106 #[must_use]
108 pub fn identity_clusters(mut self, clusters: Vec<IdentityCluster>) -> Self {
109 self.identity_clusters = clusters;
110 self
111 }
112
113 #[must_use]
115 pub fn timeline(mut self, timeline: Vec<ReportTimelineEvent>) -> Self {
116 self.timeline = timeline;
117 self
118 }
119
120 #[must_use]
122 pub fn disabled_sites(mut self, disabled_sites: Vec<ReportDisabledSite>) -> Self {
123 self.disabled_sites = disabled_sites;
124 self
125 }
126
127 #[must_use]
129 pub fn generated_at_ms(mut self, generated_at_ms: u64) -> Self {
130 self.generated_at_ms = Some(generated_at_ms);
131 self
132 }
133
134 #[must_use]
136 pub fn build(self) -> InvestigationReport {
137 let mut sections = collect_report_sections(
138 &self.outcomes,
139 &self.identity_clusters,
140 &self.disabled_sites,
141 );
142 sort_report_sections(&mut sections);
143 let high_confidence_accounts = high_confidence_accounts(§ions.found_accounts);
144 let summary = ReportSummary::from_parts(
145 &self.outcomes,
146 §ions.found_accounts,
147 §ions.uncertain_accounts,
148 sections.evidence_table.len(),
149 &self.identity_clusters,
150 self.timeline.len(),
151 self.disabled_sites.len(),
152 );
153
154 InvestigationReport {
155 schema_version: INVESTIGATION_REPORT_SCHEMA_VERSION,
156 username: self.username,
157 generated_at_ms: self.generated_at_ms,
158 summary,
159 found_accounts: sections.found_accounts,
160 high_confidence_accounts,
161 uncertain_accounts: sections.uncertain_accounts,
162 evidence_table: sections.evidence_table,
163 identity_clusters: self.identity_clusters,
164 timeline: self.timeline,
165 disabled_sites: self.disabled_sites,
166 limitations: sections.limitations,
167 }
168 }
169}
170
171#[derive(Debug, Default)]
172struct ReportSections {
173 found_accounts: Vec<ReportAccount>,
174 uncertain_accounts: Vec<ReportUncertainAccount>,
175 evidence_table: Vec<ReportEvidence>,
176 limitations: Vec<ReportLimitation>,
177}
178
179fn collect_report_sections(
180 outcomes: &[CheckOutcome],
181 identity_clusters: &[IdentityCluster],
182 disabled_sites: &[ReportDisabledSite],
183) -> ReportSections {
184 let cluster_ids = cluster_ids_by_member(identity_clusters);
185 let mut sections = ReportSections::default();
186
187 for outcome in outcomes {
188 collect_outcome_sections(outcome, &cluster_ids, &mut sections);
189 }
190 for disabled in disabled_sites {
191 sections.limitations.push(ReportLimitation {
192 kind: ReportLimitationKind::DisabledSiteOmitted,
193 site: Some(disabled.name.clone()),
194 detail: Some(disabled.disabled_reason.clone()),
195 });
196 }
197
198 sections
199}
200
201fn collect_outcome_sections(
202 outcome: &CheckOutcome,
203 cluster_ids: &BTreeMap<(String, String), Vec<String>>,
204 sections: &mut ReportSections,
205) {
206 match outcome.kind {
207 MatchKind::Found => collect_found_sections(outcome, cluster_ids, sections),
208 MatchKind::Uncertain => {
209 sections
210 .uncertain_accounts
211 .push(ReportUncertainAccount::from_outcome(outcome));
212 push_uncertain_limitations(outcome, &mut sections.limitations);
213 }
214 MatchKind::NotFound => {}
215 }
216
217 if outcome
218 .confidence
219 .reasons
220 .iter()
221 .any(|reason| matches!(reason, ConfidenceReason::TransportBlocked))
222 {
223 sections.limitations.push(ReportLimitation::site(
224 ReportLimitationKind::TransportBlocked,
225 &outcome.site,
226 ));
227 }
228}
229
230fn collect_found_sections(
231 outcome: &CheckOutcome,
232 cluster_ids: &BTreeMap<(String, String), Vec<String>>,
233 sections: &mut ReportSections,
234) {
235 let account = ReportAccount::from_outcome(
236 outcome,
237 cluster_ids
238 .get(&(outcome.site.clone(), outcome.url.clone()))
239 .cloned()
240 .unwrap_or_default(),
241 );
242 if account.profile_evidence.is_empty() {
243 sections.limitations.push(ReportLimitation::site(
244 ReportLimitationKind::MissingProfileEvidence,
245 &account.site,
246 ));
247 }
248 if matches!(account.confidence.label, ConfidenceLabel::Low) {
249 sections.limitations.push(ReportLimitation::site(
250 ReportLimitationKind::LowConfidenceFound,
251 &account.site,
252 ));
253 }
254 sections.found_accounts.push(account);
255 sections
256 .evidence_table
257 .extend(outcome.profile_evidence.iter().map(ReportEvidence::from));
258}
259
260fn sort_report_sections(sections: &mut ReportSections) {
261 sections.found_accounts.sort_by(|left, right| {
262 right
263 .confidence
264 .score
265 .cmp(&left.confidence.score)
266 .then_with(|| left.site.cmp(&right.site))
267 .then_with(|| left.url.cmp(&right.url))
268 });
269 sections.uncertain_accounts.sort_by(|left, right| {
270 left.site
271 .cmp(&right.site)
272 .then_with(|| left.url.cmp(&right.url))
273 });
274 sections.evidence_table.sort_by(|left, right| {
275 left.site
276 .cmp(&right.site)
277 .then_with(|| evidence_kind_rank(left.kind).cmp(&evidence_kind_rank(right.kind)))
278 .then_with(|| left.value.cmp(&right.value))
279 });
280 sections.limitations.sort_by(|left, right| {
281 left.site
282 .cmp(&right.site)
283 .then_with(|| left.kind.cmp(&right.kind))
284 .then_with(|| left.detail.cmp(&right.detail))
285 });
286 sections.limitations.dedup();
287}
288
289fn high_confidence_accounts(found_accounts: &[ReportAccount]) -> Vec<ReportAccount> {
290 found_accounts
291 .iter()
292 .filter(|account| {
293 matches!(
294 account.confidence.label,
295 ConfidenceLabel::High | ConfidenceLabel::Verified
296 )
297 })
298 .cloned()
299 .collect()
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304pub struct ReportSummary {
305 pub total: usize,
307 pub found: usize,
309 pub not_found: usize,
311 pub uncertain: usize,
313 pub high_confidence_found: usize,
315 pub found_with_profile_evidence: usize,
317 pub profile_evidence_items: usize,
319 pub identity_clusters: usize,
321 pub uncertain_identity_clusters: usize,
323 pub clustered_profiles: usize,
325 pub timeline_events: usize,
327 pub disabled_sites: usize,
329}
330
331impl ReportSummary {
332 fn from_parts(
333 outcomes: &[CheckOutcome],
334 found_accounts: &[ReportAccount],
335 uncertain_accounts: &[ReportUncertainAccount],
336 profile_evidence_items: usize,
337 identity_clusters: &[IdentityCluster],
338 timeline_events: usize,
339 disabled_sites: usize,
340 ) -> Self {
341 Self {
342 total: outcomes.len(),
343 found: found_accounts.len(),
344 not_found: outcomes
345 .iter()
346 .filter(|outcome| outcome.kind == MatchKind::NotFound)
347 .count(),
348 uncertain: uncertain_accounts.len(),
349 high_confidence_found: found_accounts
350 .iter()
351 .filter(|account| {
352 matches!(
353 account.confidence.label,
354 ConfidenceLabel::High | ConfidenceLabel::Verified
355 )
356 })
357 .count(),
358 found_with_profile_evidence: found_accounts
359 .iter()
360 .filter(|account| !account.profile_evidence.is_empty())
361 .count(),
362 profile_evidence_items,
363 identity_clusters: identity_clusters.len(),
364 uncertain_identity_clusters: identity_clusters
365 .iter()
366 .filter(|cluster| cluster.uncertain)
367 .count(),
368 clustered_profiles: identity_clusters
369 .iter()
370 .map(|cluster| cluster.members.len())
371 .sum(),
372 timeline_events,
373 disabled_sites,
374 }
375 }
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380pub struct ReportAccount {
381 pub site: String,
383 pub url: String,
385 pub confidence: ConfidenceScore,
387 #[serde(default, skip_serializing_if = "Vec::is_empty")]
389 pub signal_evidence: Vec<String>,
390 #[serde(default, skip_serializing_if = "Vec::is_empty")]
392 pub profile_evidence: Vec<ProfileEvidence>,
393 #[serde(default, skip_serializing_if = "Vec::is_empty")]
395 pub cluster_ids: Vec<String>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub transport: Option<TransportTier>,
399 #[serde(default, skip_serializing_if = "is_zero_u8")]
401 pub escalations: u8,
402 pub elapsed_ms: u64,
404}
405
406impl ReportAccount {
407 fn from_outcome(outcome: &CheckOutcome, cluster_ids: Vec<String>) -> Self {
408 Self {
409 site: outcome.site.clone(),
410 url: outcome.url.clone(),
411 confidence: outcome.confidence.clone(),
412 signal_evidence: outcome.evidence.clone(),
413 profile_evidence: outcome.profile_evidence.clone(),
414 cluster_ids,
415 transport: outcome.transport,
416 escalations: outcome.escalations,
417 elapsed_ms: outcome.elapsed_ms,
418 }
419 }
420}
421
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
424pub struct ReportUncertainAccount {
425 pub site: String,
427 pub url: String,
429 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub reason: Option<UncertainReason>,
432 pub confidence: ConfidenceScore,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub transport: Option<TransportTier>,
437 #[serde(default, skip_serializing_if = "is_zero_u8")]
439 pub escalations: u8,
440 pub elapsed_ms: u64,
442}
443
444impl ReportUncertainAccount {
445 fn from_outcome(outcome: &CheckOutcome) -> Self {
446 Self {
447 site: outcome.site.clone(),
448 url: outcome.url.clone(),
449 reason: outcome.reason.clone(),
450 confidence: outcome.confidence.clone(),
451 transport: outcome.transport,
452 escalations: outcome.escalations,
453 elapsed_ms: outcome.elapsed_ms,
454 }
455 }
456}
457
458#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
460pub struct ReportEvidence {
461 pub site: String,
463 pub url: String,
465 pub kind: ProfileEvidenceKind,
467 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub field: Option<String>,
470 pub value: String,
472 pub source: EvidenceSource,
474}
475
476impl From<&ProfileEvidence> for ReportEvidence {
477 fn from(evidence: &ProfileEvidence) -> Self {
478 Self {
479 site: evidence.source.site.clone(),
480 url: evidence.source.url.clone(),
481 kind: evidence.kind,
482 field: evidence.field.clone(),
483 value: evidence.value.clone(),
484 source: evidence.source.clone(),
485 }
486 }
487}
488
489#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
491pub struct ReportTimelineEvent {
492 pub kind: ReportTimelineEventKind,
494 #[serde(default, skip_serializing_if = "Option::is_none")]
496 pub site: Option<String>,
497 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub scan_id: Option<String>,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
502 pub observed_at_ms: Option<u64>,
503 #[serde(default, skip_serializing_if = "Option::is_none")]
505 pub detail: Option<String>,
506}
507
508#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
510#[serde(rename_all = "snake_case")]
511pub enum ReportTimelineEventKind {
512 AddedFound,
514 RemovedFound,
516 VerdictChanged,
518 EvidenceChanged,
520 Reappeared,
522}
523
524#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
526pub struct ReportDisabledSite {
527 pub name: String,
529 pub url: String,
531 #[serde(default, skip_serializing_if = "Vec::is_empty")]
533 pub tags: Vec<String>,
534 pub disabled_reason: String,
536}
537
538#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
540pub struct ReportLimitation {
541 pub kind: ReportLimitationKind,
543 #[serde(default, skip_serializing_if = "Option::is_none")]
545 pub site: Option<String>,
546 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub detail: Option<String>,
549}
550
551impl ReportLimitation {
552 fn site(kind: ReportLimitationKind, site: &str) -> Self {
553 Self {
554 kind,
555 site: Some(site.to_owned()),
556 detail: None,
557 }
558 }
559}
560
561#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
563#[serde(rename_all = "snake_case")]
564pub enum ReportLimitationKind {
565 LowConfidenceFound,
567 MissingProfileEvidence,
569 UncertainOutcome,
571 SessionRequired,
573 GeoUnavailable,
575 Captcha,
577 RateLimited,
579 BrowserBudget,
581 TransportBlocked,
583 DisabledSiteOmitted,
585}
586
587fn cluster_ids_by_member(clusters: &[IdentityCluster]) -> BTreeMap<(String, String), Vec<String>> {
588 let mut by_member: BTreeMap<(String, String), Vec<String>> = BTreeMap::new();
589 for cluster in clusters {
590 for member in &cluster.members {
591 by_member
592 .entry((member.site.clone(), member.url.clone()))
593 .or_default()
594 .push(cluster.id.clone());
595 }
596 }
597 by_member
598}
599
600fn push_uncertain_limitations(outcome: &CheckOutcome, limitations: &mut Vec<ReportLimitation>) {
601 limitations.push(ReportLimitation::site(
602 ReportLimitationKind::UncertainOutcome,
603 &outcome.site,
604 ));
605
606 let Some(reason) = &outcome.reason else {
607 return;
608 };
609 let kind = match reason {
610 UncertainReason::SessionRequired => Some(ReportLimitationKind::SessionRequired),
611 UncertainReason::GeoUnavailable => Some(ReportLimitationKind::GeoUnavailable),
612 UncertainReason::Captcha => Some(ReportLimitationKind::Captcha),
613 UncertainReason::RateLimited => Some(ReportLimitationKind::RateLimited),
614 UncertainReason::BrowserBudget => Some(ReportLimitationKind::BrowserBudget),
615 UncertainReason::CloudflareChallenge
616 | UncertainReason::BrowserFailed(_)
617 | UncertainReason::Network(_)
618 | UncertainReason::BodyRead(_) => Some(ReportLimitationKind::TransportBlocked),
619 UncertainReason::RobotsDisallowed
620 | UncertainReason::Deadline
621 | UncertainReason::SchedulerClosed
622 | UncertainReason::UsernameNotAllowed
623 | UncertainReason::Other(_) => None,
624 };
625
626 if let Some(kind) = kind {
627 limitations.push(ReportLimitation {
628 kind,
629 site: Some(outcome.site.clone()),
630 detail: Some(reason.to_string()),
631 });
632 }
633}
634
635const fn evidence_kind_rank(kind: ProfileEvidenceKind) -> u8 {
636 match kind {
637 ProfileEvidenceKind::Username => 0,
638 ProfileEvidenceKind::DisplayName => 1,
639 ProfileEvidenceKind::Bio => 2,
640 ProfileEvidenceKind::AvatarUrl => 3,
641 ProfileEvidenceKind::AvatarHash => 4,
642 ProfileEvidenceKind::ExternalLink => 5,
643 ProfileEvidenceKind::Location => 6,
644 ProfileEvidenceKind::JoinedDate => 7,
645 ProfileEvidenceKind::ProfileTitle => 8,
646 ProfileEvidenceKind::MetaDescription => 9,
647 ProfileEvidenceKind::ExtractedField => 10,
648 }
649}
650
651#[allow(clippy::trivially_copy_pass_by_ref)]
652fn is_zero_u8(n: &u8) -> bool {
653 *n == 0
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659 use crate::{ProfileEvidence, build_identity_clusters};
660
661 fn outcome(site: &str, kind: MatchKind, label: ConfidenceLabel, score: u8) -> CheckOutcome {
662 CheckOutcome {
663 site: site.to_owned(),
664 url: format!("https://{}.example/alice", site.to_lowercase()),
665 kind,
666 reason: None,
667 elapsed_ms: 10,
668 enrichment: BTreeMap::new(),
669 evidence: Vec::new(),
670 profile_evidence: Vec::new(),
671 confidence: ConfidenceScore {
672 score,
673 label,
674 reasons: Vec::new(),
675 },
676 transport: Some(TransportTier::Http),
677 escalations: 0,
678 }
679 }
680
681 fn found_with_website(site: &str, website: &str, score: u8) -> CheckOutcome {
682 let mut outcome = outcome(site, MatchKind::Found, ConfidenceLabel::High, score);
683 outcome.evidence = vec!["HTTP 200 (status_found)".to_owned()];
684 outcome.profile_evidence = vec![ProfileEvidence::from_enrichment(
685 site,
686 &outcome.url,
687 "website",
688 website,
689 )];
690 outcome
691 }
692
693 #[test]
694 fn builds_report_sections_from_outcomes_and_clusters() {
695 let github = found_with_website("GitHub", "https://alice.dev", 90);
696 let gitlab = found_with_website("GitLab", "https://alice.dev", 82);
697 let mut mastodon = outcome("Mastodon", MatchKind::Uncertain, ConfidenceLabel::Low, 20);
698 mastodon.reason = Some(UncertainReason::SessionRequired);
699 let hn = outcome(
700 "HackerNews",
701 MatchKind::NotFound,
702 ConfidenceLabel::Medium,
703 60,
704 );
705 let outcomes = vec![github, gitlab, mastodon, hn];
706 let clusters = build_identity_clusters("alice", &outcomes);
707
708 let report = InvestigationReport::from_scan("alice", &outcomes, clusters);
709
710 assert_eq!(report.schema_version, INVESTIGATION_REPORT_SCHEMA_VERSION);
711 assert_eq!(report.username, "alice");
712 assert_eq!(report.summary.total, 4);
713 assert_eq!(report.summary.found, 2);
714 assert_eq!(report.summary.not_found, 1);
715 assert_eq!(report.summary.uncertain, 1);
716 assert_eq!(report.summary.high_confidence_found, 2);
717 assert_eq!(report.summary.profile_evidence_items, 2);
718 assert_eq!(report.summary.identity_clusters, 1);
719 assert_eq!(report.evidence_table.len(), 2);
720 assert_eq!(report.high_confidence_accounts.len(), 2);
721 assert_eq!(report.uncertain_accounts[0].site, "Mastodon");
722 assert!(
723 report
724 .found_accounts
725 .iter()
726 .all(|account| account.cluster_ids == ["identity-0001"])
727 );
728 }
729
730 #[test]
731 fn records_limitations_for_weak_inputs() {
732 let low_found = outcome("GitHub", MatchKind::Found, ConfidenceLabel::Low, 35);
733 let mut captcha = outcome("Forum", MatchKind::Uncertain, ConfidenceLabel::Low, 10);
734 captcha.reason = Some(UncertainReason::Captcha);
735 captcha.confidence.reasons = vec![ConfidenceReason::TransportBlocked];
736 let disabled = ReportDisabledSite {
737 name: "Threads".to_owned(),
738 url: "https://threads.net/@{username}".to_owned(),
739 tags: vec!["social".to_owned()],
740 disabled_reason: "login wall".to_owned(),
741 };
742
743 let report = InvestigationReport::builder("alice", &[low_found, captcha])
744 .disabled_sites(vec![disabled])
745 .build();
746 let kinds: Vec<_> = report
747 .limitations
748 .iter()
749 .map(|limitation| limitation.kind)
750 .collect();
751
752 assert!(kinds.contains(&ReportLimitationKind::LowConfidenceFound));
753 assert!(kinds.contains(&ReportLimitationKind::MissingProfileEvidence));
754 assert!(kinds.contains(&ReportLimitationKind::UncertainOutcome));
755 assert!(kinds.contains(&ReportLimitationKind::Captcha));
756 assert!(kinds.contains(&ReportLimitationKind::TransportBlocked));
757 assert!(kinds.contains(&ReportLimitationKind::DisabledSiteOmitted));
758 assert_eq!(report.summary.disabled_sites, 1);
759 }
760
761 #[test]
762 fn carries_timeline_and_generation_metadata() {
763 let timeline = vec![ReportTimelineEvent {
764 kind: ReportTimelineEventKind::AddedFound,
765 site: Some("GitHub".to_owned()),
766 scan_id: Some("scan_1".to_owned()),
767 observed_at_ms: Some(1_781_192_451_000),
768 detail: Some("first seen".to_owned()),
769 }];
770 let report = InvestigationReport::builder("alice", &[])
771 .timeline(timeline.clone())
772 .generated_at_ms(1_781_192_452_000)
773 .build();
774
775 assert_eq!(report.generated_at_ms, Some(1_781_192_452_000));
776 assert_eq!(report.timeline, timeline);
777 assert_eq!(report.summary.timeline_events, 1);
778 }
779
780 #[test]
781 fn serializes_snake_case_report_enums() {
782 let low_found = outcome("GitHub", MatchKind::Found, ConfidenceLabel::Low, 35);
783 let report = InvestigationReport::builder("alice", &[low_found]).build();
784 let json = serde_json::to_value(&report).unwrap();
785
786 assert_eq!(json["schema_version"], 3);
787 assert_eq!(json["limitations"][0]["kind"], "low_confidence_found");
788 assert_eq!(json["found_accounts"][0]["confidence"]["label"], "low");
789 }
790}