1use std::collections::HashSet;
2
3use super::{CandidateAssessment, MatchEvidence, RoutingTrace};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum MatchPolicy {
8 RequireSlugEvidence,
10 AllowPassthrough,
12 InstalledOnly,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum RejectionReason {
19 HarnessNotInstalled {
20 harness: String,
21 },
22 NoSlugEvidence {
23 harness: String,
24 },
25 AssessmentFailed {
26 harness: String,
27 skip_reason: Option<String>,
28 },
29}
30
31impl RejectionReason {
32 pub fn is_not_installed(&self) -> bool {
33 matches!(self, Self::HarnessNotInstalled { .. })
34 }
35}
36
37pub fn accept_route(
39 trace: &RoutingTrace,
40 installed: &HashSet<String>,
41 policy: MatchPolicy,
42) -> Result<(), RejectionReason> {
43 if !installed.contains(&trace.harness) {
44 return Err(RejectionReason::HarnessNotInstalled {
45 harness: trace.harness.clone(),
46 });
47 }
48
49 match policy {
50 MatchPolicy::InstalledOnly => Ok(()),
51 MatchPolicy::AllowPassthrough => match trace.match_evidence {
52 MatchEvidence::Confirmed | MatchEvidence::Constrained | MatchEvidence::Passthrough => {
53 Ok(())
54 }
55 MatchEvidence::None => Err(RejectionReason::NoSlugEvidence {
56 harness: trace.harness.clone(),
57 }),
58 },
59 MatchPolicy::RequireSlugEvidence => match trace.match_evidence {
60 MatchEvidence::Confirmed | MatchEvidence::Constrained => Ok(()),
61 MatchEvidence::Passthrough | MatchEvidence::None => {
62 Err(RejectionReason::NoSlugEvidence {
63 harness: trace.harness.clone(),
64 })
65 }
66 },
67 }
68}
69
70pub fn accept_assessment(assessment: &CandidateAssessment) -> Result<(), RejectionReason> {
72 if !assessment.installed {
73 return Err(RejectionReason::HarnessNotInstalled {
74 harness: assessment.harness.clone(),
75 });
76 }
77
78 match assessment.match_evidence {
79 Some(_) => Ok(()),
80 None => Err(RejectionReason::AssessmentFailed {
81 harness: assessment.harness.clone(),
82 skip_reason: assessment.skip_reason.map(str::to_string),
83 }),
84 }
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90 use crate::routing::{RouteSource, SelectionKind};
91
92 fn installed(names: &[&str]) -> HashSet<String> {
93 names.iter().map(|name| (*name).to_string()).collect()
94 }
95
96 fn trace(harness: &str, match_evidence: MatchEvidence) -> RoutingTrace {
97 RoutingTrace {
98 source: RouteSource::Provider,
99 selection_kind: SelectionKind::Auto,
100 match_evidence,
101 harness: harness.to_string(),
102 harness_order_position: None,
103 candidates_tried: vec![harness.to_string()],
104 assessments: Vec::new(),
105 diagnostics: Vec::new(),
106 }
107 }
108
109 fn assessment(
110 harness: &str,
111 installed: bool,
112 match_evidence: Option<MatchEvidence>,
113 skip_reason: Option<&'static str>,
114 ) -> CandidateAssessment {
115 CandidateAssessment {
116 harness: harness.to_string(),
117 installed,
118 candidate_slugs: Vec::new(),
119 filtered_slugs: Vec::new(),
120 chosen_slug: None,
121 chosen_model: None,
122 match_evidence,
123 skip_reason,
124 }
125 }
126
127 #[test]
128 fn installed_only_accepts_any_evidence_when_installed() {
129 let installed = installed(&["pi"]);
130 for match_evidence in [
131 MatchEvidence::Confirmed,
132 MatchEvidence::Constrained,
133 MatchEvidence::Passthrough,
134 MatchEvidence::None,
135 ] {
136 assert_eq!(
137 accept_route(
138 &trace("pi", match_evidence),
139 &installed,
140 MatchPolicy::InstalledOnly
141 ),
142 Ok(())
143 );
144 }
145 }
146
147 #[test]
148 fn any_policy_rejects_when_harness_not_installed() {
149 let installed = installed(&["codex"]);
150 for policy in [
151 MatchPolicy::InstalledOnly,
152 MatchPolicy::AllowPassthrough,
153 MatchPolicy::RequireSlugEvidence,
154 ] {
155 assert_eq!(
156 accept_route(&trace("pi", MatchEvidence::Confirmed), &installed, policy),
157 Err(RejectionReason::HarnessNotInstalled {
158 harness: "pi".to_string()
159 })
160 );
161 }
162 }
163
164 #[test]
165 fn allow_passthrough_rejects_only_none_evidence() {
166 let installed = installed(&["pi"]);
167 for match_evidence in [
168 MatchEvidence::Confirmed,
169 MatchEvidence::Constrained,
170 MatchEvidence::Passthrough,
171 ] {
172 assert_eq!(
173 accept_route(
174 &trace("pi", match_evidence),
175 &installed,
176 MatchPolicy::AllowPassthrough
177 ),
178 Ok(())
179 );
180 }
181 assert_eq!(
182 accept_route(
183 &trace("pi", MatchEvidence::None),
184 &installed,
185 MatchPolicy::AllowPassthrough
186 ),
187 Err(RejectionReason::NoSlugEvidence {
188 harness: "pi".to_string()
189 })
190 );
191 }
192
193 #[test]
194 fn require_slug_evidence_rejects_passthrough_and_none() {
195 let installed = installed(&["pi"]);
196 for match_evidence in [MatchEvidence::Confirmed, MatchEvidence::Constrained] {
197 assert_eq!(
198 accept_route(
199 &trace("pi", match_evidence),
200 &installed,
201 MatchPolicy::RequireSlugEvidence
202 ),
203 Ok(())
204 );
205 }
206 for match_evidence in [MatchEvidence::Passthrough, MatchEvidence::None] {
207 assert_eq!(
208 accept_route(
209 &trace("pi", match_evidence),
210 &installed,
211 MatchPolicy::RequireSlugEvidence
212 ),
213 Err(RejectionReason::NoSlugEvidence {
214 harness: "pi".to_string()
215 })
216 );
217 }
218 }
219
220 #[test]
221 fn accept_assessment_rejects_not_installed() {
222 let rejection =
223 accept_assessment(&assessment("claude", false, None, Some("not_installed")))
224 .expect_err("assessment should reject when harness is not installed");
225 assert!(rejection.is_not_installed());
226 assert_eq!(
227 rejection,
228 RejectionReason::HarnessNotInstalled {
229 harness: "claude".to_string()
230 }
231 );
232 }
233
234 #[test]
235 fn accept_assessment_rejects_missing_evidence_with_skip_reason() {
236 assert_eq!(
237 accept_assessment(&assessment(
238 "codex",
239 true,
240 None,
241 Some("provider_constraint_unsatisfied")
242 )),
243 Err(RejectionReason::AssessmentFailed {
244 harness: "codex".to_string(),
245 skip_reason: Some("provider_constraint_unsatisfied".to_string())
246 })
247 );
248 }
249
250 #[test]
251 fn accept_assessment_accepts_any_present_evidence() {
252 for match_evidence in [
253 MatchEvidence::Confirmed,
254 MatchEvidence::Constrained,
255 MatchEvidence::Passthrough,
256 MatchEvidence::None,
257 ] {
258 assert_eq!(
259 accept_assessment(&assessment("pi", true, Some(match_evidence), None)),
260 Ok(())
261 );
262 }
263 }
264}