1use std::collections::BTreeMap;
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum MatchKind {
12 Found,
14 NotFound,
16 Uncertain,
20}
21
22impl MatchKind {
23 pub const fn is_found(self) -> bool {
25 matches!(self, Self::Found)
26 }
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum UncertainReason {
40 RateLimited,
42 CloudflareChallenge,
44 Captcha,
46 RobotsDisallowed,
48 Deadline,
50 SchedulerClosed,
52 Network(String),
54 BodyRead(String),
56 BrowserBudget,
59 UsernameNotAllowed,
65 BrowserFailed(String),
68 GeoUnavailable,
74 SessionRequired,
79 Other(String),
81}
82
83impl fmt::Display for UncertainReason {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 match self {
86 Self::RateLimited => f.write_str("rate_limited"),
87 Self::CloudflareChallenge => f.write_str("cloudflare_challenge"),
88 Self::Captcha => f.write_str("captcha"),
89 Self::RobotsDisallowed => f.write_str("robots_disallowed"),
90 Self::Deadline => f.write_str("deadline reached"),
91 Self::SchedulerClosed => f.write_str("scheduler closed"),
92 Self::Network(detail) => write!(f, "request: {detail}"),
93 Self::BodyRead(detail) => write!(f, "body read: {detail}"),
94 Self::BrowserBudget => f.write_str("browser_budget_exceeded"),
95 Self::UsernameNotAllowed => f.write_str("username_not_allowed"),
96 Self::BrowserFailed(detail) => write!(f, "browser: {detail}"),
97 Self::GeoUnavailable => f.write_str("geo_unavailable"),
98 Self::SessionRequired => f.write_str("session_required"),
99 Self::Other(detail) => f.write_str(detail),
100 }
101 }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct CheckOutcome {
107 pub site: String,
109 pub url: String,
111 pub kind: MatchKind,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub reason: Option<UncertainReason>,
117 pub elapsed_ms: u64,
119 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
123 pub enrichment: BTreeMap<String, String>,
124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
128 pub evidence: Vec<String>,
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn match_kind_serialises_snake_case() {
137 assert_eq!(
138 serde_json::to_string(&MatchKind::Found).unwrap(),
139 "\"found\""
140 );
141 assert_eq!(
142 serde_json::to_string(&MatchKind::NotFound).unwrap(),
143 "\"not_found\""
144 );
145 assert_eq!(
146 serde_json::to_string(&MatchKind::Uncertain).unwrap(),
147 "\"uncertain\""
148 );
149 }
150
151 #[test]
152 fn match_kind_is_found() {
153 assert!(MatchKind::Found.is_found());
154 assert!(!MatchKind::NotFound.is_found());
155 assert!(!MatchKind::Uncertain.is_found());
156 }
157
158 #[test]
159 fn outcome_skips_absent_reason() {
160 let outcome = CheckOutcome {
161 site: "GitHub".into(),
162 url: "https://github.com/alice".into(),
163 kind: MatchKind::Found,
164 reason: None,
165 elapsed_ms: 42,
166 enrichment: BTreeMap::new(),
167 evidence: Vec::new(),
168 };
169 let json = serde_json::to_string(&outcome).unwrap();
170 assert!(
171 !json.contains("reason"),
172 "reason field must be omitted when None"
173 );
174 assert!(
175 !json.contains("enrichment"),
176 "enrichment must be omitted when empty"
177 );
178 assert!(json.contains("\"kind\":\"found\""));
179 assert!(json.contains("\"elapsed_ms\":42"));
180 }
181
182 #[test]
183 fn unit_reason_serialises_as_snake_case_string() {
184 let outcome = CheckOutcome {
185 site: "GitHub".into(),
186 url: "https://github.com/alice".into(),
187 kind: MatchKind::Uncertain,
188 reason: Some(UncertainReason::RateLimited),
189 elapsed_ms: 5_000,
190 enrichment: BTreeMap::new(),
191 evidence: Vec::new(),
192 };
193 let json = serde_json::to_string(&outcome).unwrap();
194 assert!(json.contains("\"reason\":\"rate_limited\""), "{json}");
195 }
196
197 #[test]
198 fn detail_reason_serialises_as_tagged_object() {
199 let json = serde_json::to_string(&UncertainReason::Network("refused".into())).unwrap();
200 assert_eq!(json, "{\"network\":\"refused\"}");
201 }
202
203 #[test]
204 fn reason_display_matches_legacy_note_text() {
205 assert_eq!(UncertainReason::RateLimited.to_string(), "rate_limited");
206 assert_eq!(UncertainReason::Deadline.to_string(), "deadline reached");
207 assert_eq!(
208 UncertainReason::Network("boom".into()).to_string(),
209 "request: boom"
210 );
211 }
212}