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