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