1use crate::model::EffectiveDefaults;
8use serde::Serialize;
9use std::fmt;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum MatchDecision {
15 Match,
17 Exclude,
19 NoMatch,
21}
22
23impl fmt::Display for MatchDecision {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 f.write_str(match self {
26 Self::Match => "MATCH",
27 Self::Exclude => "BLOCK",
28 Self::NoMatch => "NO_MATCH",
29 })
30 }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "snake_case", tag = "component", content = "name")]
36pub enum UrlComponent {
37 Path,
39 Query,
41 QueryItem(String),
43 Fragment,
45}
46
47impl fmt::Display for UrlComponent {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 match self {
50 Self::Path => f.write_str("path"),
51 Self::Query => f.write_str("query"),
52 Self::QueryItem(name) => write!(f, "query[{name}]"),
53 Self::Fragment => f.write_str("fragment"),
54 }
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60#[serde(rename_all = "snake_case")]
61#[non_exhaustive]
62pub enum ComponentReason {
63 Unconstrained,
65 Exact,
67 Wildcard,
69 Substitution,
71 PatternMismatch,
73 CaseMismatch,
75 MissingQueryItem,
77 UnsupportedPredicate,
80}
81
82impl ComponentReason {
83 #[must_use]
85 pub fn is_match(self) -> bool {
86 matches!(
87 self,
88 Self::Unconstrained
89 | Self::Exact
90 | Self::Wildcard
91 | Self::Substitution
92 | Self::UnsupportedPredicate
93 )
94 }
95}
96
97impl fmt::Display for ComponentReason {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 f.write_str(match self {
100 Self::Unconstrained => "not constrained by this rule",
101 Self::Exact => "literal match",
102 Self::Wildcard => "wildcard match",
103 Self::Substitution => "substitution match",
104 Self::PatternMismatch => "pattern did not match",
105 Self::CaseMismatch => "differs only by letter case",
106 Self::MissingQueryItem => "query item is missing",
107 Self::UnsupportedPredicate => {
108 "predicate is not a string, so the whole query dictionary is ignored"
109 }
110 })
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
116pub struct ComponentTrace {
117 #[serde(flatten)]
119 pub component: UrlComponent,
120 pub pattern: Option<String>,
122 pub input: String,
124 pub matched: bool,
126 pub reason: ComponentReason,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
132pub struct RuleTrace {
133 pub detail_index: usize,
135 pub rule_index: usize,
137 pub legacy: bool,
139 pub exclude: bool,
141 pub comment: Option<String>,
143 pub effective: EffectiveDefaults,
145 pub components: Vec<ComponentTrace>,
147 pub matched: bool,
149}
150
151impl RuleTrace {
152 #[must_use]
154 pub fn matched_component_count(&self) -> usize {
155 self.components
156 .iter()
157 .filter(|component| {
158 component.matched && component.reason != ComponentReason::Unconstrained
159 })
160 .count()
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
166pub struct DetailTrace {
167 pub index: usize,
169 pub app_ids: Vec<String>,
171 pub applies: bool,
173 pub rules: Vec<RuleTrace>,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
179#[serde(rename_all = "snake_case", tag = "stop")]
180#[non_exhaustive]
181pub enum StopReason {
182 Matched,
184 Excluded,
186 NoAppLinksSection,
188 NoApplicableDetail,
190 NoRuleMatched,
192 HostMismatch {
194 expected: String,
196 actual: String,
198 },
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
203pub struct MatchTrace {
204 pub details: Vec<DetailTrace>,
206 pub selected_detail: Option<usize>,
208 pub selected_rule: Option<usize>,
210 pub stop_reason: StopReason,
212 pub closest_failure: Option<RuleTrace>,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
218pub struct MatchResult {
219 pub decision: MatchDecision,
221 pub domain: String,
223 pub app_id: String,
225 pub url: String,
227 pub trace: MatchTrace,
229 pub notes: Vec<String>,
231}
232
233impl MatchResult {
234 #[must_use]
236 pub fn is_match(&self) -> bool {
237 self.decision == MatchDecision::Match
238 }
239
240 #[must_use]
242 pub fn selected_rule(&self) -> Option<&RuleTrace> {
243 let detail = self.trace.selected_detail?;
244 let rule = self.trace.selected_rule?;
245 self.trace
246 .details
247 .iter()
248 .find(|entry| entry.index == detail)?
249 .rules
250 .iter()
251 .find(|candidate| candidate.rule_index == rule)
252 }
253}
254
255impl fmt::Display for MatchResult {
256 #[allow(clippy::too_many_lines)]
257 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258 writeln!(f, "{}", self.decision)?;
259 writeln!(f)?;
260 writeln!(f, "application: {}", self.app_id)?;
261 writeln!(f, "domain: {}", self.domain)?;
262 writeln!(f, "url: {}", self.url)?;
263
264 if let Some(rule) = self.selected_rule() {
265 writeln!(f)?;
266 writeln!(f, "detail: #{}", rule.detail_index)?;
267 writeln!(
268 f,
269 "rule: #{}{}",
270 rule.rule_index,
271 if rule.legacy { " (legacy paths)" } else { "" }
272 )?;
273 if let Some(comment) = &rule.comment {
274 writeln!(f, "comment: {comment}")?;
275 }
276 writeln!(f)?;
277 write_components(f, rule)?;
278 writeln!(f)?;
279 writeln!(
280 f,
281 "effective settings:\n caseSensitive = {}\n percentEncoded = {}",
282 rule.effective.case_sensitive, rule.effective.percent_encoded
283 )?;
284 }
285
286 writeln!(f)?;
287 writeln!(f, "reason:")?;
288 match &self.trace.stop_reason {
289 StopReason::Matched => {
290 writeln!(f, " every component this rule specifies matched")?;
291 }
292 StopReason::Excluded => {
293 writeln!(
294 f,
295 " the first matching rule sets exclude: true, so matching stopped"
296 )?;
297 }
298 StopReason::NoAppLinksSection => {
299 writeln!(f, " the document has no applinks section")?;
300 }
301 StopReason::NoApplicableDetail => {
302 writeln!(f, " no applinks.details entry lists {}", self.app_id)?;
303 }
304 StopReason::NoRuleMatched => {
305 writeln!(
306 f,
307 " the entries that apply to {} have no rule matching this URL",
308 self.app_id
309 )?;
310 }
311 StopReason::HostMismatch { expected, actual } => {
312 writeln!(
313 f,
314 " the URL host is {actual}, but this document was served for {expected}"
315 )?;
316 }
317 }
318
319 if let Some(closest) = &self.trace.closest_failure {
320 writeln!(f)?;
321 writeln!(
322 f,
323 "closest failure:\n detail #{}, rule #{}",
324 closest.detail_index, closest.rule_index
325 )?;
326 write_components(f, closest)?;
327 }
328
329 for note in &self.notes {
330 writeln!(f, "\nnote: {note}")?;
331 }
332 Ok(())
333 }
334}
335
336fn write_components(f: &mut fmt::Formatter<'_>, rule: &RuleTrace) -> fmt::Result {
337 for component in &rule.components {
338 if component.reason == ComponentReason::Unconstrained {
339 continue;
340 }
341 let mark = if component.matched { "ok " } else { "FAIL" };
342 writeln!(f, " [{mark}] {}", component.component)?;
343 writeln!(f, " url: {}", component.input)?;
344 if let Some(pattern) = &component.pattern {
345 writeln!(f, " pattern: {pattern}")?;
346 }
347 writeln!(f, " {}", component.reason)?;
348 }
349 Ok(())
350}