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 Other(String),
76}
77
78impl fmt::Display for UncertainReason {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 match self {
81 Self::RateLimited => f.write_str("rate_limited"),
82 Self::CloudflareChallenge => f.write_str("cloudflare_challenge"),
83 Self::Captcha => f.write_str("captcha"),
84 Self::RobotsDisallowed => f.write_str("robots_disallowed"),
85 Self::Deadline => f.write_str("deadline reached"),
86 Self::SchedulerClosed => f.write_str("scheduler closed"),
87 Self::Network(detail) => write!(f, "request: {detail}"),
88 Self::BodyRead(detail) => write!(f, "body read: {detail}"),
89 Self::BrowserBudget => f.write_str("browser_budget_exceeded"),
90 Self::UsernameNotAllowed => f.write_str("username_not_allowed"),
91 Self::BrowserFailed(detail) => write!(f, "browser: {detail}"),
92 Self::GeoUnavailable => f.write_str("geo_unavailable"),
93 Self::Other(detail) => f.write_str(detail),
94 }
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct CheckOutcome {
101 pub site: String,
103 pub url: String,
105 pub kind: MatchKind,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub reason: Option<UncertainReason>,
111 pub elapsed_ms: u64,
113 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
117 pub enrichment: BTreeMap<String, String>,
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
122 pub evidence: Vec<String>,
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn match_kind_serialises_snake_case() {
131 assert_eq!(
132 serde_json::to_string(&MatchKind::Found).unwrap(),
133 "\"found\""
134 );
135 assert_eq!(
136 serde_json::to_string(&MatchKind::NotFound).unwrap(),
137 "\"not_found\""
138 );
139 assert_eq!(
140 serde_json::to_string(&MatchKind::Uncertain).unwrap(),
141 "\"uncertain\""
142 );
143 }
144
145 #[test]
146 fn match_kind_is_found() {
147 assert!(MatchKind::Found.is_found());
148 assert!(!MatchKind::NotFound.is_found());
149 assert!(!MatchKind::Uncertain.is_found());
150 }
151
152 #[test]
153 fn outcome_skips_absent_reason() {
154 let outcome = CheckOutcome {
155 site: "GitHub".into(),
156 url: "https://github.com/alice".into(),
157 kind: MatchKind::Found,
158 reason: None,
159 elapsed_ms: 42,
160 enrichment: BTreeMap::new(),
161 evidence: Vec::new(),
162 };
163 let json = serde_json::to_string(&outcome).unwrap();
164 assert!(
165 !json.contains("reason"),
166 "reason field must be omitted when None"
167 );
168 assert!(
169 !json.contains("enrichment"),
170 "enrichment must be omitted when empty"
171 );
172 assert!(json.contains("\"kind\":\"found\""));
173 assert!(json.contains("\"elapsed_ms\":42"));
174 }
175
176 #[test]
177 fn unit_reason_serialises_as_snake_case_string() {
178 let outcome = CheckOutcome {
179 site: "GitHub".into(),
180 url: "https://github.com/alice".into(),
181 kind: MatchKind::Uncertain,
182 reason: Some(UncertainReason::RateLimited),
183 elapsed_ms: 5_000,
184 enrichment: BTreeMap::new(),
185 evidence: Vec::new(),
186 };
187 let json = serde_json::to_string(&outcome).unwrap();
188 assert!(json.contains("\"reason\":\"rate_limited\""), "{json}");
189 }
190
191 #[test]
192 fn detail_reason_serialises_as_tagged_object() {
193 let json = serde_json::to_string(&UncertainReason::Network("refused".into())).unwrap();
194 assert_eq!(json, "{\"network\":\"refused\"}");
195 }
196
197 #[test]
198 fn reason_display_matches_legacy_note_text() {
199 assert_eq!(UncertainReason::RateLimited.to_string(), "rate_limited");
200 assert_eq!(UncertainReason::Deadline.to_string(), "deadline reached");
201 assert_eq!(
202 UncertainReason::Network("boom".into()).to_string(),
203 "request: boom"
204 );
205 }
206}