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 self.refresh_confidence_with_history(0);
160 }
161
162 pub fn refresh_confidence_with_history(&mut self, historical_consistency_count: usize) {
168 let access_paths = self
169 .profile_evidence
170 .iter()
171 .filter_map(|evidence| evidence.source.access_path.as_ref());
172 let authenticated_access = access_paths.clone().any(|path| path.authenticated);
173 let metadata_transport = access_paths.clone().map(|path| path.transport).next();
174 let metadata_escalated = access_paths.clone().any(|path| path.escalated);
175 let username_evidence_count = self
176 .profile_evidence
177 .iter()
178 .filter(|evidence| evidence.kind == ProfileEvidenceKind::Username)
179 .count();
180 let profile_evidence_count = self
181 .profile_evidence
182 .len()
183 .saturating_sub(username_evidence_count);
184 self.confidence = ConfidenceScore::from_signals(&ConfidenceSignals {
185 kind: self.kind,
186 reason: self.reason.clone(),
187 signal_evidence_count: self.evidence.len(),
188 profile_evidence_count,
189 username_evidence_count,
190 historical_consistency_count,
191 authenticated_access,
192 transport: metadata_transport.or(self.transport),
193 escalations: if metadata_escalated && self.escalations == 0 {
194 1
195 } else {
196 self.escalations
197 },
198 });
199 }
200}
201
202#[allow(clippy::trivially_copy_pass_by_ref)]
203fn is_zero_u8(n: &u8) -> bool {
204 *n == 0
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn match_kind_serialises_snake_case() {
213 assert_eq!(
214 serde_json::to_string(&MatchKind::Found).unwrap(),
215 "\"found\""
216 );
217 assert_eq!(
218 serde_json::to_string(&MatchKind::NotFound).unwrap(),
219 "\"not_found\""
220 );
221 assert_eq!(
222 serde_json::to_string(&MatchKind::Uncertain).unwrap(),
223 "\"uncertain\""
224 );
225 }
226
227 #[test]
228 fn match_kind_is_found() {
229 assert!(MatchKind::Found.is_found());
230 assert!(!MatchKind::NotFound.is_found());
231 assert!(!MatchKind::Uncertain.is_found());
232 }
233
234 #[test]
235 fn outcome_skips_absent_reason() {
236 let outcome = CheckOutcome {
237 site: "GitHub".into(),
238 url: "https://github.com/alice".into(),
239 kind: MatchKind::Found,
240 reason: None,
241 elapsed_ms: 42,
242 enrichment: BTreeMap::new(),
243 evidence: Vec::new(),
244 profile_evidence: Vec::new(),
245 confidence: ConfidenceScore::default(),
246 transport: None,
247 escalations: 0,
248 };
249 let json = serde_json::to_string(&outcome).unwrap();
250 assert!(
251 !json.contains("reason"),
252 "reason field must be omitted when None"
253 );
254 assert!(
255 !json.contains("enrichment"),
256 "enrichment must be omitted when empty"
257 );
258 assert!(
259 !json.contains("transport"),
260 "transport must be omitted when None"
261 );
262 assert!(
263 !json.contains("escalations"),
264 "escalations must be omitted when zero"
265 );
266 assert!(json.contains("\"kind\":\"found\""));
267 assert!(json.contains("\"elapsed_ms\":42"));
268 }
269
270 #[test]
271 fn unit_reason_serialises_as_snake_case_string() {
272 let outcome = CheckOutcome {
273 site: "GitHub".into(),
274 url: "https://github.com/alice".into(),
275 kind: MatchKind::Uncertain,
276 reason: Some(UncertainReason::RateLimited),
277 elapsed_ms: 5_000,
278 enrichment: BTreeMap::new(),
279 evidence: Vec::new(),
280 profile_evidence: Vec::new(),
281 confidence: ConfidenceScore::default(),
282 transport: None,
283 escalations: 0,
284 };
285 let json = serde_json::to_string(&outcome).unwrap();
286 assert!(json.contains("\"reason\":\"rate_limited\""), "{json}");
287 }
288
289 #[test]
290 fn detail_reason_serialises_as_tagged_object() {
291 let json = serde_json::to_string(&UncertainReason::Network("refused".into())).unwrap();
292 assert_eq!(json, "{\"network\":\"refused\"}");
293 }
294
295 #[test]
296 fn reason_display_matches_legacy_note_text() {
297 assert_eq!(UncertainReason::RateLimited.to_string(), "rate_limited");
298 assert_eq!(UncertainReason::Deadline.to_string(), "deadline reached");
299 assert_eq!(
300 UncertainReason::Network("boom".into()).to_string(),
301 "request: boom"
302 );
303 }
304
305 #[test]
306 fn old_outcome_json_defaults_confidence_and_profile_evidence() {
307 let json = r#"{
308 "site": "GitHub",
309 "url": "https://github.com/alice",
310 "kind": "found",
311 "elapsed_ms": 42
312 }"#;
313 let mut outcome: CheckOutcome = serde_json::from_str(json).unwrap();
314 assert!(outcome.profile_evidence.is_empty());
315 assert_eq!(outcome.confidence, ConfidenceScore::default());
316 outcome.refresh_confidence();
317 assert_eq!(outcome.confidence.score, 65);
318 }
319}