1use std::collections::BTreeMap;
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8use crate::confidence::{ConfidenceScore, ConfidenceSignals};
9use crate::profile::{ProfileEvidence, ProfileEvidenceKind};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum MatchKind {
15 Found,
17 NotFound,
19 Uncertain,
23}
24
25impl MatchKind {
26 pub const fn is_found(self) -> bool {
28 matches!(self, Self::Found)
29 }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum UncertainReason {
43 RateLimited,
45 CloudflareChallenge,
47 Captcha,
49 RobotsDisallowed,
51 Deadline,
53 SchedulerClosed,
55 Network(String),
57 BodyRead(String),
59 BrowserBudget,
62 UsernameNotAllowed,
68 BrowserFailed(String),
71 GeoUnavailable,
77 SessionRequired,
82 Other(String),
84}
85
86impl fmt::Display for UncertainReason {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 match self {
89 Self::RateLimited => f.write_str("rate_limited"),
90 Self::CloudflareChallenge => f.write_str("cloudflare_challenge"),
91 Self::Captcha => f.write_str("captcha"),
92 Self::RobotsDisallowed => f.write_str("robots_disallowed"),
93 Self::Deadline => f.write_str("deadline reached"),
94 Self::SchedulerClosed => f.write_str("scheduler closed"),
95 Self::Network(detail) => write!(f, "request: {detail}"),
96 Self::BodyRead(detail) => write!(f, "body read: {detail}"),
97 Self::BrowserBudget => f.write_str("browser_budget_exceeded"),
98 Self::UsernameNotAllowed => f.write_str("username_not_allowed"),
99 Self::BrowserFailed(detail) => write!(f, "browser: {detail}"),
100 Self::GeoUnavailable => f.write_str("geo_unavailable"),
101 Self::SessionRequired => f.write_str("session_required"),
102 Self::Other(detail) => f.write_str(detail),
103 }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct CheckOutcome {
110 pub site: String,
112 pub url: String,
114 pub kind: MatchKind,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub reason: Option<UncertainReason>,
120 pub elapsed_ms: u64,
122 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
126 pub enrichment: BTreeMap<String, String>,
127 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 pub evidence: Vec<String>,
132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
138 pub profile_evidence: Vec<ProfileEvidence>,
139 #[serde(default)]
141 pub confidence: ConfidenceScore,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub transport: Option<crate::escalation::TransportTier>,
147 #[serde(default, skip_serializing_if = "is_zero_u8")]
153 pub escalations: u8,
154}
155
156impl CheckOutcome {
157 pub fn refresh_confidence(&mut self) {
159 let access_paths = self
160 .profile_evidence
161 .iter()
162 .filter_map(|evidence| evidence.source.access_path.as_ref());
163 let authenticated_access = access_paths.clone().any(|path| path.authenticated);
164 let metadata_transport = access_paths.clone().map(|path| path.transport).next();
165 let metadata_escalated = access_paths.clone().any(|path| path.escalated);
166 let username_evidence_count = self
167 .profile_evidence
168 .iter()
169 .filter(|evidence| evidence.kind == ProfileEvidenceKind::Username)
170 .count();
171 let profile_evidence_count = self
172 .profile_evidence
173 .len()
174 .saturating_sub(username_evidence_count);
175 self.confidence = ConfidenceScore::from_signals(&ConfidenceSignals {
176 kind: self.kind,
177 reason: self.reason.clone(),
178 signal_evidence_count: self.evidence.len(),
179 profile_evidence_count,
180 username_evidence_count,
181 authenticated_access,
182 transport: metadata_transport.or(self.transport),
183 escalations: if metadata_escalated && self.escalations == 0 {
184 1
185 } else {
186 self.escalations
187 },
188 });
189 }
190}
191
192#[allow(clippy::trivially_copy_pass_by_ref)]
193fn is_zero_u8(n: &u8) -> bool {
194 *n == 0
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn match_kind_serialises_snake_case() {
203 assert_eq!(
204 serde_json::to_string(&MatchKind::Found).unwrap(),
205 "\"found\""
206 );
207 assert_eq!(
208 serde_json::to_string(&MatchKind::NotFound).unwrap(),
209 "\"not_found\""
210 );
211 assert_eq!(
212 serde_json::to_string(&MatchKind::Uncertain).unwrap(),
213 "\"uncertain\""
214 );
215 }
216
217 #[test]
218 fn match_kind_is_found() {
219 assert!(MatchKind::Found.is_found());
220 assert!(!MatchKind::NotFound.is_found());
221 assert!(!MatchKind::Uncertain.is_found());
222 }
223
224 #[test]
225 fn outcome_skips_absent_reason() {
226 let outcome = CheckOutcome {
227 site: "GitHub".into(),
228 url: "https://github.com/alice".into(),
229 kind: MatchKind::Found,
230 reason: None,
231 elapsed_ms: 42,
232 enrichment: BTreeMap::new(),
233 evidence: Vec::new(),
234 profile_evidence: Vec::new(),
235 confidence: ConfidenceScore::default(),
236 transport: None,
237 escalations: 0,
238 };
239 let json = serde_json::to_string(&outcome).unwrap();
240 assert!(
241 !json.contains("reason"),
242 "reason field must be omitted when None"
243 );
244 assert!(
245 !json.contains("enrichment"),
246 "enrichment must be omitted when empty"
247 );
248 assert!(
249 !json.contains("transport"),
250 "transport must be omitted when None"
251 );
252 assert!(
253 !json.contains("escalations"),
254 "escalations must be omitted when zero"
255 );
256 assert!(json.contains("\"kind\":\"found\""));
257 assert!(json.contains("\"elapsed_ms\":42"));
258 }
259
260 #[test]
261 fn unit_reason_serialises_as_snake_case_string() {
262 let outcome = CheckOutcome {
263 site: "GitHub".into(),
264 url: "https://github.com/alice".into(),
265 kind: MatchKind::Uncertain,
266 reason: Some(UncertainReason::RateLimited),
267 elapsed_ms: 5_000,
268 enrichment: BTreeMap::new(),
269 evidence: Vec::new(),
270 profile_evidence: Vec::new(),
271 confidence: ConfidenceScore::default(),
272 transport: None,
273 escalations: 0,
274 };
275 let json = serde_json::to_string(&outcome).unwrap();
276 assert!(json.contains("\"reason\":\"rate_limited\""), "{json}");
277 }
278
279 #[test]
280 fn detail_reason_serialises_as_tagged_object() {
281 let json = serde_json::to_string(&UncertainReason::Network("refused".into())).unwrap();
282 assert_eq!(json, "{\"network\":\"refused\"}");
283 }
284
285 #[test]
286 fn reason_display_matches_legacy_note_text() {
287 assert_eq!(UncertainReason::RateLimited.to_string(), "rate_limited");
288 assert_eq!(UncertainReason::Deadline.to_string(), "deadline reached");
289 assert_eq!(
290 UncertainReason::Network("boom".into()).to_string(),
291 "request: boom"
292 );
293 }
294
295 #[test]
296 fn old_outcome_json_defaults_confidence_and_profile_evidence() {
297 let json = r#"{
298 "site": "GitHub",
299 "url": "https://github.com/alice",
300 "kind": "found",
301 "elapsed_ms": 42
302 }"#;
303 let mut outcome: CheckOutcome = serde_json::from_str(json).unwrap();
304 assert!(outcome.profile_evidence.is_empty());
305 assert_eq!(outcome.confidence, ConfidenceScore::default());
306 outcome.refresh_confidence();
307 assert_eq!(outcome.confidence.score, 65);
308 }
309}