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 select_until(
53 profiles,
54 author_id,
55 reviewer_id,
56 isolation_root,
57 timeout,
58 &ostraka_adapter::interrupt::Stop::new(),
59 )
60}
61
62pub fn select_until(
69 profiles: &[Profile],
70 author_id: Option<&str>,
71 reviewer_id: Option<&str>,
72 isolation_root: &Path,
73 timeout: Option<std::time::Duration>,
74 stop: &ostraka_adapter::interrupt::Stop,
75) -> Result<Routing> {
76 if profiles.is_empty() {
77 return Err(Error::Other("no adapter profiles found".to_string()));
78 }
79
80 let find = |id: &str| -> Result<Profile> {
81 profiles
82 .iter()
83 .find(|p| p.id == id)
84 .cloned()
85 .ok_or_else(|| Error::Other(format!("no adapter profile with id {id:?}")))
86 };
87
88 let (author, reviewer) = match (author_id, reviewer_id) {
89 (Some(a), Some(r)) => (find(a)?, find(r)?),
90 (Some(a), None) => {
91 let author = find(a)?;
92 let reviewer = first_other(profiles, Some(&author), Side::Review)?;
93 (author, reviewer)
94 }
95 (None, Some(r)) => {
96 let reviewer = find(r)?;
97 let author = first_other(profiles, Some(&reviewer), Side::Author)?;
98 (author, reviewer)
99 }
100 (None, None) => {
101 let author = first_other(profiles, None, Side::Author)?;
102 let reviewer = first_other(profiles, Some(&author), Side::Review)?;
103 (author, reviewer)
104 }
105 };
106
107 if author.id == reviewer.id {
108 return Err(Error::Other(format!(
109 "author and reviewer are the same adapter ({:?}); a change cannot review itself",
110 author.id
111 )));
112 }
113
114 Ok(Routing {
115 author: Box::new(
116 ProcessAdapter::new(author)
117 .isolated_under(isolation_root)
118 .within(timeout)
119 .stopped_by(stop.clone()),
120 ),
121 reviewer: Box::new(
122 ProcessAdapter::reviewing(reviewer)
123 .isolated_under(isolation_root)
124 .within(timeout)
125 .stopped_by(stop.clone()),
126 ),
127 })
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132enum Side {
133 Author,
134 Review,
135}
136
137fn first_other(profiles: &[Profile], exclude: Option<&Profile>, side: Side) -> Result<Profile> {
162 let excluded_id = exclude.map(|p| p.id.as_str()).unwrap_or_default();
163 let excluded_command = exclude.map(|p| p.command.as_str());
164 let mut others: Vec<&Profile> = profiles.iter().filter(|p| p.id != excluded_id).collect();
165 others.sort_by(|a, b| {
166 let reviews_with_author_invocation =
171 |p: &Profile| side == Side::Review && p.review_args.is_none();
172 let same_binary_as_other = |p: &Profile| excluded_command == Some(p.command.as_str());
173 reviews_with_author_invocation(a)
174 .cmp(&reviews_with_author_invocation(b))
175 .then_with(|| same_binary_as_other(a).cmp(&same_binary_as_other(b)))
176 .then_with(|| a.id.cmp(&b.id))
177 });
178
179 let mut unusable: Vec<String> = Vec::new();
180 for candidate in &others {
181 match ProcessAdapter::new((*candidate).clone()).probe() {
182 a if a.is_ready() => return Ok((*candidate).clone()),
183 Availability::NotFound { command } => {
184 unusable.push(format!("{}: {command} not on PATH", candidate.id));
185 }
186 Availability::Unusable { reason } => {
187 unusable.push(format!("{}: {reason}", candidate.id));
188 }
189 Availability::Ready { .. } => return Ok((*candidate).clone()),
192 }
193 }
194
195 if others.is_empty() {
196 return Err(Error::Other(
197 "only one adapter profile is configured, so no independent reviewer exists; \
198 add a second profile in adapters/"
199 .to_string(),
200 ));
201 }
202 let besides = if excluded_id.is_empty() {
203 String::new()
204 } else {
205 format!(" besides {excluded_id:?}")
206 };
207 Err(Error::Other(format!(
208 "no usable adapter profile{besides}; run `ostraka adapters` for detail. Checked — {}",
209 unusable.join("; ")
210 )))
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 fn root() -> &'static Path {
219 Path::new("/nonexistent-isolation-root")
220 }
221
222 fn command_profile(id: &str, command: &str) -> Profile {
223 Profile::parse(&format!(
224 r#"
225 id = "{id}"
226 command = "{command}"
227 args = ["{{{{prompt}}}}"]
228 "#
229 ))
230 .expect("valid")
231 }
232
233 fn profile(id: &str) -> Profile {
234 Profile::parse(&format!(
235 r#"
236 id = "{id}"
237 command = "true"
238 args = ["{{{{prompt}}}}"]
239 "#
240 ))
241 .expect("valid")
242 }
243
244 #[test]
245 fn a_lone_adapter_cannot_review_itself() {
246 let err = select(&[profile("solo")], None, None, root(), None).expect_err("must refuse");
247 assert!(err.to_string().contains("no independent reviewer"));
248 }
249
250 #[test]
251 fn naming_the_same_adapter_twice_is_refused() {
252 let profiles = [profile("a"), profile("b")];
253 let err = select(&profiles, Some("a"), Some("a"), root(), None).expect_err("must refuse");
254 assert!(err.to_string().contains("cannot review itself"));
255 }
256
257 #[test]
258 fn selection_is_deterministic_not_listing_order() {
259 let forward =
260 select(&[profile("b"), profile("a")], None, None, root(), None).expect("routes");
261 let reverse =
262 select(&[profile("a"), profile("b")], None, None, root(), None).expect("routes");
263 assert_eq!(forward.author.id(), "a");
264 assert_eq!(forward.author.id(), reverse.author.id());
265 assert_eq!(forward.reviewer.id(), "b");
266 }
267
268 #[test]
269 fn a_profile_whose_cli_is_absent_is_not_routed_to() {
270 let missing = Profile::parse(
271 r#"
272 id = "aaa-missing"
273 command = "definitely-not-a-real-binary-xyz"
274 args = ["{{prompt}}"]
275 "#,
276 )
277 .expect("valid");
278 let routing = select(
280 &[missing, profile("b"), profile("c")],
281 None,
282 None,
283 root(),
284 None,
285 )
286 .expect("routes");
287 assert_eq!(routing.author.id(), "b");
288 assert_eq!(routing.reviewer.id(), "c");
289 }
290
291 #[test]
292 fn an_unrunnable_choice_is_still_honoured_when_named() {
293 let missing = Profile::parse(
296 r#"
297 id = "missing"
298 command = "definitely-not-a-real-binary-xyz"
299 args = ["{{prompt}}"]
300 "#,
301 )
302 .expect("valid");
303 let routing = select(
304 &[missing, profile("b")],
305 Some("missing"),
306 None,
307 root(),
308 None,
309 )
310 .expect("routes");
311 assert_eq!(routing.author.id(), "missing");
312 }
313
314 #[test]
315 fn when_nothing_is_runnable_the_reason_is_named() {
316 let missing = Profile::parse(
317 r#"
318 id = "gone"
319 command = "definitely-not-a-real-binary-xyz"
320 args = ["{{prompt}}"]
321 "#,
322 )
323 .expect("valid");
324 let err = select(&[missing], None, None, root(), None).expect_err("must refuse");
325 assert!(err.to_string().contains("definitely-not-a-real-binary-xyz"));
326 }
327
328 #[test]
329 fn an_unpicked_reviewer_prefers_a_different_binary_over_a_lower_id() {
330 let same_vendor_a = command_profile("aa-vendor-fast", "true");
334 let same_vendor_b = command_profile("ab-vendor-slow", "true");
335 let other_vendor = command_profile("zz-other", "echo");
336
337 let routing = select(
338 &[same_vendor_a, same_vendor_b, other_vendor],
339 Some("aa-vendor-fast"),
340 None,
341 root(),
342 None,
343 )
344 .expect("routes");
345 assert_eq!(routing.reviewer.id(), "zz-other");
346 }
347
348 fn reviewing_profile(id: &str) -> Profile {
349 Profile::parse(&format!(
350 r#"
351 id = "{id}"
352 command = "true"
353 args = ["{{{{prompt}}}}", "--write"]
354 review_args = ["{{{{prompt}}}}", "--read-only"]
355 "#
356 ))
357 .expect("valid")
358 }
359
360 #[test]
361 fn an_unpicked_reviewer_is_one_with_a_review_invocation_first() {
362 let profiles = [profile("a"), reviewing_profile("b"), profile("writer")];
365 let routing = select(&profiles, Some("writer"), None, root(), None).expect("routes");
366 assert_eq!(routing.reviewer.id(), "b");
367 }
368
369 #[test]
370 fn posture_outranks_a_different_binary_when_choosing_a_reviewer() {
371 let author = command_profile("author", "true");
375 let other = command_profile("other", "sh");
376 let same = reviewing_profile("same");
377 let routing =
378 select(&[author, other, same], Some("author"), None, root(), None).expect("routes");
379 assert_eq!(routing.reviewer.id(), "same");
380 }
381
382 #[test]
383 fn posture_is_not_asked_of_an_author() {
384 let profiles = [profile("a"), reviewing_profile("b")];
388 let routing = select(&profiles, None, None, root(), None).expect("routes");
389 assert_eq!(routing.author.id(), "a");
390 assert_eq!(routing.reviewer.id(), "b");
391 }
392
393 #[test]
394 fn a_writable_reviewer_is_still_used_when_it_is_the_only_one() {
395 let profiles = [profile("a"), profile("b")];
398 let routing = select(&profiles, Some("a"), None, root(), None).expect("routes");
399 assert_eq!(routing.reviewer.id(), "b");
400 }
401
402 #[test]
403 fn a_named_writable_reviewer_is_honoured() {
404 let profiles = [profile("a"), reviewing_profile("b"), profile("c")];
405 let routing = select(&profiles, Some("b"), Some("c"), root(), None).expect("routes");
406 assert_eq!(routing.reviewer.id(), "c");
407 }
408
409 #[test]
410 fn one_vendor_on_two_profiles_is_still_a_usable_pair() {
411 let profiles = [
415 command_profile("vendor-fast", "true"),
416 command_profile("vendor-slow", "true"),
417 ];
418 let routing = select(&profiles, None, None, root(), None).expect("routes");
419 assert_eq!(routing.author.id(), "vendor-fast");
420 assert_eq!(routing.reviewer.id(), "vendor-slow");
421 }
422
423 #[test]
424 fn an_unknown_id_is_reported_by_name() {
425 let err =
426 select(&[profile("a")], Some("nope"), None, root(), None).expect_err("must refuse");
427 assert!(err.to_string().contains("nope"));
428 }
429}