Skip to main content

adler_core/
report.rs

1//! Core investigation-report model.
2//!
3//! This module is intentionally presentation-agnostic. It does not render
4//! Markdown/HTML and it does not depend on `adler-server` persisted-scan
5//! structs. Callers adapt their scan artifacts into the stable core inputs:
6//! outcomes, identity clusters, optional timeline events, and disabled-site
7//! diagnostics.
8
9use 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
19/// Current schema version for investigation report JSON.
20pub const INVESTIGATION_REPORT_SCHEMA_VERSION: u16 = 3;
21
22/// Structured report over one scan or scan-derived artifact.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct InvestigationReport {
25    /// Version of this report model.
26    pub schema_version: u16,
27    /// Username or handle being investigated.
28    pub username: String,
29    /// Unix epoch milliseconds when the report was generated, if the caller
30    /// supplies it.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub generated_at_ms: Option<u64>,
33    /// Aggregate scan/report counts.
34    pub summary: ReportSummary,
35    /// All found accounts, sorted by confidence descending and then site.
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub found_accounts: Vec<ReportAccount>,
38    /// High-confidence found accounts, split out as a first-class section for
39    /// renderers.
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub high_confidence_accounts: Vec<ReportAccount>,
42    /// Inconclusive site outcomes that must not be treated as present or
43    /// absent.
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub uncertain_accounts: Vec<ReportUncertainAccount>,
46    /// Flat structured evidence table for found accounts.
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub evidence_table: Vec<ReportEvidence>,
49    /// Identity candidates supplied by the scan artifact.
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub identity_clusters: Vec<IdentityCluster>,
52    /// Optional timeline/change events supplied by higher layers.
53    #[serde(default, skip_serializing_if = "Vec::is_empty")]
54    pub timeline: Vec<ReportTimelineEvent>,
55    /// Disabled or parked sites that matched the scan scope but were omitted.
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub disabled_sites: Vec<ReportDisabledSite>,
58    /// Explicit caveats that renderers should include in the report.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub limitations: Vec<ReportLimitation>,
61}
62
63impl InvestigationReport {
64    /// Build a report from one scan's outcomes and precomputed identity
65    /// clusters.
66    #[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    /// Start a report builder.
78    #[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/// Builder for optional report inputs that do not live in `CheckOutcome`.
95#[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    /// Attach precomputed identity clusters.
107    #[must_use]
108    pub fn identity_clusters(mut self, clusters: Vec<IdentityCluster>) -> Self {
109        self.identity_clusters = clusters;
110        self
111    }
112
113    /// Attach timeline/change events from scan history.
114    #[must_use]
115    pub fn timeline(mut self, timeline: Vec<ReportTimelineEvent>) -> Self {
116        self.timeline = timeline;
117        self
118    }
119
120    /// Attach disabled/parked sites that matched report scope.
121    #[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    /// Stamp a caller-supplied generation time.
128    #[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    /// Finish building the report.
135    #[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(&sections.found_accounts);
144        let summary = ReportSummary::from_parts(
145            &self.outcomes,
146            &sections.found_accounts,
147            &sections.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/// Aggregate counts used by report renderers.
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304pub struct ReportSummary {
305    /// Number of outcomes in the scan.
306    pub total: usize,
307    /// Found outcome count.
308    pub found: usize,
309    /// Not-found outcome count.
310    pub not_found: usize,
311    /// Uncertain outcome count.
312    pub uncertain: usize,
313    /// Found outcomes with `high` or `verified` confidence.
314    pub high_confidence_found: usize,
315    /// Found outcomes with at least one structured profile evidence item.
316    pub found_with_profile_evidence: usize,
317    /// Flat profile evidence row count.
318    pub profile_evidence_items: usize,
319    /// Identity cluster count.
320    pub identity_clusters: usize,
321    /// Identity clusters marked uncertain.
322    pub uncertain_identity_clusters: usize,
323    /// Profiles participating in at least one identity cluster.
324    pub clustered_profiles: usize,
325    /// Timeline event count.
326    pub timeline_events: usize,
327    /// Disabled/parked site count.
328    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/// Found account row used by report sections.
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380pub struct ReportAccount {
381    /// Site name.
382    pub site: String,
383    /// Concrete profile URL.
384    pub url: String,
385    /// Confidence in this per-site verdict.
386    pub confidence: ConfidenceScore,
387    /// Human-readable detection signal evidence.
388    #[serde(default, skip_serializing_if = "Vec::is_empty")]
389    pub signal_evidence: Vec<String>,
390    /// Structured profile evidence.
391    #[serde(default, skip_serializing_if = "Vec::is_empty")]
392    pub profile_evidence: Vec<ProfileEvidence>,
393    /// Identity cluster ids containing this account.
394    #[serde(default, skip_serializing_if = "Vec::is_empty")]
395    pub cluster_ids: Vec<String>,
396    /// Transport that produced the result.
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub transport: Option<TransportTier>,
399    /// Automatic escalations beyond the primary route.
400    #[serde(default, skip_serializing_if = "is_zero_u8")]
401    pub escalations: u8,
402    /// Probe duration.
403    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/// Inconclusive account row.
423#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
424pub struct ReportUncertainAccount {
425    /// Site name.
426    pub site: String,
427    /// URL that was attempted.
428    pub url: String,
429    /// Typed uncertain reason.
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub reason: Option<UncertainReason>,
432    /// Confidence in the inconclusive verdict.
433    pub confidence: ConfidenceScore,
434    /// Transport that produced the result, if known.
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub transport: Option<TransportTier>,
437    /// Automatic escalations beyond the primary route.
438    #[serde(default, skip_serializing_if = "is_zero_u8")]
439    pub escalations: u8,
440    /// Probe duration.
441    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/// One structured evidence table row.
459#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
460pub struct ReportEvidence {
461    /// Site name.
462    pub site: String,
463    /// Profile URL.
464    pub url: String,
465    /// Evidence kind.
466    pub kind: ProfileEvidenceKind,
467    /// Original extractor field.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub field: Option<String>,
470    /// Observed value.
471    pub value: String,
472    /// Full source metadata.
473    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/// Optional history event supplied by timeline/watchlist layers.
490#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
491pub struct ReportTimelineEvent {
492    /// Event kind.
493    pub kind: ReportTimelineEventKind,
494    /// Site name, when event is site-specific.
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub site: Option<String>,
497    /// Scan id where this event was observed.
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub scan_id: Option<String>,
500    /// Unix epoch milliseconds for the event, if known.
501    #[serde(default, skip_serializing_if = "Option::is_none")]
502    pub observed_at_ms: Option<u64>,
503    /// Short machine/human detail from the caller.
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    pub detail: Option<String>,
506}
507
508/// Timeline event categories available to report renderers.
509#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
510#[serde(rename_all = "snake_case")]
511pub enum ReportTimelineEventKind {
512    /// A found profile appeared.
513    AddedFound,
514    /// A previously found profile disappeared or stopped resolving as found.
515    RemovedFound,
516    /// A site's verdict changed.
517    VerdictChanged,
518    /// Profile evidence changed while the site stayed found.
519    EvidenceChanged,
520    /// A profile disappeared and later appeared again.
521    Reappeared,
522}
523
524/// Disabled or parked site omitted from the scan scope.
525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
526pub struct ReportDisabledSite {
527    /// Site name.
528    pub name: String,
529    /// URL template or representative profile URL.
530    pub url: String,
531    /// Registry tags.
532    #[serde(default, skip_serializing_if = "Vec::is_empty")]
533    pub tags: Vec<String>,
534    /// Why this site is disabled/parked.
535    pub disabled_reason: String,
536}
537
538/// Explicit caveat for the report.
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
540pub struct ReportLimitation {
541    /// Limitation category.
542    pub kind: ReportLimitationKind,
543    /// Site name, when site-specific.
544    #[serde(default, skip_serializing_if = "Option::is_none")]
545    pub site: Option<String>,
546    /// Extra detail suitable for renderer text.
547    #[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/// Limitation categories.
562#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
563#[serde(rename_all = "snake_case")]
564pub enum ReportLimitationKind {
565    /// Found account has low per-site confidence.
566    LowConfidenceFound,
567    /// Found account has no structured profile evidence.
568    MissingProfileEvidence,
569    /// A site outcome was inconclusive.
570    UncertainOutcome,
571    /// A required operator session was not available.
572    SessionRequired,
573    /// Required geo/egress was unavailable.
574    GeoUnavailable,
575    /// CAPTCHA blocked reliable probing.
576    Captcha,
577    /// Rate limiting blocked reliable probing.
578    RateLimited,
579    /// Browser budget prevented browser probing.
580    BrowserBudget,
581    /// Transport/access conditions blocked reliable probing.
582    TransportBlocked,
583    /// A disabled/parked matching site was omitted.
584    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}