1use crate::{Error, Result};
9use ostraka_adapter::{Availability, Profile, VendorAdapter, process::ProcessAdapter};
10use std::path::Path;
11
12pub struct Routing {
14 pub author: Box<dyn VendorAdapter>,
15 pub reviewer: Box<dyn VendorAdapter>,
16}
17
18impl std::fmt::Debug for Routing {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 f.debug_struct("Routing")
23 .field("author", &self.author.id())
24 .field("reviewer", &self.reviewer.id())
25 .finish()
26 }
27}
28
29pub fn select(
46 profiles: &[Profile],
47 author_id: Option<&str>,
48 reviewer_id: Option<&str>,
49 isolation_root: &Path,
50 timeout: Option<std::time::Duration>,
51) -> Result<Routing> {
52 if profiles.is_empty() {
53 return Err(Error::Other("no adapter profiles found".to_string()));
54 }
55
56 let find = |id: &str| -> Result<Profile> {
57 profiles
58 .iter()
59 .find(|p| p.id == id)
60 .cloned()
61 .ok_or_else(|| Error::Other(format!("no adapter profile with id {id:?}")))
62 };
63
64 let (author, reviewer) = match (author_id, reviewer_id) {
65 (Some(a), Some(r)) => (find(a)?, find(r)?),
66 (Some(a), None) => {
67 let author = find(a)?;
68 let reviewer = first_other(profiles, Some(&author))?;
69 (author, reviewer)
70 }
71 (None, Some(r)) => {
72 let reviewer = find(r)?;
73 let author = first_other(profiles, Some(&reviewer))?;
74 (author, reviewer)
75 }
76 (None, None) => {
77 let author = first_other(profiles, None)?;
78 let reviewer = first_other(profiles, Some(&author))?;
79 (author, reviewer)
80 }
81 };
82
83 if author.id == reviewer.id {
84 return Err(Error::Other(format!(
85 "author and reviewer are the same adapter ({:?}); a change cannot review itself",
86 author.id
87 )));
88 }
89
90 Ok(Routing {
91 author: Box::new(
92 ProcessAdapter::new(author)
93 .isolated_under(isolation_root)
94 .within(timeout),
95 ),
96 reviewer: Box::new(
97 ProcessAdapter::reviewing(reviewer)
98 .isolated_under(isolation_root)
99 .within(timeout),
100 ),
101 })
102}
103
104fn first_other(profiles: &[Profile], exclude: Option<&Profile>) -> Result<Profile> {
118 let excluded_id = exclude.map(|p| p.id.as_str()).unwrap_or_default();
119 let excluded_command = exclude.map(|p| p.command.as_str());
120 let mut others: Vec<&Profile> = profiles.iter().filter(|p| p.id != excluded_id).collect();
121 others.sort_by(|a, b| {
122 let same = |p: &Profile| excluded_command == Some(p.command.as_str());
123 same(a).cmp(&same(b)).then_with(|| a.id.cmp(&b.id))
124 });
125
126 let mut unusable: Vec<String> = Vec::new();
127 for candidate in &others {
128 match ProcessAdapter::new((*candidate).clone()).probe() {
129 a if a.is_ready() => return Ok((*candidate).clone()),
130 Availability::NotFound { command } => {
131 unusable.push(format!("{}: {command} not on PATH", candidate.id));
132 }
133 Availability::Unusable { reason } => {
134 unusable.push(format!("{}: {reason}", candidate.id));
135 }
136 Availability::Ready { .. } => return Ok((*candidate).clone()),
139 }
140 }
141
142 if others.is_empty() {
143 return Err(Error::Other(
144 "only one adapter profile is configured, so no independent reviewer exists; \
145 add a second profile in adapters/"
146 .to_string(),
147 ));
148 }
149 let besides = if excluded_id.is_empty() {
150 String::new()
151 } else {
152 format!(" besides {excluded_id:?}")
153 };
154 Err(Error::Other(format!(
155 "no usable adapter profile{besides}; run `ostraka adapters` for detail. Checked — {}",
156 unusable.join("; ")
157 )))
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 fn root() -> &'static Path {
166 Path::new("/nonexistent-isolation-root")
167 }
168
169 fn command_profile(id: &str, command: &str) -> Profile {
170 Profile::parse(&format!(
171 r#"
172 id = "{id}"
173 command = "{command}"
174 args = ["{{{{prompt}}}}"]
175 "#
176 ))
177 .expect("valid")
178 }
179
180 fn profile(id: &str) -> Profile {
181 Profile::parse(&format!(
182 r#"
183 id = "{id}"
184 command = "true"
185 args = ["{{{{prompt}}}}"]
186 "#
187 ))
188 .expect("valid")
189 }
190
191 #[test]
192 fn a_lone_adapter_cannot_review_itself() {
193 let err = select(&[profile("solo")], None, None, root(), None).expect_err("must refuse");
194 assert!(err.to_string().contains("no independent reviewer"));
195 }
196
197 #[test]
198 fn naming_the_same_adapter_twice_is_refused() {
199 let profiles = [profile("a"), profile("b")];
200 let err = select(&profiles, Some("a"), Some("a"), root(), None).expect_err("must refuse");
201 assert!(err.to_string().contains("cannot review itself"));
202 }
203
204 #[test]
205 fn selection_is_deterministic_not_listing_order() {
206 let forward =
207 select(&[profile("b"), profile("a")], None, None, root(), None).expect("routes");
208 let reverse =
209 select(&[profile("a"), profile("b")], None, None, root(), None).expect("routes");
210 assert_eq!(forward.author.id(), "a");
211 assert_eq!(forward.author.id(), reverse.author.id());
212 assert_eq!(forward.reviewer.id(), "b");
213 }
214
215 #[test]
216 fn a_profile_whose_cli_is_absent_is_not_routed_to() {
217 let missing = Profile::parse(
218 r#"
219 id = "aaa-missing"
220 command = "definitely-not-a-real-binary-xyz"
221 args = ["{{prompt}}"]
222 "#,
223 )
224 .expect("valid");
225 let routing = select(
227 &[missing, profile("b"), profile("c")],
228 None,
229 None,
230 root(),
231 None,
232 )
233 .expect("routes");
234 assert_eq!(routing.author.id(), "b");
235 assert_eq!(routing.reviewer.id(), "c");
236 }
237
238 #[test]
239 fn an_unrunnable_choice_is_still_honoured_when_named() {
240 let missing = Profile::parse(
243 r#"
244 id = "missing"
245 command = "definitely-not-a-real-binary-xyz"
246 args = ["{{prompt}}"]
247 "#,
248 )
249 .expect("valid");
250 let routing = select(
251 &[missing, profile("b")],
252 Some("missing"),
253 None,
254 root(),
255 None,
256 )
257 .expect("routes");
258 assert_eq!(routing.author.id(), "missing");
259 }
260
261 #[test]
262 fn when_nothing_is_runnable_the_reason_is_named() {
263 let missing = Profile::parse(
264 r#"
265 id = "gone"
266 command = "definitely-not-a-real-binary-xyz"
267 args = ["{{prompt}}"]
268 "#,
269 )
270 .expect("valid");
271 let err = select(&[missing], None, None, root(), None).expect_err("must refuse");
272 assert!(err.to_string().contains("definitely-not-a-real-binary-xyz"));
273 }
274
275 #[test]
276 fn an_unpicked_reviewer_prefers_a_different_binary_over_a_lower_id() {
277 let same_vendor_a = command_profile("aa-vendor-fast", "true");
281 let same_vendor_b = command_profile("ab-vendor-slow", "true");
282 let other_vendor = command_profile("zz-other", "echo");
283
284 let routing = select(
285 &[same_vendor_a, same_vendor_b, other_vendor],
286 Some("aa-vendor-fast"),
287 None,
288 root(),
289 None,
290 )
291 .expect("routes");
292 assert_eq!(routing.reviewer.id(), "zz-other");
293 }
294
295 #[test]
296 fn one_vendor_on_two_profiles_is_still_a_usable_pair() {
297 let profiles = [
301 command_profile("vendor-fast", "true"),
302 command_profile("vendor-slow", "true"),
303 ];
304 let routing = select(&profiles, None, None, root(), None).expect("routes");
305 assert_eq!(routing.author.id(), "vendor-fast");
306 assert_eq!(routing.reviewer.id(), "vendor-slow");
307 }
308
309 #[test]
310 fn an_unknown_id_is_reported_by_name() {
311 let err =
312 select(&[profile("a")], Some("nope"), None, root(), None).expect_err("must refuse");
313 assert!(err.to_string().contains("nope"));
314 }
315}