use std::collections::BTreeMap;
use anyhow::Result;
use clap::Parser;
use crate::jev::client::JevClient;
use crate::jev::config::JevConfig;
use crate::jev::error::JevError;
use crate::jev::protocol::{Question, SystemOneRequest};
use super::common::{
build_state_value, format_single, resolve_state, JevFormat, SINGLE_QUESTION_KEY,
};
#[derive(Parser)]
pub struct ChoiceCommand {
pub state: Option<String>,
#[arg(long)]
pub state_json: bool,
#[arg(long)]
pub instructions: String,
#[arg(long = "option", value_name = "NAME=DESC", value_parser = parse_option_kv)]
pub option: Vec<(String, String)>,
#[arg(short = 'o', long, value_enum, default_value_t = JevFormat::Json)]
pub(super) output: JevFormat,
#[arg(long, value_name = "MODEL")]
pub jev_model: Option<String>,
}
impl ChoiceCommand {
pub async fn execute(self) -> Result<()> {
let mut config = JevConfig::from_env()?;
if let Some(model) = self.jev_model {
config.model = model;
}
let client = JevClient::from_config(&config)?;
let output = run_choice(
&client,
&config.model,
self.state,
self.state_json,
&self.instructions,
self.option,
self.output,
)
.await?;
print!("{output}");
Ok(())
}
}
fn parse_option_kv(s: &str) -> Result<(String, String), String> {
let (name, desc) = s
.split_once('=')
.ok_or_else(|| format!("`{s}` is not in NAME=DESC form"))?;
if name.is_empty() {
return Err(format!("`{s}` has an empty option name"));
}
Ok((name.to_string(), desc.to_string()))
}
#[allow(clippy::too_many_arguments)]
async fn run_choice(
client: &JevClient,
model: &str,
state: Option<String>,
state_json: bool,
instructions: &str,
options: Vec<(String, String)>,
format: JevFormat,
) -> Result<String> {
let mut criteria = BTreeMap::new();
for (name, desc) in options {
if criteria.contains_key(&name) {
return Err(
JevError::InvalidQuestionSpec(format!("duplicate --option name: {name}")).into(),
);
}
criteria.insert(name, desc);
}
let question = Question::Choice {
instructions: instructions.to_string(),
criteria,
};
question.validate_for_model(model)?;
let raw_state = resolve_state(state)?;
let state_value = build_state_value(&raw_state, state_json)?;
let request = SystemOneRequest {
state: state_value,
model: model.to_string(),
questions: BTreeMap::from([(SINGLE_QUESTION_KEY.to_string(), question)]),
};
let response = client.system_one(&request).await?;
format_single(&response, format)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::super::common::test_support::dead_client;
use super::*;
#[test]
fn parse_option_kv_splits_name_and_description() {
assert_eq!(
parse_option_kv("billing=Payments").unwrap(),
("billing".to_string(), "Payments".to_string())
);
}
#[test]
fn parse_option_kv_keeps_later_equals_in_description() {
assert_eq!(
parse_option_kv("a=b=c").unwrap(),
("a".to_string(), "b=c".to_string())
);
}
#[test]
fn parse_option_kv_rejects_missing_equals() {
let err = parse_option_kv("noequals").unwrap_err();
assert!(err.contains("NAME=DESC"));
}
#[test]
fn parse_option_kv_rejects_empty_name() {
let err = parse_option_kv("=desc").unwrap_err();
assert!(err.contains("empty option name"));
}
#[test]
fn parse_option_kv_allows_empty_description() {
assert_eq!(
parse_option_kv("name=").unwrap(),
("name".to_string(), String::new())
);
}
#[tokio::test]
async fn run_choice_rejects_a_single_option() {
let err = run_choice(
&dead_client(),
"jev-latest",
Some("state".to_string()),
false,
"route this",
vec![("billing".to_string(), "Payments".to_string())],
JevFormat::Json,
)
.await
.unwrap_err();
assert!(matches!(
err.downcast_ref::<JevError>(),
Some(JevError::InvalidQuestionSpec(_))
));
assert!(err.to_string().contains("at least 2 options, got 1"));
}
#[tokio::test]
async fn run_choice_rejects_zero_options() {
let err = run_choice(
&dead_client(),
"jev-latest",
Some("state".to_string()),
false,
"route this",
Vec::new(),
JevFormat::Json,
)
.await
.unwrap_err();
assert!(err.to_string().contains("got 0"));
}
fn numbered_options(n: usize) -> Vec<(String, String)> {
(0..n)
.map(|i| (format!("o{i}"), "desc".to_string()))
.collect()
}
#[tokio::test]
async fn run_choice_rejects_more_than_255_options_before_any_request() {
let err = run_choice(
&dead_client(),
"jev-1.13.0",
Some("state".to_string()),
false,
"route this",
numbered_options(256),
JevFormat::Json,
)
.await
.unwrap_err();
assert!(matches!(
err.downcast_ref::<JevError>(),
Some(JevError::InvalidQuestionSpec(_))
));
assert!(err
.to_string()
.contains("at most 255 options on jev-1.13.0, got 256"));
}
#[tokio::test]
async fn run_choice_sends_more_than_255_options_to_an_unknown_model() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/v1/systemone"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"model": "jev-2.0.0",
"answers": {
"answer": {
"type": "choice",
"choice": "o0",
"confidence": 0.5,
"probabilities": {"o0": 0.5}
}
},
"usage": {"input_tokens": 1, "output_tokens": 1}
})),
)
.expect(1)
.mount(&server)
.await;
let client = JevClient::new(&server.uri(), "my-key").unwrap();
run_choice(
&client,
"jev-2.0.0",
Some("state".to_string()),
false,
"route this",
numbered_options(300),
JevFormat::Json,
)
.await
.unwrap();
}
#[tokio::test]
async fn run_choice_rejects_duplicate_option_names() {
let err = run_choice(
&dead_client(),
"jev-latest",
Some("state".to_string()),
false,
"route this",
vec![
("billing".to_string(), "Payments".to_string()),
("billing".to_string(), "Other".to_string()),
],
JevFormat::Json,
)
.await
.unwrap_err();
assert!(err.to_string().contains("duplicate --option name: billing"));
}
#[tokio::test]
async fn run_choice_rejects_empty_state() {
let err = run_choice(
&dead_client(),
"jev-latest",
Some(" ".to_string()),
false,
"route this",
vec![
("billing".to_string(), "Payments".to_string()),
("technical".to_string(), "Bugs".to_string()),
],
JevFormat::Json,
)
.await
.unwrap_err();
assert!(err.to_string().contains("must not be empty"));
}
#[tokio::test]
async fn run_choice_sends_expected_request_and_parses_response() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/v1/systemone"))
.and(wiremock::matchers::body_json(serde_json::json!({
"state": "payouts failing 3 days",
"model": "jev-latest",
"questions": {
"answer": {
"type": "choice",
"instructions": "Route this",
"criteria": {"billing": "Payments", "technical": "Bugs"}
}
}
})))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"model": "jev-1.13.0",
"answers": {
"answer": {
"type": "choice",
"choice": "billing",
"confidence": 0.97,
"probabilities": {"billing": 0.97, "technical": 0.03}
}
},
"usage": {"input_tokens": 10, "output_tokens": 5}
})),
)
.expect(1)
.mount(&server)
.await;
let client = JevClient::new(&server.uri(), "my-key").unwrap();
let out = run_choice(
&client,
"jev-latest",
Some("payouts failing 3 days".to_string()),
false,
"Route this",
vec![
("billing".to_string(), "Payments".to_string()),
("technical".to_string(), "Bugs".to_string()),
],
JevFormat::Json,
)
.await
.unwrap();
assert!(out.contains("\"choice\": \"billing\""));
assert!(out.contains("\"model\": \"jev-1.13.0\""));
}
#[tokio::test]
async fn run_choice_yaml_output() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/v1/systemone"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"model": "jev-1.13.0",
"answers": {
"answer": {
"type": "choice",
"choice": "billing",
"confidence": 0.97,
"probabilities": {"billing": 0.97}
}
},
"usage": {"input_tokens": 1, "output_tokens": 1}
})),
)
.mount(&server)
.await;
let client = JevClient::new(&server.uri(), "my-key").unwrap();
let out = run_choice(
&client,
"jev-latest",
Some("state".to_string()),
false,
"Route this",
vec![
("billing".to_string(), "Payments".to_string()),
("technical".to_string(), "Bugs".to_string()),
],
JevFormat::Yaml,
)
.await
.unwrap();
assert!(out.contains("choice: billing"));
assert!(out.contains("model: jev-1.13.0"));
}
#[test]
fn clap_parses_repeated_options_and_short_output_flag() {
let cmd = ChoiceCommand::try_parse_from([
"choice",
"state text",
"--instructions",
"Route this",
"--option",
"billing=Payments",
"--option",
"technical=Bugs",
"-o",
"yaml",
])
.unwrap();
assert_eq!(cmd.state.as_deref(), Some("state text"));
assert_eq!(
cmd.option,
vec![
("billing".to_string(), "Payments".to_string()),
("technical".to_string(), "Bugs".to_string()),
]
);
assert_eq!(cmd.output, JevFormat::Yaml);
}
#[test]
fn clap_rejects_malformed_option() {
let err = ChoiceCommand::try_parse_from([
"choice",
"state text",
"--instructions",
"x",
"--option",
"noequals",
])
.err()
.expect("expected a parse error");
assert!(err.to_string().contains("NAME=DESC"));
}
}