Skip to main content

omni_dev/jev/
route.rs

1//! Stage routing for `omni-dev ai jev route` (#1779, #1820).
2//!
3//! Asks Jev, in one `system_one` call per issue, which model class should do
4//! each stage of the work — **design**, **implement** and **review** — and
5//! derives the issue's overall class as the higher of design and implement.
6//! The three questions are asked once per requested [`Ladder`] (one per AI
7//! provider), keyed `<provider>.stage_<stage>`, all in that same single call.
8//!
9//! Everything here consumes only the provider-neutral [`IssueDoc`], so a
10//! GitLab fetcher slots in without touching it. The question wording, the
11//! `anthropic` tier descriptions and the input format are the ones validated
12//! against `jev-1.13.0` in #1779; see `docs/jev.md` for the evidence and its
13//! limits.
14
15use std::collections::{BTreeMap, BTreeSet};
16use std::path::Path;
17
18use anyhow::{bail, Context, Result};
19use clap::ValueEnum;
20use serde::{Deserialize, Serialize};
21use tracing::warn;
22
23use crate::jev::citations::Citation;
24use crate::jev::client::JevClient;
25use crate::jev::error::is_auth_failure;
26use crate::jev::input::truncate_middle;
27use crate::jev::protocol::{Answer, Question, SystemOneRequest, Usage};
28use crate::provider::{IssueDoc, ItemState};
29
30pub use crate::jev::input::TRUNCATION_MARKER;
31
32pub mod effort;
33
34/// The three stage questions, each missing its per-tier criteria.
35const STAGE_QUESTIONS_YAML: &str = include_str!("../templates/jev-route-questions.yaml");
36
37/// The design-stage option meaning "no design work remains". Reserved: no
38/// tier may use this name.
39pub const NO_DESIGN: &str = "none";
40
41/// An AI provider with an embedded model ladder (#1820).
42///
43/// Each variant's ladder lives in `src/templates/jev-route-tiers-<name>.yaml`,
44/// least capable first, named by the abbreviated model name so the consumer
45/// gets a model to hand the work to rather than a rung to translate. Only
46/// `anthropic`'s text was validated against Jev (#1779); the other ladders
47/// reuse its descriptions rung for rung, differing only in the tier names
48/// Jev sees as criterion keys. `anthropic` has two rungs (#1903 dropped
49/// `fable` as redundant with `opus`); `openai`/`gemini` still have three.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
51pub enum Provider {
52    /// `sonnet` / `opus`.
53    #[value(name = "anthropic")]
54    Anthropic,
55    /// `terra` / `sol` / `astra` (gpt-5.6-terra / gpt-5.6-sol / gpt-6-astra).
56    #[value(name = "openai")]
57    OpenAi,
58    /// `flash` / `pro` / `deep-think` (gemini-3-flash-preview /
59    /// gemini-3.1-pro-preview / Gemini 3 Deep Think).
60    #[value(name = "gemini")]
61    Gemini,
62}
63
64impl Provider {
65    /// Every provider, in the order the docs list them.
66    pub const ALL: [Self; 3] = [Self::Anthropic, Self::OpenAi, Self::Gemini];
67
68    /// The provider's name: its clap value, its output key and its question
69    /// key prefix.
70    #[must_use]
71    pub const fn name(self) -> &'static str {
72        match self {
73            Self::Anthropic => "anthropic",
74            Self::OpenAi => "openai",
75            Self::Gemini => "gemini",
76        }
77    }
78
79    /// The provider's embedded ladder file.
80    const fn tiers_yaml(self) -> &'static str {
81        match self {
82            Self::Anthropic => include_str!("../templates/jev-route-tiers-anthropic.yaml"),
83            Self::OpenAi => include_str!("../templates/jev-route-tiers-openai.yaml"),
84            Self::Gemini => include_str!("../templates/jev-route-tiers-gemini.yaml"),
85        }
86    }
87
88    /// The provider's embedded ladder, parsed and validated.
89    pub fn tiers(self) -> Result<Tiers> {
90        Tiers::parse(self.tiers_yaml())
91            .with_context(|| format!("Invalid embedded tiers for provider {}", self.name()))
92    }
93}
94
95/// Default confidence below which a stage is reported as a close call. A
96/// working heuristic from the #1779 backlog run, not a validated threshold.
97pub const DEFAULT_CLOSE_CALL: f64 = 0.3;
98
99/// Default cap, in characters, on the issue text sent to Jev. Jev's input
100/// limit is undocumented; the longest input tested was about 48k characters.
101pub const DEFAULT_MAX_INPUT_CHARS: usize = 60_000;
102
103/// Fewest tiers a tiers file may define — routing between one class is not
104/// a judgment.
105const MIN_TIERS: usize = 2;
106
107/// The open citations found in each routed issue's own text (#1812).
108///
109/// Keyed by the citing issue's `(project, number)`. Built by the CLI's
110/// blocking `gh` fetch (`fetch_docs`) — [`run_route`] only reads it, so it
111/// stays free of GitHub-specific I/O.
112pub type OpenDependencies = BTreeMap<(String, u64), Vec<Citation>>;
113
114/// Citations whose individual GitHub lookup did not resolve.
115///
116/// Keyed by the citing issue's `(project, number)`, like
117/// [`OpenDependencies`]. These are retained in route output so a typo or
118/// stale reference is visible rather than being mistaken for no citation.
119pub type ReferenceFetchFailures = BTreeMap<(String, u64), Vec<ReferenceFetchFailure>>;
120
121/// A citation GitHub could not fetch while preparing a route.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
123pub struct ReferenceFetchFailure {
124    /// The citation exactly as written in the issue text.
125    #[serde(rename = "ref")]
126    pub item_ref: String,
127    /// Why the reference could not be fetched.
128    pub error: String,
129}
130
131/// One stage of the work on an issue.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
133#[serde(rename_all = "lowercase")]
134pub enum Stage {
135    /// Choosing the approach, settling open questions, writing a plan.
136    Design,
137    /// Writing the code, tests and docs.
138    Implement,
139    /// Reviewing the finished change before merge.
140    Review,
141}
142
143impl Stage {
144    /// Every stage, in the order they happen.
145    const ALL: [Self; 3] = [Self::Design, Self::Implement, Self::Review];
146
147    /// The key of this stage's question in the embedded questions file.
148    const fn template_key(self) -> &'static str {
149        match self {
150            Self::Design => "stage_design",
151            Self::Implement => "stage_implement",
152            Self::Review => "stage_review",
153        }
154    }
155
156    /// The key of this stage's question for `provider` in the Jev request
157    /// and response, e.g. `anthropic.stage_design`.
158    fn question_key(self, provider: &str) -> String {
159        format!("{provider}.{}", self.template_key())
160    }
161}
162
163/// One model class a stage can be routed to.
164#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct Tier {
167    /// The option name Jev picks, e.g. `sonnet`.
168    pub name: String,
169    /// What this class is reliable at, shown to Jev as the option's criterion.
170    pub description: String,
171    /// Concrete downstream models and their effort capabilities; absent for legacy ladders.
172    pub models: Option<Vec<effort::Model>>,
173}
174
175/// The on-disk shape of a tiers file.
176#[derive(Deserialize)]
177#[serde(deny_unknown_fields)]
178struct TiersFile {
179    tiers: Vec<Tier>,
180}
181
182/// An ordered list of [`Tier`]s, from least to most capable.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct Tiers(Vec<Tier>);
185
186impl Tiers {
187    /// Parses and validates a tiers file: at least two tiers, non-empty
188    /// unique names and descriptions, and no tier named [`NO_DESIGN`].
189    pub fn parse(yaml: &str) -> Result<Self> {
190        let file: TiersFile = serde_yaml::from_str(yaml).context("Failed to parse tiers file")?;
191        if file.tiers.len() < MIN_TIERS {
192            bail!(
193                "a tiers file needs at least {MIN_TIERS} tiers, got {}",
194                file.tiers.len()
195            );
196        }
197        let mut seen = BTreeSet::new();
198        for tier in &file.tiers {
199            if tier.name.trim().is_empty() || tier.description.trim().is_empty() {
200                bail!("every tier needs a non-empty name and description");
201            }
202            if tier.name == NO_DESIGN {
203                bail!("the tier name {NO_DESIGN:?} is reserved for \"no design work remains\"");
204            }
205            if !seen.insert(tier.name.as_str()) {
206                bail!("tier {:?} is defined more than once", tier.name);
207            }
208        }
209        let mut model_rungs = BTreeMap::new();
210        for tier in &file.tiers {
211            if let Some(models) = &tier.models {
212                effort::validate(models).with_context(|| format!("rung {:?}", tier.name))?;
213                for model in models {
214                    for &stage in &model.stages {
215                        if let Some(previous) = model_rungs.insert((&model.name, stage), &tier.name)
216                        {
217                            bail!(
218                                "model {:?} is bound to both rungs {previous:?} and {:?} for {stage:?}",
219                                model.name,
220                                tier.name
221                            );
222                        }
223                    }
224                }
225            }
226        }
227        Ok(Self(file.tiers))
228    }
229
230    /// Loads a custom tiers file.
231    pub fn load_file(path: &Path) -> Result<Self> {
232        let yaml = std::fs::read_to_string(path)
233            .with_context(|| format!("Failed to read tiers file {}", path.display()))?;
234        Self::parse(&yaml).with_context(|| format!("Invalid tiers file {}", path.display()))
235    }
236
237    /// The tiers, least capable first.
238    #[must_use]
239    pub fn as_slice(&self) -> &[Tier] {
240        &self.0
241    }
242
243    /// The rank of `choice` on the scale `none` (0) < first tier (1) < … —
244    /// `None` if `choice` is neither [`NO_DESIGN`] nor a tier.
245    fn rank(&self, choice: &str) -> Option<usize> {
246        if choice == NO_DESIGN {
247            return Some(0);
248        }
249        self.0.iter().position(|t| t.name == choice).map(|i| i + 1)
250    }
251}
252
253/// One model ladder (#1820, #1826).
254///
255/// The name its answers are reported under and the tiers Jev chooses
256/// between. A ladder is either a built-in provider's embedded ladder or a
257/// custom one registered under its own name via `--ladder-definition`.
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct Ladder {
260    /// The ladder's name: the output key and the question-key prefix.
261    pub name: String,
262    /// The tiers, least capable first.
263    pub tiers: Tiers,
264}
265
266impl Ladder {
267    /// A built-in provider's embedded ladder, named after the provider.
268    pub fn builtin(provider: Provider) -> Result<Self> {
269        Ok(Self {
270            name: provider.name().to_string(),
271            tiers: provider.tiers()?,
272        })
273    }
274
275    /// A custom ladder registered under `name` (#1826).
276    #[must_use]
277    pub fn named(name: String, tiers: Tiers) -> Self {
278        Self { name, tiers }
279    }
280}
281
282/// Builds class and per-model effort questions for every ladder.
283///
284/// Class questions use the embedded wording with one criterion per tier,
285/// keyed `<provider>.stage_<stage>`. All questions share one request. The
286/// route command uses a class-only subset unless effort advice is requested.
287pub fn build_route_questions(ladders: &[Ladder]) -> Result<BTreeMap<String, Question>> {
288    build_route_questions_for_mode(ladders, true)
289}
290
291fn build_route_questions_for_mode(
292    ladders: &[Ladder],
293    effort_advice: bool,
294) -> Result<BTreeMap<String, Question>> {
295    let templates: BTreeMap<String, Question> = serde_yaml::from_str(STAGE_QUESTIONS_YAML)
296        .context("Failed to parse the embedded stage questions")?;
297    for stage in Stage::ALL {
298        if !templates.contains_key(stage.template_key()) {
299            bail!(
300                "the embedded stage questions have no {:?}",
301                // omni-dev: coverage ignore-line reason="defensive: the embedded questions YAML always has all three stage keys, pinned by stage_questions_are_the_tested_wording"
302                stage.template_key()
303            );
304        }
305    }
306    let mut questions = BTreeMap::new();
307    for ladder in ladders {
308        for (key, template) in &templates {
309            let mut question = template.clone();
310            let Question::Choice { criteria, .. } = &mut question else {
311                // omni-dev: coverage ignore-line reason="defensive: every embedded stage question is a choice question, pinned by stage_questions_are_the_tested_wording"
312                bail!("embedded stage question {key:?} is not a choice question");
313            };
314            for tier in ladder.tiers.as_slice() {
315                if criteria
316                    .insert(tier.name.clone(), tier.description.clone())
317                    .is_some()
318                {
319                    // omni-dev: coverage ignore-line reason="defensive: no embedded or custom tier is ever named `none`, the one fixed criterion (stage_design's no-design-work option)"
320                    bail!(
321                        "tier {:?} of ladder {:?} collides with a fixed option of {key:?}",
322                        tier.name,
323                        ladder.name
324                    );
325                }
326            }
327            question
328                .validate()
329                .with_context(|| format!("stage question {key:?} for {:?}", ladder.name))?;
330            questions.insert(format!("{}.{key}", ladder.name), question);
331        }
332    }
333    if effort_advice {
334        for ladder in ladders {
335            effort::add_questions(ladder, &mut questions);
336        }
337    }
338    Ok(questions)
339}
340
341/// Builds the text Jev judges for one issue: its title, body and human
342/// comments. Returns the text and whether it was truncated.
343///
344/// Text over `max_chars` keeps its first and last halves with a
345/// [`TRUNCATION_MARKER`] between them, rather than losing its end: the end
346/// holds the latest comments, and a decision comment there is what moves the
347/// design stage most (#1779). No validated input reached the cap, so this
348/// policy itself is untested against Jev.
349///
350/// The layout is the input format validated in #1779 (condition C1), not the
351/// slightly different format the issue body first proposed. The one
352/// difference from the evaluation script is that bot comments are dropped
353/// upstream, as the issue asks; the script kept them. Referenced issues and
354/// pull requests are deliberately **not** included: they cost about twice the
355/// tokens and inflate small issues.
356#[must_use]
357pub fn build_route_state(doc: &IssueDoc, max_chars: usize) -> (String, bool) {
358    let mut text = format!("# #{} {}\n\n{}\n", doc.number, doc.title, doc.body.trim());
359    if !doc.comments.is_empty() {
360        text.push_str("\n\n---\n\n## Comments on this issue\n");
361        for comment in &doc.comments {
362            text.push_str(&format!(
363                "\n\n**Comment by {}:**\n\n{}\n",
364                comment.author,
365                comment.body.trim()
366            ));
367        }
368    }
369    truncate_middle(&text, max_chars)
370}
371
372/// Jev's answer for one stage.
373#[derive(Debug, Clone, PartialEq, Serialize)]
374pub struct StageAnswer {
375    /// The chosen tier, or [`NO_DESIGN`] for the design stage.
376    pub choice: String,
377    /// Jev's confidence in `choice`.
378    pub confidence: f64,
379    /// The probability of every option offered.
380    pub probabilities: BTreeMap<String, f64>,
381    /// Effort advice for every rung and its concrete models, independent of the selected rung.
382    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
383    pub effort_by_model: BTreeMap<String, Vec<effort::ModelEffort>>,
384}
385
386/// The answers for all three stages of one issue.
387#[derive(Debug, Clone, PartialEq, Serialize)]
388pub struct StageAnswers {
389    /// Who should do the remaining design work, if any.
390    pub design: StageAnswer,
391    /// Who should write the code, tests and docs.
392    pub implement: StageAnswer,
393    /// Who should review the finished change.
394    pub review: StageAnswer,
395}
396
397impl StageAnswers {
398    fn get(&self, stage: Stage) -> &StageAnswer {
399        match stage {
400            Stage::Design => &self.design,
401            Stage::Implement => &self.implement,
402            Stage::Review => &self.review,
403        }
404    }
405}
406
407/// One open issue/PR a routed issue's text cites, and whether resolving it
408/// would plausibly reduce the remaining work (#1812).
409#[derive(Debug, Clone, PartialEq, Serialize)]
410pub struct DependencyEntry {
411    /// The citation exactly as written, e.g. `"#1129"` or `"PR #1629"` — not
412    /// a reconstructed `owner/repo#N`, since the raw text already carries
413    /// whatever form the issue used.
414    #[serde(rename = "ref")]
415    pub item_ref: String,
416    /// Resolved GitHub URL for terminal links; not part of the data format.
417    #[serde(skip)]
418    pub url: Option<String>,
419    /// Always [`ItemState::Open`]: a closed citation is settled and is not
420    /// reported here.
421    pub state: ItemState,
422    /// The probability, per stage, that resolving this citation would leave
423    /// less of that stage's work remaining than the text implies. Only
424    /// `"design"` is populated for v1 — `"implement"` is deferred pending
425    /// the same kind of live validation `design` got (see #1812).
426    pub could_be_cheaper: BTreeMap<String, f64>,
427}
428
429/// The routing result for one issue.
430#[derive(Debug, Clone, PartialEq, Serialize)]
431pub struct IssueRoute {
432    /// The issue, as `owner/repo#N`.
433    #[serde(rename = "ref")]
434    pub item_ref: String,
435    /// The issue's web URL.
436    pub url: String,
437    /// The issue title.
438    pub title: String,
439    /// The issue's own open/closed state on GitHub — distinct from
440    /// `depends_on`'s `state`, which is always [`ItemState::Open`] and
441    /// describes a *citation*, not this issue.
442    pub state: ItemState,
443    /// The routing, or why this issue could not be routed.
444    #[serde(flatten)]
445    pub outcome: RouteOutcome,
446    /// Whether the issue text was cut at the input cap.
447    #[serde(skip_serializing_if = "std::ops::Not::not")]
448    pub truncated: bool,
449}
450
451/// One provider's routing of an issue (#1820).
452#[derive(Debug, Clone, PartialEq, Serialize)]
453pub struct ProviderRoute {
454    /// Jev's answer per stage.
455    pub stages: StageAnswers,
456    /// The issue's class: the higher of the design and implement choices.
457    pub class: String,
458    /// Stages whose confidence is below the close-call threshold.
459    pub close_calls: Vec<Stage>,
460}
461
462/// What routing one issue produced.
463///
464/// Serialised untagged and flattened into [`IssueRoute`], so a routed issue
465/// carries `providers`/`depends_on` and a failed one carries `error` and any
466/// citation fetch failures.
467#[derive(Debug, Clone, PartialEq, Serialize)]
468#[serde(untagged)]
469pub enum RouteOutcome {
470    /// Jev answered every stage for every ladder.
471    Routed {
472        /// Each requested ladder's routing, keyed by provider name.
473        providers: BTreeMap<String, ProviderRoute>,
474        /// Open issues/PRs this issue's text cites (#1812). About the issue,
475        /// not a provider, so it sits beside `providers` rather than inside.
476        depends_on: Vec<DependencyEntry>,
477        /// Citations that could not be fetched. They are not dependencies
478        /// because their state is unknown, but remain visible to the user.
479        #[serde(skip_serializing_if = "Vec::is_empty")]
480        reference_fetch_failures: Vec<ReferenceFetchFailure>,
481    },
482    /// The Jev call for this issue failed, or its answer was unusable. The
483    /// other issues are still routed, so a long run keeps what it paid for.
484    Failed {
485        /// The error chain, on one line.
486        error: String,
487        /// Citations that could not be fetched before the Jev call failed.
488        #[serde(skip_serializing_if = "Vec::is_empty")]
489        reference_fetch_failures: Vec<ReferenceFetchFailure>,
490    },
491}
492
493impl IssueRoute {
494    /// Whether this issue failed to route.
495    #[must_use]
496    pub fn failed(&self) -> bool {
497        matches!(self.outcome, RouteOutcome::Failed { .. })
498    }
499}
500
501/// The routing result for every issue.
502#[derive(Debug, Clone, PartialEq, Serialize)]
503pub struct RouteReport {
504    /// The Jev model that answered (comma-separated in the unlikely case the
505    /// alias moved mid-run).
506    pub model: String,
507    /// One entry per routed issue, in the order requested.
508    pub issues: Vec<IssueRoute>,
509    /// Closed issues dropped from the batch by
510    /// [`RouteOptions::ignore_closed`], in the order requested (#2000).
511    /// Omitted when empty, so a run that skipped nothing serialises as before.
512    #[serde(skip_serializing_if = "Vec::is_empty")]
513    pub ignored_closed: Vec<IgnoredIssue>,
514    /// Token usage summed over every Jev call.
515    pub usage: Usage,
516}
517
518/// A closed issue [`RouteOptions::ignore_closed`] dropped from the batch
519/// without routing it (#2000).
520#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
521pub struct IgnoredIssue {
522    /// The issue, as `owner/repo#N`.
523    #[serde(rename = "ref")]
524    pub item_ref: String,
525    /// The issue's web URL.
526    pub url: String,
527    /// The issue title.
528    pub title: String,
529}
530
531/// Knobs for [`run_route`].
532#[derive(Debug, Clone)]
533pub struct RouteOptions {
534    /// The Jev model to request.
535    pub model: String,
536    /// Confidence below which a stage is a close call.
537    pub close_call: f64,
538    /// Ask for per-model effort advice as well as class routing.
539    pub effort_advice: bool,
540    /// Cap, in characters, on each issue's text.
541    pub max_input_chars: usize,
542    /// Route closed issues instead of refusing them.
543    pub allow_closed: bool,
544    /// Drop closed issues from the batch instead of refusing them, listing
545    /// them under [`RouteReport::ignored_closed`] (#2000). Mutually
546    /// exclusive with [`RouteOptions::allow_closed`].
547    pub ignore_closed: bool,
548}
549
550/// Routes every issue in `docs`, one Jev call each, in order.
551///
552/// Every ladder in `ladders` is routed in that same call: Jev takes a map of
553/// questions over one state, so the three stage questions are keyed per
554/// provider and share the issue text (#1820).
555///
556/// Refuses closed issues unless [`RouteOptions::allow_closed`]: their comments
557/// often describe how the work was actually done, which leaks the answer.
558/// With [`RouteOptions::ignore_closed`] they are instead dropped before any
559/// request is sent and listed under [`RouteReport::ignored_closed`]; a batch
560/// with no open issue left is still an error.
561///
562/// `dependencies` is each issue's open citations (#1812), keyed by `(project,
563/// number)` — pre-resolved by the caller's `gh` fetch, since this function
564/// only ever talks to Jev. An issue with no entry (or an empty one) gets an
565/// empty `depends_on` and no extra questions.
566///
567/// A failure on one issue is recorded on that issue as
568/// [`RouteOutcome::Failed`] and the run continues, so a transient error late
569/// in a long `--all-open` run does not discard the answers already paid for.
570/// The exception is an authentication failure (HTTP 401/403), which would
571/// fail every remaining issue the same way and so ends the run.
572pub async fn run_route(
573    jev: &JevClient,
574    docs: &[IssueDoc],
575    ladders: &[Ladder],
576    opts: &RouteOptions,
577    dependencies: &OpenDependencies,
578) -> Result<RouteReport> {
579    run_route_with_reference_fetch_failures(
580        jev,
581        docs,
582        ladders,
583        opts,
584        dependencies,
585        &ReferenceFetchFailures::new(),
586    )
587    .await
588}
589
590/// Like [`run_route`], while retaining per-citation fetch failures in the
591/// report. The CLI supplies this data after its blocking GitHub fetch.
592pub async fn run_route_with_reference_fetch_failures(
593    jev: &JevClient,
594    docs: &[IssueDoc],
595    ladders: &[Ladder],
596    opts: &RouteOptions,
597    dependencies: &OpenDependencies,
598    reference_fetch_failures: &ReferenceFetchFailures,
599) -> Result<RouteReport> {
600    let (docs, ignored_closed) = drop_closed(docs, opts)?;
601    validate_options(&docs, ladders, opts)?;
602    let questions = build_route_questions_for_mode(ladders, opts.effort_advice)?;
603
604    let mut models = BTreeSet::new();
605    let mut usage = Usage::default();
606    let mut issues = Vec::with_capacity(docs.len());
607    for doc in docs {
608        let item_ref = item_ref(doc);
609        let (state, truncated) = build_route_state(doc, opts.max_input_chars);
610        if truncated {
611            warn!(
612                "Issue {item_ref} is longer than {} characters; its input was truncated",
613                opts.max_input_chars
614            );
615        }
616        let citations = dependencies
617            .get(&(doc.project.clone(), doc.number))
618            .map_or(&[][..], Vec::as_slice);
619        let citation_failures = reference_fetch_failures
620            .get(&(doc.project.clone(), doc.number))
621            .cloned()
622            .unwrap_or_default();
623        let mut request_questions = questions.clone();
624        for (i, citation) in citations.iter().enumerate() {
625            request_questions.insert(
626                could_be_cheaper_key(i),
627                could_be_cheaper_question(&citation.raw),
628            );
629        }
630        let request = SystemOneRequest {
631            state: serde_json::Value::String(state),
632            model: opts.model.clone(),
633            questions: request_questions,
634        };
635        let outcome = match jev.system_one(&request).await {
636            Err(err) if is_auth_failure(&err) => {
637                return Err(err).with_context(|| format!("Failed to route issue {item_ref}"));
638            }
639            Err(err) => failed(
640                &item_ref,
641                &err.context("Jev request failed"),
642                &citation_failures,
643            ),
644            Ok(response) => {
645                models.insert(response.model);
646                usage.input_tokens += response.usage.input_tokens;
647                usage.output_tokens += response.usage.output_tokens;
648                match provider_routes(
649                    &response.answers,
650                    ladders,
651                    opts.close_call,
652                    opts.effort_advice,
653                ) {
654                    Ok(providers) => RouteOutcome::Routed {
655                        providers,
656                        depends_on: dependency_entries(&item_ref, citations, &response.answers),
657                        reference_fetch_failures: citation_failures.clone(),
658                    },
659                    Err(err) => failed(
660                        &item_ref,
661                        &err.context("Unexpected Jev answer"),
662                        &citation_failures,
663                    ),
664                }
665            }
666        };
667        issues.push(IssueRoute {
668            item_ref,
669            url: doc.url.clone(),
670            title: doc.title.clone(),
671            state: doc.state,
672            outcome,
673            truncated,
674        });
675    }
676
677    Ok(RouteReport {
678        model: models.into_iter().collect::<Vec<_>>().join(", "),
679        issues,
680        ignored_closed,
681        usage,
682    })
683}
684
685/// Whether `--ignore-closed` drops `doc` from the batch. Shared with the
686/// CLI, which skips resolving the citations of exactly these issues.
687#[must_use]
688pub fn is_ignored_closed(doc: &IssueDoc, ignore_closed: bool) -> bool {
689    ignore_closed && doc.state == ItemState::Closed
690}
691
692/// `doc`'s `owner/repo#N` reference.
693fn item_ref(doc: &IssueDoc) -> String {
694    format!("{}#{}", doc.project, doc.number)
695}
696
697/// Splits off the closed issues [`RouteOptions::ignore_closed`] skips,
698/// returning the issues still to route. Without the flag every issue is kept,
699/// so [`validate_options`] can refuse the closed ones. Refuses the flag
700/// alongside [`RouteOptions::allow_closed`] first, so that conflict is
701/// reported whatever the batch holds, then a batch the flag would leave empty, naming what it skipped, rather than letting it
702/// surface as the less helpful "no issues to route".
703fn drop_closed<'a>(
704    docs: &'a [IssueDoc],
705    opts: &RouteOptions,
706) -> Result<(Vec<&'a IssueDoc>, Vec<IgnoredIssue>)> {
707    if opts.allow_closed && opts.ignore_closed {
708        bail!("--allow-closed and --ignore-closed cannot be used together");
709    }
710    let (open, closed): (Vec<&IssueDoc>, Vec<&IssueDoc>) = docs
711        .iter()
712        .partition(|d| !is_ignored_closed(d, opts.ignore_closed));
713    if open.is_empty() && !closed.is_empty() {
714        bail!(
715            "every requested issue is closed ({}) and --ignore-closed skipped them; nothing to \
716             route",
717            closed
718                .iter()
719                .map(|d| item_ref(d))
720                .collect::<Vec<_>>()
721                .join(", ")
722        );
723    }
724    let ignored = closed
725        .into_iter()
726        .map(|d| IgnoredIssue {
727            item_ref: item_ref(d),
728            url: d.url.clone(),
729            title: d.title.clone(),
730        })
731        .collect();
732    Ok((open, ignored))
733}
734
735/// Records `err` as `item_ref`'s outcome, warning so a long run shows it as
736/// it happens.
737fn failed(
738    item_ref: &str,
739    err: &anyhow::Error,
740    reference_fetch_failures: &[ReferenceFetchFailure],
741) -> RouteOutcome {
742    let error = format!("{err:#}");
743    warn!("Could not route issue {item_ref}: {error}");
744    RouteOutcome::Failed {
745        error,
746        reference_fetch_failures: reference_fetch_failures.to_vec(),
747    }
748}
749
750/// Rejects an empty run, an empty or repeated ladder list, out-of-range
751/// knobs, and (by default) closed issues, before any paid request is sent.
752fn validate_options(docs: &[&IssueDoc], ladders: &[Ladder], opts: &RouteOptions) -> Result<()> {
753    if docs.is_empty() {
754        bail!("no issues to route");
755    }
756    if ladders.is_empty() {
757        bail!("no ladders to route against");
758    }
759    let mut seen = BTreeSet::new();
760    for ladder in ladders {
761        if !seen.insert(ladder.name.as_str()) {
762            bail!("ladder {:?} is listed more than once", ladder.name);
763        }
764    }
765    if !(0.0..=1.0).contains(&opts.close_call) {
766        bail!(
767            "the close-call threshold must be between 0 and 1, got {}",
768            opts.close_call
769        );
770    }
771    if opts.max_input_chars == 0 {
772        bail!("the input cap must be at least 1 character");
773    }
774    if !opts.allow_closed {
775        let closed: Vec<String> = docs
776            .iter()
777            .filter(|d| d.state == ItemState::Closed)
778            .map(|d| item_ref(d))
779            .collect();
780        if !closed.is_empty() {
781            bail!(
782                "refusing to route closed issues ({}): their comments often describe how the \
783                 work was done, which leaks the answer. Pass --ignore-closed to skip them, or \
784                 --allow-closed to route them anyway",
785                closed.join(", ")
786            );
787        }
788    }
789    Ok(())
790}
791
792/// Reads every ladder's routing out of one response. Any ladder whose
793/// answers are missing or malformed fails the whole issue, as a single
794/// ladder's did before #1820.
795fn provider_routes(
796    answers: &BTreeMap<String, Answer>,
797    ladders: &[Ladder],
798    close_call: f64,
799    effort_advice: bool,
800) -> Result<BTreeMap<String, ProviderRoute>> {
801    ladders
802        .iter()
803        .map(|ladder| {
804            let mut stages = stage_answers(answers, ladder)?;
805            if effort_advice {
806                for (stage, answer) in [
807                    (Stage::Design, &mut stages.design),
808                    (Stage::Implement, &mut stages.implement),
809                    (Stage::Review, &mut stages.review),
810                ] {
811                    answer.effort_by_model =
812                        effort::decode(ladder, stage, answer, answers, close_call)?;
813                }
814            }
815            let route = ProviderRoute {
816                class: issue_class(&stages, &ladder.tiers),
817                close_calls: close_calls(&stages, close_call),
818                stages,
819            };
820            Ok((ladder.name.clone(), route))
821        })
822        .collect()
823}
824
825/// Reads one ladder's three stage answers out of a response, checking each
826/// is a `choice` naming an option that was offered.
827fn stage_answers(answers: &BTreeMap<String, Answer>, ladder: &Ladder) -> Result<StageAnswers> {
828    let tiers = &ladder.tiers;
829    let read = |stage: Stage| -> Result<StageAnswer> {
830        let key = stage.question_key(&ladder.name);
831        let Some(Answer::Choice {
832            choice,
833            confidence,
834            probabilities,
835        }) = answers.get(&key)
836        else {
837            bail!("no choice answer for {key:?}");
838        };
839        let offered = match tiers.rank(choice) {
840            Some(0) => stage == Stage::Design,
841            Some(_) => true,
842            None => false,
843        };
844        if !offered {
845            bail!("{key:?} chose {choice:?}, which was not offered");
846        }
847        Ok(StageAnswer {
848            choice: choice.clone(),
849            confidence: *confidence,
850            probabilities: probabilities.clone(),
851            effort_by_model: BTreeMap::new(),
852        })
853    };
854    Ok(StageAnswers {
855        design: read(Stage::Design)?,
856        implement: read(Stage::Implement)?,
857        review: read(Stage::Review)?,
858    })
859}
860
861/// The higher of the design and implement choices. Implement is always a
862/// tier, so the result is always a tier, never [`NO_DESIGN`].
863fn issue_class(stages: &StageAnswers, tiers: &Tiers) -> String {
864    [&stages.design, &stages.implement]
865        .into_iter()
866        .max_by_key(|a| tiers.rank(&a.choice).unwrap_or_default())
867        .map(|a| a.choice.clone())
868        .unwrap_or_default()
869}
870
871/// The stages whose confidence is below `threshold`, in stage order.
872fn close_calls(stages: &StageAnswers, threshold: f64) -> Vec<Stage> {
873    Stage::ALL
874        .into_iter()
875        .filter(|&s| stages.get(s).confidence < threshold)
876        .collect()
877}
878
879/// The request key for the `i`th open citation's `could_be_cheaper` question.
880fn could_be_cheaper_key(i: usize) -> String {
881    format!("could_be_cheaper_{i}")
882}
883
884/// Builds the `could_be_cheaper` question for one open citation (#1812).
885/// Design-stage only for v1 — the exact wording validated live against
886/// `jev-1.13.0`, 2026-09-20; see docs/jev.md for the evidence. Do not reword
887/// without re-validating (pinned by
888/// `could_be_cheaper_question_is_the_tested_wording`).
889fn could_be_cheaper_question(citation: &str) -> Question {
890    Question::Noul {
891        instructions: format!(
892            "This issue cites {citation}, which is still open. If {citation} is resolved, how \
893             likely is it that LESS design work would remain for THIS issue than the current \
894             text implies — as opposed to this issue's own remaining work being unaffected, \
895             because it is already scoped separately, is a parallel/sibling effort, or \
896             {citation} is otherwise not a precondition for finishing this issue's own \
897             remaining work?"
898        ),
899        criteria: None,
900    }
901}
902
903/// Builds `depends_on` from `citations` and the Jev response that answered
904/// one `could_be_cheaper` question per citation.
905///
906/// A missing or malformed answer for one citation drops just that
907/// dependency — logged, not failed — since `could_be_cheaper` is additive on
908/// top of a routing result ([`stage_answers`]) that already succeeded.
909fn dependency_entries(
910    item_ref: &str,
911    citations: &[Citation],
912    answers: &BTreeMap<String, Answer>,
913) -> Vec<DependencyEntry> {
914    citations
915        .iter()
916        .enumerate()
917        .filter_map(|(i, citation)| {
918            if let Some(Answer::Noul { noul }) = answers.get(&could_be_cheaper_key(i)) {
919                return Some(DependencyEntry {
920                    item_ref: citation.raw.clone(),
921                    url: citation.url.clone(),
922                    state: ItemState::Open,
923                    could_be_cheaper: BTreeMap::from([("design".to_string(), *noul)]),
924                });
925            }
926            warn!(
927                "Issue {item_ref}: no could_be_cheaper answer for citation {:?}",
928                citation.raw
929            );
930            None
931        })
932        .collect()
933}
934
935/// Renders `report` as one block per issue, blank-line separated, in request
936/// order, followed by a trailing model/usage line (#1824).
937///
938/// Plain text, deliberately with no markdown: it's read directly in a
939/// terminal, which doesn't render `**bold**`/`*italic*` markers, so they'd
940/// just be clutter. Each issue is a header line (`ref — title`, with a
941/// trailing ` (closed)` when the issue itself is closed — relevant here since
942/// `--allow-closed` routes them) followed by one indented line per fact — one
943/// per provider's routing, then `depends_on` — rather than one run-on
944/// sentence, which reads poorly once more than one provider is involved.
945/// Closed issues `--ignore-closed` skipped get one `ignored closed:` line
946/// before the `model`/`usage` footer.
947///
948/// `max_input_chars` is threaded in explicitly rather than read off
949/// `RouteReport` (which doesn't carry it) so a truncated issue's block can
950/// name the actual configured cap. This is still a pure, side-effect-free
951/// function of its arguments, unit-tested directly with fixture reports.
952///
953/// The wording is **not** a stable contract: it may change without notice.
954/// Scripts should use `json` or `yaml` instead.
955#[must_use]
956pub fn render_route_text(report: &RouteReport, max_input_chars: usize) -> String {
957    render_route_text_styled(report, max_input_chars, &[], TerminalStyle::default())
958}
959
960/// Terminal features selected by the CLI. Passing them explicitly keeps the
961/// renderer deterministic and lets callers retain plain output for pipes.
962#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
963pub struct TerminalStyle {
964    /// Emit SGR colour sequences.
965    pub color: bool,
966    /// Emit OSC 8 links around issue references.
967    pub hyperlinks: bool,
968}
969
970impl TerminalStyle {
971    /// Disables escape sequences for redirected output and dumb terminals.
972    #[must_use]
973    pub fn new(tty: bool, term: &str, no_color: bool, supports_links: bool) -> Self {
974        let usable = tty && !term.is_empty() && term != "dumb";
975        Self {
976            color: usable && !no_color,
977            hyperlinks: usable && supports_links,
978        }
979    }
980}
981
982/// Renders the human report with terminal presentation when supported.
983#[must_use]
984pub fn render_route_text_styled(
985    report: &RouteReport,
986    max_input_chars: usize,
987    ladders: &[Ladder],
988    style: TerminalStyle,
989) -> String {
990    let mut blocks: Vec<String> = report
991        .issues
992        .iter()
993        .map(|issue| render_issue_block(issue, max_input_chars, ladders, style))
994        .collect();
995    if !report.ignored_closed.is_empty() {
996        let refs: Vec<String> = report
997            .ignored_closed
998            .iter()
999            .map(|issue| hyperlink(&issue.item_ref, Some(&issue.url), style))
1000            .collect();
1001        blocks.push(format!("ignored closed: {}", refs.join(", ")));
1002    }
1003    blocks.push(format!(
1004        "model: {}, usage: {} input tokens, {} output tokens",
1005        report.model, report.usage.input_tokens, report.usage.output_tokens
1006    ));
1007    let mut text = blocks.join("\n\n");
1008    text.push('\n');
1009    text
1010}
1011
1012/// Renders one issue's block: a header line, one indented line per provider's
1013/// routing (or its error), one `cites` line per open citation, and a
1014/// truncation note.
1015fn render_issue_block(
1016    issue: &IssueRoute,
1017    max_input_chars: usize,
1018    ladders: &[Ladder],
1019    style: TerminalStyle,
1020) -> String {
1021    let item_ref = hyperlink(&issue.item_ref, Some(&issue.url), style);
1022    let item_ref = if issue.failed() {
1023        item_ref
1024    } else {
1025        colorize(&item_ref, Complexity::Low, style)
1026    };
1027    let header = if issue.state == ItemState::Closed {
1028        format!("{item_ref} — {} (closed)", issue.title)
1029    } else {
1030        format!("{item_ref} — {}", issue.title)
1031    };
1032    let mut lines = vec![header];
1033    match &issue.outcome {
1034        RouteOutcome::Routed {
1035            providers,
1036            depends_on,
1037            reference_fetch_failures,
1038        } => {
1039            for (provider, route) in providers {
1040                let ladder = ladders.iter().find(|ladder| ladder.name == *provider);
1041                lines.extend(render_provider_line(
1042                    provider,
1043                    route,
1044                    providers.len(),
1045                    ladder,
1046                    style,
1047                ));
1048            }
1049            lines.extend(render_depends_on_lines(depends_on, style));
1050            lines.extend(render_reference_fetch_failure_lines(
1051                reference_fetch_failures,
1052            ));
1053        }
1054        RouteOutcome::Failed {
1055            error,
1056            reference_fetch_failures,
1057        } => {
1058            lines.push(format!(
1059                "  {}",
1060                colorize(&format!("failed: {error}"), Complexity::High, style)
1061            ));
1062            lines.extend(render_reference_fetch_failure_lines(
1063                reference_fetch_failures,
1064            ));
1065        }
1066    }
1067    if issue.truncated {
1068        lines.push(format!(
1069            "  input truncated at {} characters",
1070            with_thousands(max_input_chars)
1071        ));
1072    }
1073    lines.join("\n")
1074}
1075
1076/// Whether any of `route`'s three stage answers names a multi-model tier (a
1077/// comma-joined tier name from a custom ladder, #1826). The compact
1078/// single-line layout repeats a chosen tier name up to four times, which
1079/// becomes unreadable once that name is ~100 characters of comma-joined
1080/// model ids — so this is the trigger for the one-line-per-stage layout
1081/// (#1847). Checking the three stage choices is sufficient: `route.class` is
1082/// always one of `design`/`implement`'s choice (see `issue_class`), so it
1083/// can never be multi-model without one of those already being caught.
1084fn route_has_multi_model_tier(route: &ProviderRoute) -> bool {
1085    [
1086        &route.stages.design,
1087        &route.stages.implement,
1088        &route.stages.review,
1089    ]
1090    .into_iter()
1091    .any(|answer| answer.choice.contains(','))
1092}
1093
1094/// Renders one provider's routing as one or more indented lines: the compact
1095/// single line (unchanged since #1824) when every chosen tier is
1096/// single-model, or one line per fact when a multi-model tier name would
1097/// otherwise repeat illegibly on one line (#1847).
1098fn render_provider_line(
1099    provider: &str,
1100    route: &ProviderRoute,
1101    provider_count: usize,
1102    ladder: Option<&Ladder>,
1103    style: TerminalStyle,
1104) -> Vec<String> {
1105    let mut lines = if route_has_multi_model_tier(route) {
1106        render_provider_block(provider, route, provider_count, ladder, style)
1107    } else {
1108        vec![render_provider_line_compact(
1109            provider,
1110            route,
1111            provider_count,
1112            ladder,
1113            style,
1114        )]
1115    };
1116    lines.extend(effort::render(&route.stages, ladder));
1117    lines
1118}
1119
1120/// Renders one provider's routing as an indented line, e.g. `  sonnet —
1121/// design needs opus (0.52), ...` or, with more than one provider requested,
1122/// `  anthropic: sonnet — design needs opus (0.52), ...`.
1123fn render_provider_line_compact(
1124    provider: &str,
1125    route: &ProviderRoute,
1126    provider_count: usize,
1127    ladder: Option<&Ladder>,
1128    style: TerminalStyle,
1129) -> String {
1130    let class = color_choice(&route.class, ladder, style);
1131    let lead = if provider_count > 1 {
1132        format!("{provider}: {class}")
1133    } else {
1134        class
1135    };
1136    let clauses: Vec<String> = Stage::ALL
1137        .into_iter()
1138        .map(|stage| render_stage_clause(stage, &route.stages, &route.close_calls, ladder, style))
1139        .collect();
1140    format!("  {lead} — {}", clauses.join(", "))
1141}
1142
1143/// Renders one stage's clause, e.g. `design needs opus (0.52)` or `review
1144/// sonnet (0.41, close call)`.
1145fn render_stage_clause(
1146    stage: Stage,
1147    stages: &StageAnswers,
1148    close_calls: &[Stage],
1149    ladder: Option<&Ladder>,
1150    style: TerminalStyle,
1151) -> String {
1152    let answer = stages.get(stage);
1153    let choice = color_choice(&answer.choice, ladder, style);
1154    let label = match stage {
1155        Stage::Design if answer.choice == NO_DESIGN => format!(
1156            "design needs {}",
1157            colorize("no further work", Complexity::Low, style)
1158        ),
1159        Stage::Design => format!("design needs {choice}"),
1160        Stage::Implement => format!("implementation {choice}"),
1161        Stage::Review => format!("review {choice}"),
1162    };
1163    format!(
1164        "{label} ({})",
1165        render_stage_confidence(answer, close_calls.contains(&stage))
1166    )
1167}
1168
1169/// Adds the highest-probability alternative to a close call in either text
1170/// layout. Ties pick the alphabetically first option for stable output.
1171fn render_stage_confidence(answer: &StageAnswer, close_call: bool) -> String {
1172    let mut detail = format!("{:.2}", answer.confidence);
1173    if close_call {
1174        detail.push_str(", close call");
1175        if let Some((name, probability)) = answer
1176            .probabilities
1177            .iter()
1178            .filter(|(name, probability)| name.as_str() != answer.choice && probability.is_finite())
1179            .max_by(|a, b| a.1.total_cmp(b.1).then_with(|| b.0.cmp(a.0)))
1180        {
1181            detail.push_str(&format!(" — {name} {probability:.2}"));
1182        }
1183    }
1184    detail
1185}
1186
1187/// Renders one provider's routing as one line per fact: `class:` once, then
1188/// `design:` / `implementation:` / `review:` — the one-line-per-stage layout
1189/// used when a chosen tier name is multi-model (#1847). With more than one
1190/// provider requested, the provider name heads the block instead of
1191/// prefixing every line, and the fact lines nest one indent level deeper.
1192fn render_provider_block(
1193    provider: &str,
1194    route: &ProviderRoute,
1195    provider_count: usize,
1196    ladder: Option<&Ladder>,
1197    style: TerminalStyle,
1198) -> Vec<String> {
1199    let mut lines = Vec::with_capacity(5);
1200    let indent = if provider_count > 1 {
1201        lines.push(format!("  {provider}:"));
1202        "    "
1203    } else {
1204        "  "
1205    };
1206    lines.push(format!(
1207        "{indent}class: {}",
1208        color_choice(&route.class, ladder, style)
1209    ));
1210    for stage in Stage::ALL {
1211        lines.push(format!(
1212            "{indent}{}",
1213            render_stage_line(stage, &route.stages, &route.close_calls, ladder, style)
1214        ));
1215    }
1216    lines
1217}
1218
1219/// Renders one stage's fact line for [`render_provider_block`], e.g.
1220/// `design: needs no further work (0.94)` or `review: opus (0.41, close
1221/// call)` — parallel to [`render_stage_clause`] but with the stage name as a
1222/// `label:` prefix rather than folded into a clause.
1223fn render_stage_line(
1224    stage: Stage,
1225    stages: &StageAnswers,
1226    close_calls: &[Stage],
1227    ladder: Option<&Ladder>,
1228    style: TerminalStyle,
1229) -> String {
1230    let answer = stages.get(stage);
1231    let choice = color_choice(&answer.choice, ladder, style);
1232    let (label, content) = match stage {
1233        Stage::Design if answer.choice == NO_DESIGN => (
1234            "design",
1235            format!(
1236                "needs {}",
1237                colorize("no further work", Complexity::Low, style)
1238            ),
1239        ),
1240        Stage::Design => ("design", format!("needs {choice}")),
1241        Stage::Implement => ("implementation", choice),
1242        Stage::Review => ("review", choice),
1243    };
1244    format!(
1245        "{label}: {content} ({})",
1246        render_stage_confidence(answer, close_calls.contains(&stage))
1247    )
1248}
1249
1250/// Renders one `  cites` line per open citation in `depends_on`, in citation
1251/// order. Empty when there are no open citations.
1252fn render_depends_on_lines(depends_on: &[DependencyEntry], style: TerminalStyle) -> Vec<String> {
1253    depends_on
1254        .iter()
1255        .map(|dep| {
1256            let item_ref = hyperlink(&dep.item_ref, dep.url.as_deref(), style);
1257            match dep.could_be_cheaper.get("design") {
1258                Some(prob) => format!(
1259                    "  cites open {item_ref}, which could leave less design work if resolved ({prob:.2})"
1260                ),
1261                None => format!("  cites open {item_ref}"),
1262            }
1263        })
1264        .collect()
1265}
1266
1267/// Renders per-citation GitHub misses separately from open dependencies.
1268fn render_reference_fetch_failure_lines(failures: &[ReferenceFetchFailure]) -> Vec<String> {
1269    failures
1270        .iter()
1271        .map(|failure| {
1272            format!(
1273                "  reference fetch failed: {} ({})",
1274                failure.item_ref, failure.error
1275            )
1276        })
1277        .collect()
1278}
1279
1280#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1281enum Complexity {
1282    Low,
1283    Medium,
1284    High,
1285}
1286
1287/// Tier order, rather than provider-specific names, determines complexity.
1288fn choice_complexity(choice: &str, ladder: &Ladder) -> Option<Complexity> {
1289    if choice == NO_DESIGN {
1290        return Some(Complexity::Low);
1291    }
1292    let tiers = ladder.tiers.as_slice();
1293    let index = tiers.iter().position(|tier| tier.name == choice)?;
1294    Some(if index == 0 {
1295        Complexity::Low
1296    } else if index == tiers.len() - 1 {
1297        Complexity::High
1298    } else {
1299        Complexity::Medium
1300    })
1301}
1302
1303fn color_choice(choice: &str, ladder: Option<&Ladder>, style: TerminalStyle) -> String {
1304    ladder
1305        .and_then(|ladder| choice_complexity(choice, ladder))
1306        .map_or_else(
1307            || choice.to_string(),
1308            |level| colorize(choice, level, style),
1309        )
1310}
1311
1312fn colorize(text: &str, level: Complexity, style: TerminalStyle) -> String {
1313    if !style.color {
1314        return text.to_string();
1315    }
1316    let code = match level {
1317        Complexity::Low => 32,
1318        Complexity::Medium => 33,
1319        Complexity::High => 31,
1320    };
1321    format!("\x1b[{code}m{text}\x1b[0m")
1322}
1323
1324/// Link only the visible reference, and only to a GitHub issue/PR URL. OSC 8
1325/// URLs must not contain characters that could terminate the control sequence.
1326fn hyperlink(text: &str, url: Option<&str>, style: TerminalStyle) -> String {
1327    let Some(url) = url.filter(|url| style.hyperlinks && valid_github_item_url(url)) else {
1328        return text.to_string();
1329    };
1330    format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\")
1331}
1332
1333fn valid_github_item_url(raw: &str) -> bool {
1334    if raw.chars().any(char::is_control) {
1335        return false;
1336    }
1337    let Ok(url) = url::Url::parse(raw) else {
1338        return false;
1339    };
1340    if url.scheme() != "https" || url.host_str() != Some("github.com") {
1341        return false;
1342    }
1343    let Some(segments) = url.path_segments() else {
1344        return false; // omni-dev: coverage ignore-line reason="unreachable: https is a special scheme per the WHATWG URL spec, so a URL that already passed the scheme check above can never be cannot-be-a-base and path_segments() is always Some"
1345    };
1346    let parts: Vec<_> = segments.collect();
1347    parts.len() == 4
1348        && !parts[0].is_empty()
1349        && !parts[1].is_empty()
1350        && matches!(parts[2], "issues" | "pull")
1351        && parts[3].parse::<u64>().is_ok()
1352}
1353
1354/// Groups `n`'s digits by thousands, e.g. `60_000` -> `"60,000"`.
1355fn with_thousands(n: usize) -> String {
1356    let digits = n.to_string();
1357    let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
1358    for (i, ch) in digits.chars().rev().enumerate() {
1359        if i > 0 && i % 3 == 0 {
1360            grouped.push(',');
1361        }
1362        grouped.push(ch);
1363    }
1364    grouped.chars().rev().collect()
1365}
1366
1367#[cfg(test)]
1368#[allow(clippy::unwrap_used, clippy::expect_used)]
1369mod tests {
1370    use super::*;
1371    use crate::provider::{Comment, GitProvider, ItemKind, ItemRef};
1372
1373    fn doc(number: u64, state: ItemState) -> IssueDoc {
1374        IssueDoc {
1375            provider: GitProvider::GitHub,
1376            project: "rust-works/omni-dev".to_string(),
1377            number,
1378            kind: ItemKind::Issue,
1379            title: "Route issues".to_string(),
1380            state,
1381            body: "  The body.\n".to_string(),
1382            comments: vec![],
1383            closed_by: vec![],
1384            url: format!("https://github.com/rust-works/omni-dev/issues/{number}"),
1385        }
1386    }
1387
1388    fn opts() -> RouteOptions {
1389        RouteOptions {
1390            model: "jev-latest".to_string(),
1391            close_call: DEFAULT_CLOSE_CALL,
1392            effort_advice: false,
1393            max_input_chars: DEFAULT_MAX_INPUT_CHARS,
1394            allow_closed: false,
1395            ignore_closed: false,
1396        }
1397    }
1398
1399    fn default_tiers() -> Tiers {
1400        Provider::Anthropic.tiers().unwrap()
1401    }
1402
1403    // Existing class-only fixtures exercise backward compatibility; effort tests use real built-ins.
1404    fn class_only(provider: Provider) -> Result<Ladder> {
1405        let mut ladder = Ladder::builtin(provider)?;
1406        for tier in &mut ladder.tiers.0 {
1407            tier.models = None;
1408        }
1409        Ok(ladder)
1410    }
1411
1412    fn anthropic() -> Vec<Ladder> {
1413        vec![class_only(Provider::Anthropic).unwrap()]
1414    }
1415
1416    fn tier_names(tiers: &Tiers) -> Vec<&str> {
1417        tiers.as_slice().iter().map(|t| t.name.as_str()).collect()
1418    }
1419
1420    // ── Tiers and providers ──────────────────────────────────────────
1421
1422    /// #1903 dropped `fable` as redundant with `opus`; the ladder is now two
1423    /// rungs.
1424    #[test]
1425    fn anthropic_tiers_are_sonnet_opus_in_order() {
1426        assert_eq!(tier_names(&default_tiers()), ["sonnet", "opus"]);
1427    }
1428
1429    #[test]
1430    fn anthropic_tier_descriptions_are_the_tested_text() {
1431        let tiers = default_tiers();
1432        let opus = &tiers.as_slice()[1];
1433        assert_eq!(
1434            opus.description,
1435            "Reliable at reasoning across many files, subtle library or platform semantics, \
1436             concurrency and ordering, once the overall direction is set. Can under-explore a \
1437             genuinely open design space."
1438        );
1439    }
1440
1441    #[test]
1442    fn provider_names_are_the_documented_abbreviations() {
1443        assert_eq!(
1444            tier_names(&Provider::OpenAi.tiers().unwrap()),
1445            ["terra", "sol", "astra"]
1446        );
1447        assert_eq!(
1448            tier_names(&Provider::Gemini.tiers().unwrap()),
1449            ["flash", "pro", "deep-think"]
1450        );
1451        assert_eq!(
1452            Provider::ALL.map(Provider::name),
1453            ["anthropic", "openai", "gemini"]
1454        );
1455        for provider in Provider::ALL {
1456            assert_eq!(
1457                provider.to_possible_value().unwrap().get_name(),
1458                provider.name()
1459            );
1460        }
1461    }
1462
1463    /// Only the anthropic text was validated (#1779), and rewording shifts
1464    /// answers, so the other ladders copy it rung for rung where they
1465    /// overlap. A ladder that diverges must do so deliberately, by editing
1466    /// this test too.
1467    ///
1468    /// #1903 dropped `anthropic`'s third rung (`fable`) as redundant with
1469    /// its second (`opus`), on evidence specific to those two Claude
1470    /// families; `openai`/`gemini` keep three rungs each, since no
1471    /// equivalent case has been made for `astra`/`deep-think` shrinking
1472    /// their own top rungs. Their third rung's description therefore still
1473    /// reuses the retired `fable` text verbatim, pinned here as a literal
1474    /// since it is no longer present in `anthropic`'s own (now two-rung)
1475    /// tiers.
1476    #[test]
1477    fn openai_and_gemini_ladders_reuse_the_anthropic_descriptions_rung_for_rung() {
1478        let anthropic = default_tiers();
1479        const RETIRED_ANTHROPIC_THIRD_RUNG: &str =
1480            "Strongest at open-ended design and research: choosing between architectures, \
1481             working in unfamiliar territory with no precedent in the codebase, anticipating \
1482             failure modes, and security-critical judgement.";
1483        for provider in [Provider::OpenAi, Provider::Gemini] {
1484            let tiers = provider.tiers().unwrap();
1485            assert_eq!(tiers.as_slice().len(), 3);
1486            for (rung, reference) in tiers.as_slice().iter().zip(anthropic.as_slice()) {
1487                assert_eq!(
1488                    rung.description,
1489                    reference.description,
1490                    "{}: {} differs from {}",
1491                    // omni-dev: coverage ignore-line reason="assert_eq!'s message args are only evaluated on failure, and this test always passes"
1492                    provider.name(),
1493                    rung.name,
1494                    reference.name
1495                );
1496            }
1497            assert_eq!(
1498                tiers.as_slice()[2].description,
1499                RETIRED_ANTHROPIC_THIRD_RUNG,
1500                "{}: {} no longer matches the retired anthropic fable rung",
1501                provider.name(), // omni-dev: coverage ignore-line reason="assert_eq!'s message args are only evaluated on failure, and this test always passes"
1502                tiers.as_slice()[2].name // omni-dev: coverage ignore-line reason="assert_eq!'s message args are only evaluated on failure, and this test always passes"
1503            );
1504        }
1505    }
1506
1507    #[test]
1508    fn ladders_are_named_by_provider_or_a_custom_name() {
1509        let ladder = class_only(Provider::Gemini).unwrap();
1510        assert_eq!(ladder.name, "gemini");
1511        let custom = Ladder::named("mine".to_string(), default_tiers());
1512        assert_eq!(custom.name, "mine");
1513        assert_eq!(custom.tiers, default_tiers());
1514    }
1515
1516    #[test]
1517    fn tiers_need_at_least_two() {
1518        let err = Tiers::parse("tiers:\n  - {name: a, description: A}\n").unwrap_err();
1519        assert!(err.to_string().contains("at least 2 tiers"), "{err}");
1520    }
1521
1522    #[test]
1523    fn tiers_reject_the_reserved_none() {
1524        let err = Tiers::parse(
1525            "tiers:\n  - {name: a, description: A}\n  - {name: none, description: B}\n",
1526        )
1527        .unwrap_err();
1528        assert!(err.to_string().contains("reserved"), "{err}");
1529    }
1530
1531    #[test]
1532    fn tiers_reject_duplicate_names() {
1533        let err =
1534            Tiers::parse("tiers:\n  - {name: a, description: A}\n  - {name: a, description: B}\n")
1535                .unwrap_err();
1536        assert!(err.to_string().contains("more than once"), "{err}");
1537    }
1538
1539    #[test]
1540    fn tiers_reject_empty_descriptions() {
1541        let err =
1542            Tiers::parse("tiers:\n  - {name: a, description: A}\n  - {name: b, description: ''}\n")
1543                .unwrap_err();
1544        assert!(err.to_string().contains("non-empty"), "{err}");
1545    }
1546
1547    #[test]
1548    fn tiers_reject_unknown_fields() {
1549        let err = Tiers::parse(
1550            "tiers:\n  - {name: a, description: A, rank: 1}\n  - {name: b, description: B}\n",
1551        )
1552        .unwrap_err();
1553        assert!(format!("{err:#}").contains("unknown field"), "{err:#}");
1554    }
1555
1556    #[test]
1557    fn tiers_load_reads_a_custom_file() {
1558        let dir = tempfile::tempdir().unwrap();
1559        let path = dir.path().join("tiers.yaml");
1560        std::fs::write(
1561            &path,
1562            "tiers:\n  - {name: small, description: S}\n  - {name: big, description: B}\n",
1563        )
1564        .unwrap();
1565        let tiers = Tiers::load_file(&path).unwrap();
1566        assert_eq!(tiers.as_slice()[1].name, "big");
1567    }
1568
1569    #[test]
1570    fn tiers_load_names_a_missing_file() {
1571        let err = Tiers::load_file(Path::new("/no/such/tiers.yaml")).unwrap_err();
1572        assert!(err.to_string().contains("Failed to read tiers file"));
1573    }
1574
1575    // ── questions ────────────────────────────────────────────────────
1576
1577    const BAR: &str = " Choose the least capable class likely to complete this stage correctly \
1578                       with no rework, about 9 times in 10. Judge the work that remains given \
1579                       the text, not the size of the text.";
1580
1581    fn instructions(questions: &BTreeMap<String, Question>, key: &str) -> String {
1582        let Question::Choice { instructions, .. } = &questions[key] else {
1583            panic!("{key} is not a choice");
1584        };
1585        instructions.clone()
1586    }
1587
1588    fn options(questions: &BTreeMap<String, Question>, key: &str) -> Vec<String> {
1589        let Question::Choice { criteria, .. } = &questions[key] else {
1590            panic!("{key} is not a choice");
1591        };
1592        criteria.keys().cloned().collect()
1593    }
1594
1595    /// Pins the tested wording: changing it shifts every answer, so an edit
1596    /// to the template must also be a deliberate edit here.
1597    #[test]
1598    fn stage_questions_are_the_tested_wording() {
1599        let questions = build_route_questions(&anthropic()).unwrap();
1600        assert_eq!(
1601            instructions(&questions, "anthropic.stage_design"),
1602            format!(
1603                "Which class should do the design work that remains before implementation can \
1604                 start: choosing the approach, settling open questions, and writing a plan?{BAR}"
1605            )
1606        );
1607        assert_eq!(
1608            instructions(&questions, "anthropic.stage_implement"),
1609            format!(
1610                "Assume any remaining design work has been completed well. Which class should \
1611                 write the code, tests and docs?{BAR}"
1612            )
1613        );
1614        assert_eq!(
1615            instructions(&questions, "anthropic.stage_review"),
1616            format!(
1617                "Which class should review the finished change before merge, so that mistakes \
1618                 the automated tests would miss are caught?{BAR}"
1619            )
1620        );
1621    }
1622
1623    #[test]
1624    fn only_the_design_stage_offers_none() {
1625        let questions = build_route_questions(&anthropic()).unwrap();
1626        assert_eq!(
1627            options(&questions, "anthropic.stage_design"),
1628            ["none", "opus", "sonnet"]
1629        );
1630        assert_eq!(
1631            options(&questions, "anthropic.stage_implement"),
1632            ["opus", "sonnet"]
1633        );
1634        assert_eq!(
1635            options(&questions, "anthropic.stage_review"),
1636            ["opus", "sonnet"]
1637        );
1638        let Question::Choice { criteria, .. } = &questions["anthropic.stage_design"] else {
1639            panic!();
1640        };
1641        assert_eq!(
1642            criteria[NO_DESIGN],
1643            "No design work remains: the text already settles the approach and the open \
1644             questions."
1645        );
1646    }
1647
1648    /// Several ladders share one request: the same wording under each
1649    /// provider's prefix, with that provider's tiers as the options.
1650    #[test]
1651    fn questions_are_keyed_per_provider_in_one_map() {
1652        let ladders = [
1653            class_only(Provider::Anthropic).unwrap(),
1654            class_only(Provider::OpenAi).unwrap(),
1655        ];
1656        let questions = build_route_questions(&ladders).unwrap();
1657        let keys: Vec<&String> = questions.keys().collect();
1658        assert_eq!(
1659            keys,
1660            [
1661                "anthropic.stage_design",
1662                "anthropic.stage_implement",
1663                "anthropic.stage_review",
1664                "openai.stage_design",
1665                "openai.stage_implement",
1666                "openai.stage_review",
1667            ]
1668        );
1669        assert_eq!(
1670            options(&questions, "openai.stage_design"),
1671            ["astra", "none", "sol", "terra"]
1672        );
1673        assert_eq!(
1674            instructions(&questions, "openai.stage_review"),
1675            instructions(&questions, "anthropic.stage_review")
1676        );
1677    }
1678
1679    #[test]
1680    fn a_named_ladder_is_keyed_by_its_own_name() {
1681        let ladders = [Ladder::named(
1682            "mine".to_string(),
1683            Tiers::parse("tiers:\n  - {name: a, description: A}\n  - {name: b, description: B}\n")
1684                .unwrap(),
1685        )];
1686        let questions = build_route_questions(&ladders).unwrap();
1687        assert_eq!(options(&questions, "mine.stage_implement"), ["a", "b"]);
1688    }
1689
1690    // ── build_route_state ────────────────────────────────────────────
1691
1692    #[test]
1693    fn route_state_without_comments_is_title_and_trimmed_body() {
1694        let (state, truncated) = build_route_state(&doc(7, ItemState::Open), 1000);
1695        assert_eq!(state, "# #7 Route issues\n\nThe body.\n");
1696        assert!(!truncated);
1697    }
1698
1699    #[test]
1700    fn route_state_lists_comments_in_order_in_the_tested_format() {
1701        let mut d = doc(7, ItemState::Open);
1702        d.comments = vec![
1703            Comment {
1704                author: "alice".to_string(),
1705                body: "first\n".to_string(),
1706                id: None,
1707            },
1708            Comment {
1709                author: "bob".to_string(),
1710                body: "second".to_string(),
1711                id: None,
1712            },
1713        ];
1714        let (state, _) = build_route_state(&d, 1000);
1715        assert_eq!(
1716            state,
1717            "# #7 Route issues\n\nThe body.\n\n\n---\n\n## Comments on this issue\n\
1718             \n\n**Comment by alice:**\n\nfirst\n\
1719             \n\n**Comment by bob:**\n\nsecond\n"
1720        );
1721    }
1722
1723    #[test]
1724    fn route_state_truncates_the_middle_on_a_char_boundary() {
1725        let mut d = doc(7, ItemState::Open);
1726        d.body = "é".repeat(100);
1727        let (full, _) = build_route_state(&d, usize::MAX);
1728        let (state, truncated) = build_route_state(&d, 20);
1729        assert!(truncated);
1730        let (head, tail) = state
1731            .split_once(&format!("\n\n{TRUNCATION_MARKER}\n\n"))
1732            .unwrap();
1733        assert_eq!(head.chars().count(), 10);
1734        assert_eq!(tail.chars().count(), 10);
1735        assert!(full.starts_with(head));
1736        assert!(full.ends_with(tail));
1737    }
1738
1739    /// The latest comment is where a decision lands, so a cut must not lose it.
1740    #[test]
1741    fn route_state_truncation_keeps_the_latest_comment() {
1742        let mut d = doc(7, ItemState::Open);
1743        d.body = "x".repeat(10_000);
1744        d.comments = vec![Comment {
1745            author: "maintainer".to_string(),
1746            body: "**Decision**: settled.".to_string(),
1747            id: None,
1748        }];
1749        let (state, truncated) = build_route_state(&d, 1_000);
1750        assert!(truncated);
1751        assert!(state.starts_with("# #7 Route issues"));
1752        assert!(state.ends_with("**Decision**: settled.\n"), "{state}");
1753    }
1754
1755    #[test]
1756    fn route_state_truncation_handles_a_one_char_cap() {
1757        let (state, truncated) = build_route_state(&doc(7, ItemState::Open), 1);
1758        assert!(truncated);
1759        assert_eq!(state, format!("\n\n{TRUNCATION_MARKER}\n\n\n"));
1760    }
1761
1762    #[test]
1763    fn route_state_at_exactly_the_cap_is_not_truncated() {
1764        let d = doc(7, ItemState::Open);
1765        let (full, _) = build_route_state(&d, usize::MAX);
1766        let (state, truncated) = build_route_state(&d, full.chars().count());
1767        assert_eq!(state, full);
1768        assert!(!truncated);
1769    }
1770
1771    // ── class and close calls ────────────────────────────────────────
1772
1773    fn answer(choice: &str, confidence: f64) -> StageAnswer {
1774        StageAnswer {
1775            effort_by_model: BTreeMap::new(),
1776            choice: choice.to_string(),
1777            confidence,
1778            probabilities: BTreeMap::new(),
1779        }
1780    }
1781
1782    fn stages(design: &str, implement: &str, review: &str) -> StageAnswers {
1783        StageAnswers {
1784            design: answer(design, 0.9),
1785            implement: answer(implement, 0.9),
1786            review: answer(review, 0.9),
1787        }
1788    }
1789
1790    #[test]
1791    fn close_call_shows_the_best_alternative_in_compact_text() {
1792        let mut stages = stages("sol", "terra", "sol");
1793        stages.design.confidence = 0.21;
1794        stages.design.probabilities = BTreeMap::from([
1795            ("none".to_string(), 0.44),
1796            ("sol".to_string(), 0.48),
1797            ("terra".to_string(), 0.08),
1798        ]);
1799
1800        assert_eq!(
1801            render_stage_clause(
1802                Stage::Design,
1803                &stages,
1804                &[Stage::Design],
1805                None,
1806                TerminalStyle::default(),
1807            ),
1808            "design needs sol (0.21, close call — none 0.44)"
1809        );
1810        assert_eq!(
1811            render_stage_clause(Stage::Design, &stages, &[], None, TerminalStyle::default(),),
1812            "design needs sol (0.21)"
1813        );
1814    }
1815
1816    #[test]
1817    fn close_call_uses_a_stable_tiebreaker_and_handles_missing_alternatives() {
1818        let mut answer = answer("sol", 0.21);
1819        answer.probabilities = BTreeMap::from([
1820            ("astra".to_string(), 0.4),
1821            ("sol".to_string(), 0.2),
1822            ("terra".to_string(), 0.4),
1823        ]);
1824        assert_eq!(
1825            render_stage_confidence(&answer, true),
1826            "0.21, close call — astra 0.40"
1827        );
1828        answer.probabilities.clear();
1829        assert_eq!(render_stage_confidence(&answer, true), "0.21, close call");
1830    }
1831
1832    #[test]
1833    fn class_is_the_higher_of_design_and_implement() {
1834        let tiers = default_tiers();
1835        assert_eq!(
1836            issue_class(&stages("opus", "sonnet", "sonnet"), &tiers),
1837            "opus"
1838        );
1839        assert_eq!(
1840            issue_class(&stages("sonnet", "opus", "sonnet"), &tiers),
1841            "opus"
1842        );
1843    }
1844
1845    #[test]
1846    fn class_ignores_review_and_none() {
1847        let tiers = default_tiers();
1848        assert_eq!(
1849            issue_class(&stages("none", "sonnet", "opus"), &tiers),
1850            "sonnet"
1851        );
1852    }
1853
1854    #[test]
1855    fn close_calls_are_below_the_threshold_in_stage_order() {
1856        let mut s = stages("opus", "sonnet", "opus");
1857        s.review.confidence = 0.1;
1858        s.design.confidence = 0.29;
1859        s.implement.confidence = 0.3;
1860        assert_eq!(close_calls(&s, 0.3), [Stage::Design, Stage::Review]);
1861    }
1862
1863    #[test]
1864    fn could_be_cheaper_key_is_stable_and_indexed() {
1865        assert_eq!(could_be_cheaper_key(0), "could_be_cheaper_0");
1866        assert_eq!(could_be_cheaper_key(1), "could_be_cheaper_1");
1867    }
1868
1869    #[test]
1870    fn could_be_cheaper_question_is_the_tested_wording() {
1871        let Question::Noul {
1872            instructions,
1873            criteria,
1874        } = could_be_cheaper_question("#1129")
1875        else {
1876            panic!("expected a noul question");
1877        };
1878        assert_eq!(
1879            instructions,
1880            "This issue cites #1129, which is still open. If #1129 is resolved, how likely is \
1881             it that LESS design work would remain for THIS issue than the current text \
1882             implies — as opposed to this issue's own remaining work being unaffected, because \
1883             it is already scoped separately, is a parallel/sibling effort, or #1129 is \
1884             otherwise not a precondition for finishing this issue's own remaining work?"
1885        );
1886        assert!(criteria.is_none());
1887    }
1888
1889    #[test]
1890    fn dependency_entries_reads_the_noul_answer_per_citation() {
1891        let citations = vec![
1892            Citation {
1893                item_ref: ItemRef {
1894                    provider: GitProvider::GitHub,
1895                    project: "rust-works/omni-dev".to_string(),
1896                    kind: ItemKind::Issue,
1897                    number: 1129,
1898                },
1899                raw: "#1129".to_string(),
1900                url: None,
1901            },
1902            Citation {
1903                item_ref: ItemRef {
1904                    provider: GitProvider::GitHub,
1905                    project: "rust-works/omni-dev".to_string(),
1906                    kind: ItemKind::Issue,
1907                    number: 1349,
1908                },
1909                raw: "#1349".to_string(),
1910                url: None,
1911            },
1912        ];
1913        let answers = BTreeMap::from([(
1914            "could_be_cheaper_0".to_string(),
1915            Answer::Noul { noul: 0.75 },
1916        )]);
1917        let deps = dependency_entries("o/r#1", &citations, &answers);
1918        assert_eq!(deps.len(), 1, "{deps:?}");
1919        assert_eq!(deps[0].item_ref, "#1129");
1920        assert_eq!(deps[0].state, ItemState::Open);
1921        assert_eq!(deps[0].could_be_cheaper.get("design"), Some(&0.75));
1922    }
1923
1924    #[test]
1925    fn stage_answers_reject_none_outside_design() {
1926        let choice = |c: &str| Answer::Choice {
1927            choice: c.to_string(),
1928            confidence: 0.5,
1929            probabilities: BTreeMap::new(),
1930        };
1931        let answers = BTreeMap::from([
1932            ("anthropic.stage_design".to_string(), choice("none")),
1933            ("anthropic.stage_implement".to_string(), choice("none")),
1934            ("anthropic.stage_review".to_string(), choice("opus")),
1935        ]);
1936        let err = stage_answers(&answers, &anthropic()[0]).unwrap_err();
1937        assert!(err.to_string().contains("not offered"), "{err}");
1938    }
1939
1940    #[test]
1941    fn stage_answers_require_a_choice_per_stage() {
1942        let answers = BTreeMap::from([(
1943            "anthropic.stage_design".to_string(),
1944            Answer::Noul { noul: 0.5 },
1945        )]);
1946        let err = stage_answers(&answers, &anthropic()[0]).unwrap_err();
1947        assert!(err.to_string().contains("no choice answer"), "{err}");
1948        assert!(err.to_string().contains("anthropic.stage_design"), "{err}");
1949    }
1950
1951    /// A ladder's answers are read under its own prefix, so one provider's
1952    /// answers can never satisfy another's questions.
1953    #[test]
1954    fn stage_answers_are_read_under_the_ladders_prefix() {
1955        let choice = |c: &str| Answer::Choice {
1956            choice: c.to_string(),
1957            confidence: 0.5,
1958            probabilities: BTreeMap::new(),
1959        };
1960        let answers = BTreeMap::from([
1961            ("anthropic.stage_design".to_string(), choice("none")),
1962            ("anthropic.stage_implement".to_string(), choice("sonnet")),
1963            ("anthropic.stage_review".to_string(), choice("opus")),
1964        ]);
1965        let err = stage_answers(&answers, &class_only(Provider::OpenAi).unwrap()).unwrap_err();
1966        assert!(err.to_string().contains("openai.stage_design"), "{err}");
1967    }
1968
1969    // ── validate_options ─────────────────────────────────────────────
1970
1971    #[test]
1972    fn closed_issues_are_refused_by_default() {
1973        let err =
1974            validate_options(&[&doc(1, ItemState::Closed)], &anthropic(), &opts()).unwrap_err();
1975        assert!(err.to_string().contains("rust-works/omni-dev#1"), "{err}");
1976        assert!(err.to_string().contains("--allow-closed"), "{err}");
1977        assert!(err.to_string().contains("--ignore-closed"), "{err}");
1978    }
1979
1980    /// Checked before the batch is split, so an all-closed batch still
1981    /// reports the conflict rather than "nothing to route".
1982    #[test]
1983    fn allow_closed_and_ignore_closed_are_mutually_exclusive() {
1984        let mut o = opts();
1985        o.allow_closed = true;
1986        o.ignore_closed = true;
1987        for state in [ItemState::Open, ItemState::Closed] {
1988            let err = drop_closed(&[doc(1, state)], &o).unwrap_err();
1989            assert!(err.to_string().contains("cannot be used together"), "{err}");
1990        }
1991    }
1992
1993    // ── drop_closed (#2000) ─────────────────────────────────────────
1994
1995    #[test]
1996    fn drop_closed_keeps_every_issue_without_the_flag() {
1997        let docs = [doc(1, ItemState::Open), doc(2, ItemState::Closed)];
1998        let (kept, ignored) = drop_closed(&docs, &opts()).unwrap();
1999        assert_eq!(kept.len(), 2);
2000        assert!(ignored.is_empty());
2001    }
2002
2003    #[test]
2004    fn drop_closed_skips_closed_issues_in_request_order() {
2005        let mut o = opts();
2006        o.ignore_closed = true;
2007        let docs = [
2008            doc(3, ItemState::Closed),
2009            doc(1, ItemState::Open),
2010            doc(2, ItemState::Closed),
2011        ];
2012        let (kept, ignored) = drop_closed(&docs, &o).unwrap();
2013        assert_eq!(kept.iter().map(|d| d.number).collect::<Vec<_>>(), [1]);
2014        assert_eq!(
2015            ignored,
2016            [
2017                IgnoredIssue {
2018                    item_ref: "rust-works/omni-dev#3".to_string(),
2019                    url: "https://github.com/rust-works/omni-dev/issues/3".to_string(),
2020                    title: "Route issues".to_string(),
2021                },
2022                IgnoredIssue {
2023                    item_ref: "rust-works/omni-dev#2".to_string(),
2024                    url: "https://github.com/rust-works/omni-dev/issues/2".to_string(),
2025                    title: "Route issues".to_string(),
2026                },
2027            ]
2028        );
2029    }
2030
2031    #[test]
2032    fn drop_closed_refuses_a_batch_with_no_open_issue_left() {
2033        let mut o = opts();
2034        o.ignore_closed = true;
2035        let docs = [doc(1, ItemState::Closed), doc(2, ItemState::Closed)];
2036        let err = drop_closed(&docs, &o).unwrap_err();
2037        let msg = err.to_string();
2038        assert!(
2039            msg.contains("rust-works/omni-dev#1, rust-works/omni-dev#2"),
2040            "{msg}"
2041        );
2042        assert!(msg.contains("nothing to route"), "{msg}");
2043    }
2044
2045    /// An empty batch passes through, keeping `validate_options`' generic
2046    /// "no issues to route" error.
2047    #[test]
2048    fn drop_closed_passes_an_empty_batch_through() {
2049        let mut o = opts();
2050        o.ignore_closed = true;
2051        let (kept, ignored) = drop_closed(&[], &o).unwrap();
2052        assert!(kept.is_empty());
2053        assert!(ignored.is_empty());
2054    }
2055
2056    #[test]
2057    fn closed_issues_are_allowed_on_request() {
2058        let mut o = opts();
2059        o.allow_closed = true;
2060        validate_options(&[&doc(1, ItemState::Closed)], &anthropic(), &o).unwrap();
2061    }
2062
2063    #[test]
2064    fn empty_runs_and_bad_knobs_are_rejected() {
2065        assert!(validate_options(&[], &anthropic(), &opts()).is_err());
2066        let mut o = opts();
2067        o.close_call = 1.5;
2068        assert!(validate_options(&[&doc(1, ItemState::Open)], &anthropic(), &o).is_err());
2069        let mut o = opts();
2070        o.max_input_chars = 0;
2071        assert!(validate_options(&[&doc(1, ItemState::Open)], &anthropic(), &o).is_err());
2072    }
2073
2074    #[test]
2075    fn empty_and_duplicate_providers_are_rejected() {
2076        let err = validate_options(&[&doc(1, ItemState::Open)], &[], &opts()).unwrap_err();
2077        assert!(err.to_string().contains("no ladders"), "{err}");
2078        let twice = [
2079            class_only(Provider::Gemini).unwrap(),
2080            class_only(Provider::Gemini).unwrap(),
2081        ];
2082        let err = validate_options(&[&doc(1, ItemState::Open)], &twice, &opts()).unwrap_err();
2083        assert!(
2084            err.to_string()
2085                .contains("\"gemini\" is listed more than once"),
2086            "{err}"
2087        );
2088    }
2089
2090    // ── run_route (wiremock) ─────────────────────────────────────────
2091
2092    fn choice_json(choice: &str, confidence: f64) -> serde_json::Value {
2093        serde_json::json!({
2094            "type": "choice", "choice": choice, "confidence": confidence,
2095            "probabilities": {choice: confidence}
2096        })
2097    }
2098
2099    fn provider<'a>(outcome: &'a RouteOutcome, name: &str) -> &'a ProviderRoute {
2100        let RouteOutcome::Routed { providers, .. } = outcome else {
2101            // omni-dev: coverage ignore-line reason="guards this test helper against misuse; every call site below passes an already-routed outcome"
2102            panic!("expected a routed issue: {outcome:?}");
2103        };
2104        &providers[name]
2105    }
2106
2107    #[tokio::test]
2108    async fn run_route_asks_three_stages_per_issue_and_sums_usage() {
2109        let server = wiremock::MockServer::start().await;
2110        wiremock::Mock::given(wiremock::matchers::method("POST"))
2111            .and(wiremock::matchers::path("/v1/systemone"))
2112            .and(wiremock::matchers::body_partial_json(serde_json::json!({
2113                "model": "jev-latest",
2114                "state": "# #7 Route issues\n\nThe body.\n",
2115            })))
2116            .respond_with(
2117                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2118                    "model": "jev-1.13.0",
2119                    "answers": {
2120                        "anthropic.stage_design": choice_json("opus", 0.52),
2121                        "anthropic.stage_implement": choice_json("sonnet", 0.83),
2122                        "anthropic.stage_review": choice_json("opus", 0.21),
2123                    },
2124                    "usage": {"input_tokens": 100, "output_tokens": 10}
2125                })),
2126            )
2127            .expect(2)
2128            .mount(&server)
2129            .await;
2130        let client = JevClient::new(&server.uri(), "key").unwrap();
2131
2132        let report = run_route(
2133            &client,
2134            &[doc(7, ItemState::Open), doc(7, ItemState::Open)],
2135            &anthropic(),
2136            &opts(),
2137            &OpenDependencies::new(),
2138        )
2139        .await
2140        .unwrap();
2141
2142        assert_eq!(report.model, "jev-1.13.0");
2143        assert_eq!(report.usage.input_tokens, 200);
2144        assert_eq!(report.usage.output_tokens, 20);
2145        let issue = &report.issues[0];
2146        assert_eq!(issue.item_ref, "rust-works/omni-dev#7");
2147        let route = provider(&issue.outcome, "anthropic");
2148        assert_eq!(route.class, "opus");
2149        assert_eq!(route.close_calls, [Stage::Review]);
2150        assert!(!issue.truncated);
2151
2152        let requests = server.received_requests().await.unwrap();
2153        let body: serde_json::Value = requests[0].body_json().unwrap();
2154        let keys: Vec<&String> = body["questions"].as_object().unwrap().keys().collect();
2155        assert_eq!(
2156            keys,
2157            [
2158                "anthropic.stage_design",
2159                "anthropic.stage_implement",
2160                "anthropic.stage_review"
2161            ]
2162        );
2163    }
2164
2165    /// `--ignore-closed` sends one request per *open* issue, lists the closed
2166    /// ones under `ignored_closed`, and a closed-only batch sends nothing
2167    /// (#2000).
2168    #[tokio::test]
2169    async fn run_route_ignore_closed_routes_only_open_issues() {
2170        let server = wiremock::MockServer::start().await;
2171        wiremock::Mock::given(wiremock::matchers::method("POST"))
2172            .respond_with(
2173                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2174                    "model": "jev-1.13.0",
2175                    "answers": {
2176                        "anthropic.stage_design": choice_json("opus", 0.52),
2177                        "anthropic.stage_implement": choice_json("sonnet", 0.83),
2178                        "anthropic.stage_review": choice_json("opus", 0.61),
2179                    },
2180                    "usage": {"input_tokens": 100, "output_tokens": 10}
2181                })),
2182            )
2183            .expect(2)
2184            .mount(&server)
2185            .await;
2186        let client = JevClient::new(&server.uri(), "key").unwrap();
2187        let mut o = opts();
2188        o.ignore_closed = true;
2189
2190        let report = run_route(
2191            &client,
2192            &[
2193                doc(7, ItemState::Open),
2194                doc(8, ItemState::Closed),
2195                doc(9, ItemState::Open),
2196            ],
2197            &anthropic(),
2198            &o,
2199            &OpenDependencies::new(),
2200        )
2201        .await
2202        .unwrap();
2203
2204        let refs: Vec<&str> = report.issues.iter().map(|i| i.item_ref.as_str()).collect();
2205        assert_eq!(refs, ["rust-works/omni-dev#7", "rust-works/omni-dev#9"]);
2206        let json = serde_json::to_value(&report).unwrap();
2207        assert_eq!(
2208            json["ignored_closed"],
2209            serde_json::json!([{
2210                "ref": "rust-works/omni-dev#8",
2211                "url": "https://github.com/rust-works/omni-dev/issues/8",
2212                "title": "Route issues",
2213            }])
2214        );
2215
2216        let err = run_route(
2217            &client,
2218            &[doc(8, ItemState::Closed)],
2219            &anthropic(),
2220            &o,
2221            &OpenDependencies::new(),
2222        )
2223        .await
2224        .unwrap_err();
2225        assert!(err.to_string().contains("nothing to route"), "{err}");
2226    }
2227
2228    /// A report that skipped nothing serialises without `ignored_closed`, so
2229    /// existing consumers see the same shape as before #2000.
2230    #[test]
2231    fn route_report_omits_an_empty_ignored_closed() {
2232        let report = RouteReport {
2233            model: "m".to_string(),
2234            issues: vec![],
2235            ignored_closed: vec![],
2236            usage: Usage::default(),
2237        };
2238        let json = serde_json::to_value(&report).unwrap();
2239        assert!(json.get("ignored_closed").is_none(), "{json}");
2240    }
2241
2242    /// Two ladders ride one request and come back as two independent
2243    /// routings; `depends_on` stays issue-level, asked once.
2244    #[tokio::test]
2245    async fn run_route_routes_every_provider_in_one_call() {
2246        let server = wiremock::MockServer::start().await;
2247        wiremock::Mock::given(wiremock::matchers::method("POST"))
2248            .respond_with(
2249                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2250                    "model": "jev-1.13.0",
2251                    "answers": {
2252                        "anthropic.stage_design": choice_json("opus", 0.52),
2253                        "anthropic.stage_implement": choice_json("sonnet", 0.83),
2254                        "anthropic.stage_review": choice_json("opus", 0.9),
2255                        "openai.stage_design": choice_json("none", 0.9),
2256                        "openai.stage_implement": choice_json("sol", 0.2),
2257                        "openai.stage_review": choice_json("astra", 0.9),
2258                        "could_be_cheaper_0": {"type": "noul", "noul": 0.4},
2259                    },
2260                    "usage": {"input_tokens": 10, "output_tokens": 1}
2261                })),
2262            )
2263            .expect(1)
2264            .mount(&server)
2265            .await;
2266        let client = JevClient::new(&server.uri(), "key").unwrap();
2267        let ladders = [
2268            class_only(Provider::Anthropic).unwrap(),
2269            class_only(Provider::OpenAi).unwrap(),
2270        ];
2271        let dependencies = OpenDependencies::from([(
2272            ("rust-works/omni-dev".to_string(), 7),
2273            vec![citation("#1129", 1129)],
2274        )]);
2275        let reference_fetch_failures = ReferenceFetchFailures::from([(
2276            ("rust-works/omni-dev".to_string(), 7),
2277            vec![ReferenceFetchFailure {
2278                item_ref: "#404".to_string(),
2279                error: "not found".to_string(),
2280            }],
2281        )]);
2282
2283        let report = run_route_with_reference_fetch_failures(
2284            &client,
2285            &[doc(7, ItemState::Open)],
2286            &ladders,
2287            &opts(),
2288            &dependencies,
2289            &reference_fetch_failures,
2290        )
2291        .await
2292        .unwrap();
2293
2294        let outcome = &report.issues[0].outcome;
2295        let RouteOutcome::Routed {
2296            providers,
2297            depends_on,
2298            reference_fetch_failures,
2299            ..
2300        } = outcome
2301        else {
2302            // omni-dev: coverage ignore-line reason="guards this test's assumption; the mocked response above always answers with a routed outcome"
2303            panic!("expected a routed issue: {outcome:?}");
2304        };
2305        assert_eq!(
2306            providers.keys().collect::<Vec<_>>(),
2307            ["anthropic", "openai"]
2308        );
2309        assert_eq!(provider(outcome, "anthropic").class, "opus");
2310        assert!(provider(outcome, "anthropic").close_calls.is_empty());
2311        assert_eq!(provider(outcome, "openai").class, "sol");
2312        assert_eq!(provider(outcome, "openai").close_calls, [Stage::Implement]);
2313        assert_eq!(depends_on.len(), 1);
2314        assert_eq!(reference_fetch_failures[0].item_ref, "#404");
2315
2316        let requests = server.received_requests().await.unwrap();
2317        let body: serde_json::Value = requests[0].body_json().unwrap();
2318        let keys: Vec<&String> = body["questions"].as_object().unwrap().keys().collect();
2319        assert_eq!(
2320            keys,
2321            [
2322                "anthropic.stage_design",
2323                "anthropic.stage_implement",
2324                "anthropic.stage_review",
2325                "could_be_cheaper_0",
2326                "openai.stage_design",
2327                "openai.stage_implement",
2328                "openai.stage_review",
2329            ]
2330        );
2331    }
2332
2333    /// A ladder whose answers are missing fails the issue, so a partly
2334    /// routed issue is never reported as routed.
2335    #[tokio::test]
2336    async fn run_route_fails_the_issue_when_one_provider_is_unanswered() {
2337        let server = wiremock::MockServer::start().await;
2338        wiremock::Mock::given(wiremock::matchers::method("POST"))
2339            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(routed_json()))
2340            .mount(&server)
2341            .await;
2342        let client = JevClient::new(&server.uri(), "key").unwrap();
2343        let ladders = [
2344            class_only(Provider::Anthropic).unwrap(),
2345            class_only(Provider::Gemini).unwrap(),
2346        ];
2347        let report = run_route(
2348            &client,
2349            &[doc(7, ItemState::Open)],
2350            &ladders,
2351            &opts(),
2352            &OpenDependencies::new(),
2353        )
2354        .await
2355        .unwrap();
2356        let RouteOutcome::Failed { error, .. } = &report.issues[0].outcome else {
2357            // omni-dev: coverage ignore-line reason="guards this test's assumption; the mocked response above always leaves gemini's answers missing"
2358            panic!("expected a failed issue: {:?}", report.issues[0]);
2359        };
2360        assert!(error.contains("gemini.stage_design"), "{error}");
2361    }
2362
2363    fn routed_json() -> serde_json::Value {
2364        serde_json::json!({
2365            "model": "jev-1.13.0",
2366            "answers": {
2367                "anthropic.stage_design": choice_json("none", 0.9),
2368                "anthropic.stage_implement": choice_json("sonnet", 0.9),
2369                "anthropic.stage_review": choice_json("opus", 0.9),
2370            },
2371            "usage": {"input_tokens": 5, "output_tokens": 1}
2372        })
2373    }
2374
2375    /// A failure on one issue is recorded on it; the others are still routed.
2376    #[tokio::test]
2377    async fn run_route_keeps_going_past_a_failed_issue() {
2378        let server = wiremock::MockServer::start().await;
2379        wiremock::Mock::given(wiremock::matchers::method("POST"))
2380            .and(wiremock::matchers::body_partial_json(
2381                serde_json::json!({"state": "# #1 Route issues\n\nThe body.\n"}),
2382            ))
2383            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2384            .mount(&server)
2385            .await;
2386        wiremock::Mock::given(wiremock::matchers::method("POST"))
2387            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(routed_json()))
2388            .mount(&server)
2389            .await;
2390        let client = JevClient::new(&server.uri(), "key").unwrap();
2391        let reference_fetch_failures = ReferenceFetchFailures::from([(
2392            ("rust-works/omni-dev".to_string(), 1),
2393            vec![ReferenceFetchFailure {
2394                item_ref: "#404".to_string(),
2395                error: "not found".to_string(),
2396            }],
2397        )]);
2398        let report = run_route_with_reference_fetch_failures(
2399            &client,
2400            &[doc(1, ItemState::Open), doc(2, ItemState::Open)],
2401            &anthropic(),
2402            &opts(),
2403            &OpenDependencies::new(),
2404            &reference_fetch_failures,
2405        )
2406        .await
2407        .unwrap();
2408
2409        assert!(report.issues[0].failed());
2410        let RouteOutcome::Failed {
2411            error,
2412            reference_fetch_failures,
2413        } = &report.issues[0].outcome
2414        else {
2415            panic!();
2416        };
2417        assert!(error.contains("500"), "{error}");
2418        assert_eq!(reference_fetch_failures[0].item_ref, "#404");
2419        let json = serde_json::to_value(&report).unwrap();
2420        assert_eq!(
2421            json["issues"][0]["reference_fetch_failures"][0]["ref"],
2422            "#404"
2423        );
2424        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2425        assert!(
2426            text.contains("  reference fetch failed: #404 (not found)"),
2427            "{text}"
2428        );
2429        assert!(!report.issues[1].failed());
2430        assert_eq!(report.usage.input_tokens, 5);
2431    }
2432
2433    #[tokio::test]
2434    async fn run_route_stops_on_an_auth_failure() {
2435        let server = wiremock::MockServer::start().await;
2436        wiremock::Mock::given(wiremock::matchers::method("POST"))
2437            .respond_with(wiremock::ResponseTemplate::new(401).set_body_string("bad key"))
2438            .expect(1)
2439            .mount(&server)
2440            .await;
2441        let client = JevClient::new(&server.uri(), "key").unwrap();
2442        let err = run_route(
2443            &client,
2444            &[doc(3, ItemState::Open), doc(4, ItemState::Open)],
2445            &anthropic(),
2446            &opts(),
2447            &OpenDependencies::new(),
2448        )
2449        .await
2450        .unwrap_err();
2451        assert!(format!("{err:#}").contains("Failed to route issue rust-works/omni-dev#3"));
2452    }
2453
2454    #[tokio::test]
2455    async fn run_route_names_the_issue_on_an_unexpected_answer() {
2456        let server = wiremock::MockServer::start().await;
2457        wiremock::Mock::given(wiremock::matchers::method("POST"))
2458            .respond_with(
2459                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2460                    "model": "jev-1.13.0",
2461                    "answers": {
2462                        "anthropic.stage_design": choice_json("haiku", 0.9),
2463                        "anthropic.stage_implement": choice_json("sonnet", 0.9),
2464                        "anthropic.stage_review": choice_json("opus", 0.9),
2465                    },
2466                })),
2467            )
2468            .mount(&server)
2469            .await;
2470        let client = JevClient::new(&server.uri(), "key").unwrap();
2471        let report = run_route(
2472            &client,
2473            &[doc(9, ItemState::Open)],
2474            &anthropic(),
2475            &opts(),
2476            &OpenDependencies::new(),
2477        )
2478        .await
2479        .unwrap();
2480        let RouteOutcome::Failed { error, .. } = &report.issues[0].outcome else {
2481            panic!("expected a failed issue");
2482        };
2483        assert!(error.contains("Unexpected Jev answer"), "{error}");
2484        assert!(error.contains("not offered"), "{error}");
2485    }
2486
2487    fn citation(raw: &str, number: u64) -> Citation {
2488        Citation {
2489            item_ref: ItemRef {
2490                provider: GitProvider::GitHub,
2491                project: "rust-works/omni-dev".to_string(),
2492                kind: ItemKind::Issue,
2493                number,
2494            },
2495            raw: raw.to_string(),
2496            url: None,
2497        }
2498    }
2499
2500    #[tokio::test]
2501    async fn run_route_asks_could_be_cheaper_for_each_open_citation() {
2502        let server = wiremock::MockServer::start().await;
2503        wiremock::Mock::given(wiremock::matchers::method("POST"))
2504            .respond_with(
2505                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2506                    "model": "jev-1.13.0",
2507                    "answers": {
2508                        "anthropic.stage_design": choice_json("opus", 0.2),
2509                        "anthropic.stage_implement": choice_json("sonnet", 0.61),
2510                        "anthropic.stage_review": choice_json("opus", 0.9),
2511                        "could_be_cheaper_0": {"type": "noul", "noul": 0.75},
2512                    },
2513                    "usage": {"input_tokens": 10, "output_tokens": 1}
2514                })),
2515            )
2516            .mount(&server)
2517            .await;
2518        let client = JevClient::new(&server.uri(), "key").unwrap();
2519        let dependencies = OpenDependencies::from([(
2520            ("rust-works/omni-dev".to_string(), 7),
2521            vec![citation("#1129", 1129)],
2522        )]);
2523
2524        let report = run_route(
2525            &client,
2526            &[doc(7, ItemState::Open)],
2527            &anthropic(),
2528            &opts(),
2529            &dependencies,
2530        )
2531        .await
2532        .unwrap();
2533
2534        let RouteOutcome::Routed { depends_on, .. } = &report.issues[0].outcome else {
2535            panic!("expected a routed issue: {:?}", report.issues[0]);
2536        };
2537        assert_eq!(depends_on.len(), 1);
2538        assert_eq!(depends_on[0].item_ref, "#1129");
2539        assert_eq!(depends_on[0].state, ItemState::Open);
2540        assert_eq!(depends_on[0].could_be_cheaper.get("design"), Some(&0.75));
2541
2542        let requests = server.received_requests().await.unwrap();
2543        let body: serde_json::Value = requests[0].body_json().unwrap();
2544        let keys: Vec<&String> = body["questions"].as_object().unwrap().keys().collect();
2545        assert_eq!(
2546            keys,
2547            [
2548                "anthropic.stage_design",
2549                "anthropic.stage_implement",
2550                "anthropic.stage_review",
2551                "could_be_cheaper_0",
2552            ]
2553        );
2554    }
2555
2556    #[tokio::test]
2557    async fn run_route_drops_a_dependency_with_no_could_be_cheaper_answer() {
2558        let server = wiremock::MockServer::start().await;
2559        wiremock::Mock::given(wiremock::matchers::method("POST"))
2560            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(routed_json()))
2561            .mount(&server)
2562            .await;
2563        let client = JevClient::new(&server.uri(), "key").unwrap();
2564        let dependencies = OpenDependencies::from([(
2565            ("rust-works/omni-dev".to_string(), 7),
2566            vec![citation("#1129", 1129)],
2567        )]);
2568
2569        let report = run_route(
2570            &client,
2571            &[doc(7, ItemState::Open)],
2572            &anthropic(),
2573            &opts(),
2574            &dependencies,
2575        )
2576        .await
2577        .unwrap();
2578
2579        // routed_json() has no could_be_cheaper_0 answer; the issue still
2580        // routes, just with no dependencies reported.
2581        let RouteOutcome::Routed { depends_on, .. } = &report.issues[0].outcome else {
2582            panic!("expected a routed issue: {:?}", report.issues[0]);
2583        };
2584        assert!(depends_on.is_empty(), "{depends_on:?}");
2585    }
2586
2587    #[test]
2588    fn report_serialises_ref_and_omits_false_truncated() {
2589        let report = RouteReport {
2590            model: "jev-1.13.0".to_string(),
2591            issues: vec![IssueRoute {
2592                item_ref: "o/r#1".to_string(),
2593                url: "u".to_string(),
2594                title: "t".to_string(),
2595                state: ItemState::Open,
2596                outcome: RouteOutcome::Routed {
2597                    providers: BTreeMap::from([(
2598                        "anthropic".to_string(),
2599                        ProviderRoute {
2600                            stages: stages("none", "sonnet", "opus"),
2601                            class: "sonnet".to_string(),
2602                            close_calls: vec![],
2603                        },
2604                    )]),
2605                    depends_on: vec![],
2606                    reference_fetch_failures: vec![],
2607                },
2608                truncated: false,
2609            }],
2610            ignored_closed: vec![],
2611            usage: Usage::default(),
2612        };
2613        let value = serde_json::to_value(&report).unwrap();
2614        assert_eq!(value["issues"][0]["ref"], "o/r#1");
2615        assert!(value["issues"][0].get("truncated").is_none());
2616        let anthropic = &value["issues"][0]["providers"]["anthropic"];
2617        assert_eq!(anthropic["stages"]["design"]["choice"], "none");
2618        assert_eq!(anthropic["class"], "sonnet");
2619        assert_eq!(anthropic["close_calls"], serde_json::json!([]));
2620        assert!(value["issues"][0].get("stages").is_none());
2621        assert!(value["issues"][0].get("class").is_none());
2622        assert_eq!(value["issues"][0]["depends_on"], serde_json::json!([]));
2623        assert!(value["issues"][0].get("reference_fetch_failures").is_none());
2624        assert!(value["issues"][0].get("error").is_none());
2625    }
2626
2627    #[test]
2628    fn a_failed_issue_serialises_only_its_error() {
2629        let route = IssueRoute {
2630            item_ref: "o/r#1".to_string(),
2631            url: "u".to_string(),
2632            title: "t".to_string(),
2633            state: ItemState::Open,
2634            outcome: RouteOutcome::Failed {
2635                error: "HTTP 529".to_string(),
2636                reference_fetch_failures: vec![],
2637            },
2638            truncated: false,
2639        };
2640        let value = serde_json::to_value(&route).unwrap();
2641        assert_eq!(value["error"], "HTTP 529");
2642        assert!(value.get("providers").is_none());
2643        assert!(value.get("depends_on").is_none());
2644    }
2645
2646    // ── render_route_text (#1824) ──────────────────────────────────────
2647
2648    #[test]
2649    fn render_route_text_matches_the_worked_example() {
2650        let report = RouteReport {
2651            model: "jev-1.13.0".to_string(),
2652            issues: vec![IssueRoute {
2653                item_ref: "rust-works/omni-dev#1641".to_string(),
2654                url: "u".to_string(),
2655                title: "Some issue title".to_string(),
2656                state: ItemState::Open,
2657                outcome: RouteOutcome::Routed {
2658                    providers: BTreeMap::from([(
2659                        "anthropic".to_string(),
2660                        ProviderRoute {
2661                            stages: StageAnswers {
2662                                design: answer("opus", 0.52),
2663                                implement: answer("sonnet", 0.83),
2664                                review: answer("opus", 0.41),
2665                            },
2666                            class: "opus".to_string(),
2667                            close_calls: vec![Stage::Review],
2668                        },
2669                    )]),
2670                    depends_on: vec![DependencyEntry {
2671                        item_ref: "#1129".to_string(),
2672                        url: None,
2673                        state: ItemState::Open,
2674                        could_be_cheaper: BTreeMap::from([("design".to_string(), 0.75)]),
2675                    }],
2676                    reference_fetch_failures: vec![],
2677                },
2678                truncated: false,
2679            }],
2680            ignored_closed: vec![],
2681            usage: Usage {
2682                input_tokens: 1432,
2683                output_tokens: 61,
2684            },
2685        };
2686        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2687        assert_eq!(
2688            text,
2689            "rust-works/omni-dev#1641 — Some issue title\n\
2690             \x20\x20opus — design needs opus (0.52), implementation sonnet (0.83), review \
2691             opus (0.41, close call)\n\
2692             \x20\x20cites open #1129, which could leave less design work if resolved (0.75)\n\n\
2693             model: jev-1.13.0, usage: 1432 input tokens, 61 output tokens\n"
2694        );
2695    }
2696
2697    #[test]
2698    fn render_route_text_puts_each_open_citation_on_its_own_line() {
2699        let report = RouteReport {
2700            model: "jev-1.13.0".to_string(),
2701            issues: vec![IssueRoute {
2702                item_ref: "rust-works/omni-dev#1845".to_string(),
2703                url: "u".to_string(),
2704                title: "t".to_string(),
2705                state: ItemState::Open,
2706                outcome: RouteOutcome::Routed {
2707                    providers: BTreeMap::from([(
2708                        "anthropic".to_string(),
2709                        ProviderRoute {
2710                            stages: stages("none", "sonnet", "sonnet"),
2711                            class: "sonnet".to_string(),
2712                            close_calls: vec![],
2713                        },
2714                    )]),
2715                    depends_on: vec![
2716                        DependencyEntry {
2717                            item_ref: "#1830".to_string(),
2718                            url: None,
2719                            state: ItemState::Open,
2720                            could_be_cheaper: BTreeMap::from([("design".to_string(), 0.46)]),
2721                        },
2722                        DependencyEntry {
2723                            item_ref: "#1831".to_string(),
2724                            url: None,
2725                            state: ItemState::Open,
2726                            could_be_cheaper: BTreeMap::new(),
2727                        },
2728                    ],
2729                    reference_fetch_failures: vec![],
2730                },
2731                truncated: false,
2732            }],
2733            ignored_closed: vec![],
2734            usage: Usage::default(),
2735        };
2736        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2737        assert!(
2738            text.contains(
2739                "  cites open #1830, which could leave less design work if resolved (0.46)\n  \
2740                 cites open #1831\n"
2741            ),
2742            "{text}"
2743        );
2744        assert!(!text.contains("; "), "{text}");
2745    }
2746
2747    #[test]
2748    fn render_route_text_names_each_provider_when_there_are_several() {
2749        let route = |class: &str| ProviderRoute {
2750            stages: stages("none", "sonnet", "sonnet"),
2751            class: class.to_string(),
2752            close_calls: vec![],
2753        };
2754        let report = RouteReport {
2755            model: "jev-1.13.0".to_string(),
2756            issues: vec![IssueRoute {
2757                item_ref: "o/r#1".to_string(),
2758                url: "u".to_string(),
2759                title: "t".to_string(),
2760                state: ItemState::Open,
2761                outcome: RouteOutcome::Routed {
2762                    providers: BTreeMap::from([
2763                        ("anthropic".to_string(), route("sonnet")),
2764                        ("openai".to_string(), route("terra")),
2765                    ]),
2766                    depends_on: vec![],
2767                    reference_fetch_failures: vec![],
2768                },
2769                truncated: false,
2770            }],
2771            ignored_closed: vec![],
2772            usage: Usage::default(),
2773        };
2774        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2775        assert!(text.contains("  anthropic: sonnet —"), "{text}");
2776        assert!(text.contains("  openai: terra —"), "{text}");
2777    }
2778
2779    #[test]
2780    fn render_route_text_reports_a_failed_issue() {
2781        let report = RouteReport {
2782            model: "jev-1.13.0".to_string(),
2783            issues: vec![IssueRoute {
2784                item_ref: "o/r#1".to_string(),
2785                url: "u".to_string(),
2786                title: "t".to_string(),
2787                state: ItemState::Open,
2788                outcome: RouteOutcome::Failed {
2789                    error: "HTTP 529".to_string(),
2790                    reference_fetch_failures: vec![],
2791                },
2792                truncated: false,
2793            }],
2794            ignored_closed: vec![],
2795            usage: Usage::default(),
2796        };
2797        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2798        assert!(text.contains("o/r#1 — t\n  failed: HTTP 529"), "{text}");
2799    }
2800
2801    #[test]
2802    fn render_route_text_marks_a_closed_issue_in_the_header() {
2803        let report = RouteReport {
2804            model: "jev-1.13.0".to_string(),
2805            issues: vec![IssueRoute {
2806                item_ref: "o/r#1".to_string(),
2807                url: "u".to_string(),
2808                title: "t".to_string(),
2809                state: ItemState::Closed,
2810                outcome: RouteOutcome::Failed {
2811                    error: "HTTP 529".to_string(),
2812                    reference_fetch_failures: vec![],
2813                },
2814                truncated: false,
2815            }],
2816            ignored_closed: vec![],
2817            usage: Usage::default(),
2818        };
2819        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2820        assert!(text.starts_with("o/r#1 — t (closed)\n"), "{text}");
2821        assert!(!text.contains("ignored closed"), "{text}");
2822    }
2823
2824    #[test]
2825    fn render_route_text_lists_ignored_closed_issues_before_the_footer() {
2826        let ignored = |n: u64| IgnoredIssue {
2827            item_ref: format!("o/r#{n}"),
2828            url: format!("https://github.com/o/r/issues/{n}"),
2829            title: "t".to_string(),
2830        };
2831        let report = RouteReport {
2832            model: "jev-1.13.0".to_string(),
2833            issues: vec![],
2834            ignored_closed: vec![ignored(2), ignored(3)],
2835            usage: Usage::default(),
2836        };
2837        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2838        assert!(
2839            text.contains("ignored closed: o/r#2, o/r#3\n\nmodel: jev-1.13.0"),
2840            "{text}"
2841        );
2842        let styled = render_route_text_styled(
2843            &report,
2844            DEFAULT_MAX_INPUT_CHARS,
2845            &[],
2846            TerminalStyle {
2847                color: false,
2848                hyperlinks: true,
2849            },
2850        );
2851        assert!(
2852            styled.contains("https://github.com/o/r/issues/2"),
2853            "{styled}"
2854        );
2855    }
2856
2857    #[test]
2858    fn render_route_text_omits_the_depends_on_sentence_when_empty() {
2859        let report = RouteReport {
2860            model: "jev-1.13.0".to_string(),
2861            issues: vec![IssueRoute {
2862                item_ref: "o/r#1".to_string(),
2863                url: "u".to_string(),
2864                title: "t".to_string(),
2865                state: ItemState::Open,
2866                outcome: RouteOutcome::Routed {
2867                    providers: BTreeMap::from([(
2868                        "anthropic".to_string(),
2869                        ProviderRoute {
2870                            stages: stages("none", "sonnet", "sonnet"),
2871                            class: "sonnet".to_string(),
2872                            close_calls: vec![],
2873                        },
2874                    )]),
2875                    depends_on: vec![],
2876                    reference_fetch_failures: vec![],
2877                },
2878                truncated: false,
2879            }],
2880            ignored_closed: vec![],
2881            usage: Usage::default(),
2882        };
2883        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2884        assert!(!text.contains("cites"), "{text}");
2885    }
2886
2887    /// A dependency with no `could_be_cheaper` probability for `design`
2888    /// (defensive: [`run_route`] always sets one for every entry it puts in
2889    /// `depends_on`) still renders, just without the parenthetical.
2890    #[test]
2891    fn render_route_text_cites_a_dependency_with_no_could_be_cheaper_probability() {
2892        let report = RouteReport {
2893            model: "jev-1.13.0".to_string(),
2894            issues: vec![IssueRoute {
2895                item_ref: "o/r#1".to_string(),
2896                url: "u".to_string(),
2897                title: "t".to_string(),
2898                state: ItemState::Open,
2899                outcome: RouteOutcome::Routed {
2900                    providers: BTreeMap::from([(
2901                        "anthropic".to_string(),
2902                        ProviderRoute {
2903                            stages: stages("none", "sonnet", "sonnet"),
2904                            class: "sonnet".to_string(),
2905                            close_calls: vec![],
2906                        },
2907                    )]),
2908                    depends_on: vec![DependencyEntry {
2909                        item_ref: "#1129".to_string(),
2910                        url: None,
2911                        state: ItemState::Open,
2912                        could_be_cheaper: BTreeMap::new(),
2913                    }],
2914                    reference_fetch_failures: vec![],
2915                },
2916                truncated: false,
2917            }],
2918            ignored_closed: vec![],
2919            usage: Usage::default(),
2920        };
2921        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2922        assert!(text.contains("cites open #1129"), "{text}");
2923        assert!(!text.contains("could leave less design work"), "{text}");
2924    }
2925
2926    #[test]
2927    fn render_route_text_notes_a_truncated_issue_and_omits_the_note_otherwise() {
2928        let issue = |truncated: bool| IssueRoute {
2929            item_ref: "o/r#1".to_string(),
2930            url: "u".to_string(),
2931            title: "t".to_string(),
2932            state: ItemState::Open,
2933            outcome: RouteOutcome::Routed {
2934                providers: BTreeMap::from([(
2935                    "anthropic".to_string(),
2936                    ProviderRoute {
2937                        stages: stages("none", "sonnet", "sonnet"),
2938                        class: "sonnet".to_string(),
2939                        close_calls: vec![],
2940                    },
2941                )]),
2942                depends_on: vec![],
2943                reference_fetch_failures: vec![],
2944            },
2945            truncated,
2946        };
2947        let report = |truncated| RouteReport {
2948            model: "jev-1.13.0".to_string(),
2949            issues: vec![issue(truncated)],
2950            ignored_closed: vec![],
2951            usage: Usage::default(),
2952        };
2953        let truncated_text = render_route_text(&report(true), 60_000);
2954        assert!(
2955            truncated_text.contains("  input truncated at 60,000 characters"),
2956            "{truncated_text}"
2957        );
2958        let kept_text = render_route_text(&report(false), 60_000);
2959        assert!(!kept_text.contains("truncated"), "{kept_text}");
2960    }
2961
2962    #[test]
2963    fn render_route_text_notes_no_further_design_work() {
2964        let report = RouteReport {
2965            model: "jev-1.13.0".to_string(),
2966            issues: vec![IssueRoute {
2967                item_ref: "o/r#1".to_string(),
2968                url: "u".to_string(),
2969                title: "t".to_string(),
2970                state: ItemState::Open,
2971                outcome: RouteOutcome::Routed {
2972                    providers: BTreeMap::from([(
2973                        "anthropic".to_string(),
2974                        ProviderRoute {
2975                            stages: stages("none", "sonnet", "sonnet"),
2976                            class: "sonnet".to_string(),
2977                            close_calls: vec![],
2978                        },
2979                    )]),
2980                    depends_on: vec![],
2981                    reference_fetch_failures: vec![],
2982                },
2983                truncated: false,
2984            }],
2985            ignored_closed: vec![],
2986            usage: Usage::default(),
2987        };
2988        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
2989        assert!(text.contains("design needs no further work"), "{text}");
2990    }
2991
2992    /// Pins the layout switch from #1847: a comma-joined (multi-model) tier
2993    /// name switches the block to one line per fact instead of repeating the
2994    /// name up to four times on one line.
2995    #[test]
2996    fn render_route_text_switches_to_one_line_per_stage_for_multi_model_tiers() {
2997        let multi = "global.anthropic.claude-sonnet-4-6,global.anthropic.claude-sonnet-5";
2998        let report = RouteReport {
2999            model: "jev-1.13.0".to_string(),
3000            issues: vec![IssueRoute {
3001                item_ref: "rust-works/omni-dev#1832".to_string(),
3002                url: "u".to_string(),
3003                title: "feat(drive): banded ranges for drive sheets (#1830)".to_string(),
3004                state: ItemState::Open,
3005                outcome: RouteOutcome::Routed {
3006                    providers: BTreeMap::from([(
3007                        "anthropic".to_string(),
3008                        ProviderRoute {
3009                            stages: StageAnswers {
3010                                design: answer(NO_DESIGN, 0.94),
3011                                implement: answer(multi, 0.94),
3012                                review: answer(multi, 0.70),
3013                            },
3014                            class: multi.to_string(),
3015                            close_calls: vec![],
3016                        },
3017                    )]),
3018                    depends_on: vec![DependencyEntry {
3019                        item_ref: "#1830".to_string(),
3020                        url: None,
3021                        state: ItemState::Open,
3022                        could_be_cheaper: BTreeMap::from([("design".to_string(), 0.48)]),
3023                    }],
3024                    reference_fetch_failures: vec![],
3025                },
3026                truncated: false,
3027            }],
3028            ignored_closed: vec![],
3029            usage: Usage::default(),
3030        };
3031        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
3032        assert_eq!(
3033            text,
3034            format!(
3035                "rust-works/omni-dev#1832 — feat(drive): banded ranges for drive sheets (#1830)\n\
3036                 \x20\x20class: {multi}\n\
3037                 \x20\x20design: needs no further work (0.94)\n\
3038                 \x20\x20implementation: {multi} (0.94)\n\
3039                 \x20\x20review: {multi} (0.70)\n\
3040                 \x20\x20cites open #1830, which could leave less design work if resolved (0.48)\n\n\
3041                 model: jev-1.13.0, usage: 0 input tokens, 0 output tokens\n"
3042            )
3043        );
3044    }
3045
3046    /// A comma-free (single-model) choice keeps today's exact compact
3047    /// layout — no output churn for existing built-in-ladder users.
3048    #[test]
3049    fn render_route_text_keeps_the_compact_line_for_single_word_tiers() {
3050        let report = RouteReport {
3051            model: "jev-1.13.0".to_string(),
3052            issues: vec![IssueRoute {
3053                item_ref: "o/r#1".to_string(),
3054                url: "u".to_string(),
3055                title: "t".to_string(),
3056                state: ItemState::Open,
3057                outcome: RouteOutcome::Routed {
3058                    providers: BTreeMap::from([(
3059                        "anthropic".to_string(),
3060                        ProviderRoute {
3061                            stages: stages("opus", "sonnet", "opus"),
3062                            class: "opus".to_string(),
3063                            close_calls: vec![],
3064                        },
3065                    )]),
3066                    depends_on: vec![],
3067                    reference_fetch_failures: vec![],
3068                },
3069                truncated: false,
3070            }],
3071            ignored_closed: vec![],
3072            usage: Usage::default(),
3073        };
3074        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
3075        assert!(
3076            text.contains(
3077                "  opus — design needs opus (0.90), implementation sonnet (0.90), review \
3078                 opus (0.90)"
3079            ),
3080            "{text}"
3081        );
3082        assert!(!text.contains("class:"), "{text}");
3083    }
3084
3085    /// With more than one ladder requested, a multi-model ladder's block is
3086    /// headed by its provider name rather than prefixing every line.
3087    #[test]
3088    fn render_route_text_headers_the_block_by_provider_when_multi_model_and_multiple_ladders() {
3089        let route = |class: &str| ProviderRoute {
3090            stages: StageAnswers {
3091                design: answer(NO_DESIGN, 0.9),
3092                implement: answer(class, 0.9),
3093                review: answer(class, 0.9),
3094            },
3095            class: class.to_string(),
3096            close_calls: vec![],
3097        };
3098        let report = RouteReport {
3099            model: "jev-1.13.0".to_string(),
3100            issues: vec![IssueRoute {
3101                item_ref: "o/r#1".to_string(),
3102                url: "u".to_string(),
3103                title: "t".to_string(),
3104                state: ItemState::Open,
3105                outcome: RouteOutcome::Routed {
3106                    providers: BTreeMap::from([
3107                        ("anthropic".to_string(), route("a,b")),
3108                        ("openai".to_string(), route("c,d")),
3109                    ]),
3110                    depends_on: vec![],
3111                    reference_fetch_failures: vec![],
3112                },
3113                truncated: false,
3114            }],
3115            ignored_closed: vec![],
3116            usage: Usage::default(),
3117        };
3118        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
3119        assert!(text.contains("  anthropic:\n    class: a,b\n"), "{text}");
3120        assert!(text.contains("    implementation: a,b (0.90)"), "{text}");
3121        assert!(text.contains("  openai:\n    class: c,d\n"), "{text}");
3122        assert!(text.contains("    implementation: c,d (0.90)"), "{text}");
3123    }
3124
3125    /// The multi-line layout is decided per ladder, not for the whole issue:
3126    /// one provider with a multi-model choice does not force another
3127    /// provider's single-model choice into the block layout too.
3128    #[test]
3129    fn render_route_text_multi_model_layout_is_decided_per_ladder() {
3130        let multi_route = ProviderRoute {
3131            stages: StageAnswers {
3132                design: answer(NO_DESIGN, 0.9),
3133                implement: answer("a,b", 0.9),
3134                review: answer("a,b", 0.9),
3135            },
3136            class: "a,b".to_string(),
3137            close_calls: vec![],
3138        };
3139        let compact_route = ProviderRoute {
3140            stages: stages("none", "terra", "terra"),
3141            class: "terra".to_string(),
3142            close_calls: vec![],
3143        };
3144        let report = RouteReport {
3145            model: "jev-1.13.0".to_string(),
3146            issues: vec![IssueRoute {
3147                item_ref: "o/r#1".to_string(),
3148                url: "u".to_string(),
3149                title: "t".to_string(),
3150                state: ItemState::Open,
3151                outcome: RouteOutcome::Routed {
3152                    providers: BTreeMap::from([
3153                        ("anthropic".to_string(), multi_route),
3154                        ("openai".to_string(), compact_route),
3155                    ]),
3156                    depends_on: vec![],
3157                    reference_fetch_failures: vec![],
3158                },
3159                truncated: false,
3160            }],
3161            ignored_closed: vec![],
3162            usage: Usage::default(),
3163        };
3164        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
3165        assert!(text.contains("  anthropic:\n    class: a,b\n"), "{text}");
3166        assert!(
3167            text.contains("  openai: terra — design needs no further work"),
3168            "{text}"
3169        );
3170    }
3171
3172    /// The close-call marker stays per stage in the multi-line layout too.
3173    #[test]
3174    fn render_route_text_notes_a_close_call_in_the_multi_line_layout() {
3175        let mut review = answer("a,b", 0.41);
3176        review.probabilities =
3177            BTreeMap::from([("a,b".to_string(), 0.56), ("c,d".to_string(), 0.44)]);
3178        let report = RouteReport {
3179            model: "jev-1.13.0".to_string(),
3180            issues: vec![IssueRoute {
3181                item_ref: "o/r#1".to_string(),
3182                url: "u".to_string(),
3183                title: "t".to_string(),
3184                state: ItemState::Open,
3185                outcome: RouteOutcome::Routed {
3186                    providers: BTreeMap::from([(
3187                        "anthropic".to_string(),
3188                        ProviderRoute {
3189                            stages: StageAnswers {
3190                                design: answer(NO_DESIGN, 0.9),
3191                                implement: answer("a,b", 0.94),
3192                                review,
3193                            },
3194                            class: "a,b".to_string(),
3195                            close_calls: vec![Stage::Review],
3196                        },
3197                    )]),
3198                    depends_on: vec![],
3199                    reference_fetch_failures: vec![],
3200                },
3201                truncated: false,
3202            }],
3203            ignored_closed: vec![],
3204            usage: Usage::default(),
3205        };
3206        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
3207        assert!(
3208            text.contains("review: a,b (0.41, close call — c,d 0.44)"),
3209            "{text}"
3210        );
3211    }
3212
3213    #[test]
3214    fn render_route_text_separates_multiple_issues_with_a_blank_line_in_request_order() {
3215        let issue = |item_ref: &str| IssueRoute {
3216            item_ref: item_ref.to_string(),
3217            url: "u".to_string(),
3218            title: "t".to_string(),
3219            state: ItemState::Open,
3220            outcome: RouteOutcome::Failed {
3221                error: "e".to_string(),
3222                reference_fetch_failures: vec![],
3223            },
3224            truncated: false,
3225        };
3226        let report = RouteReport {
3227            model: "jev-1.13.0".to_string(),
3228            issues: vec![issue("o/r#1"), issue("o/r#2")],
3229            ignored_closed: vec![],
3230            usage: Usage::default(),
3231        };
3232        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
3233        let first = text.find("o/r#1").unwrap();
3234        let second = text.find("o/r#2").unwrap();
3235        assert!(first < second, "{text}");
3236        assert!(
3237            text.contains("o/r#1 — t\n  failed: e\n\no/r#2 — t\n  failed: e"),
3238            "{text}"
3239        );
3240    }
3241
3242    #[test]
3243    fn render_route_text_ends_with_the_model_and_summed_usage() {
3244        let report = RouteReport {
3245            model: "jev-1.13.0".to_string(),
3246            issues: vec![],
3247            ignored_closed: vec![],
3248            usage: Usage {
3249                input_tokens: 10,
3250                output_tokens: 20,
3251            },
3252        };
3253        let text = render_route_text(&report, DEFAULT_MAX_INPUT_CHARS);
3254        assert_eq!(
3255            text,
3256            "model: jev-1.13.0, usage: 10 input tokens, 20 output tokens\n"
3257        );
3258    }
3259
3260    #[test]
3261    fn terminal_style_requires_a_usable_tty() {
3262        assert_eq!(
3263            TerminalStyle::new(false, "xterm", false, true),
3264            TerminalStyle::default()
3265        );
3266        assert_eq!(
3267            TerminalStyle::new(true, "dumb", false, true),
3268            TerminalStyle::default()
3269        );
3270        assert_eq!(
3271            TerminalStyle::new(true, "", false, true),
3272            TerminalStyle::default()
3273        );
3274        assert_eq!(
3275            TerminalStyle::new(true, "xterm", true, true),
3276            TerminalStyle {
3277                color: false,
3278                hyperlinks: true
3279            }
3280        );
3281        assert_eq!(
3282            TerminalStyle::new(true, "xterm", false, false),
3283            TerminalStyle {
3284                color: true,
3285                hyperlinks: false
3286            }
3287        );
3288    }
3289
3290    #[test]
3291    fn complexity_uses_tier_order_for_built_in_and_custom_ladders() {
3292        let anthropic = class_only(Provider::Anthropic).unwrap();
3293        assert_eq!(
3294            choice_complexity(NO_DESIGN, &anthropic),
3295            Some(Complexity::Low)
3296        );
3297        assert_eq!(
3298            choice_complexity("sonnet", &anthropic),
3299            Some(Complexity::Low)
3300        );
3301        assert_eq!(
3302            choice_complexity("opus", &anthropic),
3303            Some(Complexity::High)
3304        );
3305        let custom = |names: &[&str]| {
3306            use std::fmt::Write;
3307
3308            let mut yaml = String::from("tiers:\n");
3309            for name in names {
3310                writeln!(yaml, "  - {{name: {name}, description: work}}").unwrap();
3311            }
3312            Ladder::named("custom".to_string(), Tiers::parse(&yaml).unwrap())
3313        };
3314        let two = custom(&["a", "b"]);
3315        assert_eq!(choice_complexity("a", &two), Some(Complexity::Low));
3316        assert_eq!(choice_complexity("b", &two), Some(Complexity::High));
3317        let four = custom(&["a", "b", "c", "d"]);
3318        assert_eq!(choice_complexity("b", &four), Some(Complexity::Medium));
3319        assert_eq!(choice_complexity("c", &four), Some(Complexity::Medium));
3320        assert_eq!(choice_complexity("d", &four), Some(Complexity::High));
3321        assert_eq!(choice_complexity("unknown", &four), None);
3322    }
3323
3324    #[test]
3325    fn hyperlink_uses_exact_osc_8_framing_and_rejects_unsafe_urls() {
3326        let style = TerminalStyle {
3327            color: false,
3328            hyperlinks: true,
3329        };
3330        let url = "https://github.com/other/repo/pull/42";
3331        assert_eq!(
3332            hyperlink("PR #42", Some(url), style),
3333            format!("\x1b]8;;{url}\x1b\\PR #42\x1b]8;;\x1b\\")
3334        );
3335        for bad in [
3336            "https://example.com/o/r/issues/1",
3337            "http://github.com/o/r/issues/1",
3338            "https://github.com/o/r/issues/1\x1b]8;;evil",
3339            "https://github.com/o/r/issues/nope",
3340            "not a valid url",
3341        ] {
3342            assert_eq!(hyperlink("#1", Some(bad), style), "#1");
3343        }
3344        assert_eq!(hyperlink("#1", None, style), "#1");
3345    }
3346
3347    #[test]
3348    fn colorize_applies_ansi_codes_per_complexity_when_color_enabled() {
3349        let color = TerminalStyle {
3350            color: true,
3351            hyperlinks: false,
3352        };
3353        assert_eq!(colorize("x", Complexity::Low, color), "\x1b[32mx\x1b[0m");
3354        assert_eq!(colorize("x", Complexity::Medium, color), "\x1b[33mx\x1b[0m");
3355        assert_eq!(colorize("x", Complexity::High, color), "\x1b[31mx\x1b[0m");
3356        assert_eq!(
3357            colorize("x", Complexity::Medium, TerminalStyle::default()),
3358            "x"
3359        );
3360    }
3361
3362    #[test]
3363    fn styled_report_colors_classes_stages_and_failure_and_links_refs() {
3364        let issue = IssueRoute {
3365            item_ref: "o/r#1".to_string(),
3366            url: "https://github.com/o/r/issues/1".to_string(),
3367            title: "t".to_string(),
3368            state: ItemState::Open,
3369            outcome: RouteOutcome::Routed {
3370                providers: BTreeMap::from([(
3371                    "anthropic".to_string(),
3372                    ProviderRoute {
3373                        stages: stages(NO_DESIGN, "opus", "sonnet"),
3374                        class: "opus".to_string(),
3375                        close_calls: vec![],
3376                    },
3377                )]),
3378                depends_on: vec![DependencyEntry {
3379                    item_ref: "#42".to_string(),
3380                    url: Some("https://github.com/other/repo/pull/42".to_string()),
3381                    state: ItemState::Open,
3382                    could_be_cheaper: BTreeMap::new(),
3383                }],
3384                reference_fetch_failures: vec![],
3385            },
3386            truncated: false,
3387        };
3388        let failed = IssueRoute {
3389            item_ref: "o/r#2".to_string(),
3390            url: "https://github.com/o/r/issues/2".to_string(),
3391            title: "failed".to_string(),
3392            state: ItemState::Open,
3393            outcome: RouteOutcome::Failed {
3394                error: "HTTP 529".to_string(),
3395                reference_fetch_failures: vec![],
3396            },
3397            truncated: false,
3398        };
3399        let report = RouteReport {
3400            model: "jev".to_string(),
3401            issues: vec![issue, failed],
3402            ignored_closed: vec![],
3403            usage: Usage::default(),
3404        };
3405        let style = TerminalStyle {
3406            color: true,
3407            hyperlinks: true,
3408        };
3409        let text = render_route_text_styled(
3410            &report,
3411            DEFAULT_MAX_INPUT_CHARS,
3412            &[class_only(Provider::Anthropic).unwrap()],
3413            style,
3414        );
3415        assert!(
3416            text.contains(
3417                "\x1b[32m\x1b]8;;https://github.com/o/r/issues/1\x1b\\o/r#1\x1b]8;;\x1b\\\x1b[0m"
3418            ),
3419            "{text}"
3420        );
3421        assert!(
3422            text.contains("\x1b[31mopus\x1b[0m — design needs \x1b[32mno further work\x1b[0m"),
3423            "{text}"
3424        );
3425        assert!(text.contains("review \x1b[32msonnet\x1b[0m"), "{text}");
3426        assert!(
3427            text.contains("\x1b]8;;https://github.com/other/repo/pull/42\x1b\\#42\x1b]8;;\x1b\\"),
3428            "{text}"
3429        );
3430        assert!(text.contains("\x1b[31mfailed: HTTP 529\x1b[0m"), "{text}");
3431        let plain = render_route_text_styled(
3432            &report,
3433            DEFAULT_MAX_INPUT_CHARS,
3434            &[],
3435            TerminalStyle::default(),
3436        );
3437        assert_eq!(plain, render_route_text(&report, DEFAULT_MAX_INPUT_CHARS));
3438        assert!(!plain.contains('\x1b'));
3439        let json = serde_json::to_string(&report).unwrap();
3440        assert!(!json.contains("https://github.com/other/repo/pull/42"));
3441    }
3442
3443    #[test]
3444    fn styled_multi_model_layout_colors_the_class_and_stage_choices() {
3445        let tier = "model-a,model-b";
3446        let ladder = Ladder::named(
3447            "custom".to_string(),
3448            Tiers::parse(&format!(
3449                "tiers:\n  - {{name: basic, description: work}}\n  - {{name: '{tier}', description: work}}\n"
3450            ))
3451            .unwrap(),
3452        );
3453        let report = RouteReport {
3454            model: "jev".to_string(),
3455            issues: vec![IssueRoute {
3456                item_ref: "o/r#1".to_string(),
3457                url: "https://github.com/o/r/issues/1".to_string(),
3458                title: "t".to_string(),
3459                state: ItemState::Open,
3460                outcome: RouteOutcome::Routed {
3461                    providers: BTreeMap::from([(
3462                        "custom".to_string(),
3463                        ProviderRoute {
3464                            stages: stages(NO_DESIGN, tier, "basic"),
3465                            class: tier.to_string(),
3466                            close_calls: vec![],
3467                        },
3468                    )]),
3469                    depends_on: vec![],
3470                    reference_fetch_failures: vec![],
3471                },
3472                truncated: false,
3473            }],
3474            ignored_closed: vec![],
3475            usage: Usage::default(),
3476        };
3477        let text = render_route_text_styled(
3478            &report,
3479            DEFAULT_MAX_INPUT_CHARS,
3480            &[ladder],
3481            TerminalStyle {
3482                color: true,
3483                hyperlinks: false,
3484            },
3485        );
3486        assert!(
3487            text.contains(&format!("  class: \x1b[31m{tier}\x1b[0m")),
3488            "{text}"
3489        );
3490        assert!(
3491            text.contains(&format!("  implementation: \x1b[31m{tier}\x1b[0m")),
3492            "{text}"
3493        );
3494        assert!(text.contains("  review: \x1b[32mbasic\x1b[0m"), "{text}");
3495    }
3496
3497    #[test]
3498    fn design_stage_with_a_named_tier_renders_needs_prefix() {
3499        let line = render_stage_line(
3500            Stage::Design,
3501            &stages("opus", "sonnet", "sonnet"),
3502            &[],
3503            None,
3504            TerminalStyle::default(),
3505        );
3506        assert_eq!(line, "design: needs opus (0.90)");
3507    }
3508
3509    #[test]
3510    fn with_thousands_groups_digits() {
3511        assert_eq!(with_thousands(0), "0");
3512        assert_eq!(with_thousands(999), "999");
3513        assert_eq!(with_thousands(60_000), "60,000");
3514        assert_eq!(with_thousands(1_000_000), "1,000,000");
3515    }
3516}