use serde::{Deserialize, Serialize};
use super::ids::{ToolCallId, TurnId};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Question {
pub header: String,
pub question: String,
#[serde(default)]
pub kind: QuestionKind,
#[serde(default)]
pub options: Vec<QuestionOption>,
#[serde(default)]
pub memory_key: Option<String>,
}
impl Question {
pub fn is_choice(&self) -> bool {
matches!(
self.kind,
QuestionKind::Select | QuestionKind::MultiSelect | QuestionKind::Rank
)
}
pub fn is_multi(&self) -> bool {
matches!(self.kind, QuestionKind::MultiSelect)
}
pub fn is_rank(&self) -> bool {
matches!(self.kind, QuestionKind::Rank)
}
pub fn is_input(&self) -> bool {
!self.is_choice()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum QuestionKind {
#[default]
Select,
MultiSelect,
Rank,
Text {
#[serde(default)]
validate: TextValidate,
},
Number {
#[serde(default)]
min: Option<f64>,
#[serde(default)]
max: Option<f64>,
#[serde(default)]
step: Option<f64>,
#[serde(default)]
slider: bool,
},
Date,
Path {
#[serde(default)]
must_exist: bool,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(tag = "rule", content = "pattern", rename_all = "camelCase")]
pub enum TextValidate {
#[default]
Any,
Number,
Regex(String),
}
pub fn validate_input(kind: &QuestionKind, value: &str) -> Result<(), String> {
let v = value.trim();
if v.is_empty() {
return Ok(());
}
match kind {
QuestionKind::Number { min, max, .. } => {
let n: f64 = v.parse().map_err(|_| "must be a number".to_string())?;
if let Some(lo) = min
&& n < *lo
{
return Err(format!("must be >= {lo}"));
}
if let Some(hi) = max
&& n > *hi
{
return Err(format!("must be <= {hi}"));
}
Ok(())
},
QuestionKind::Text { validate } => match validate {
TextValidate::Any => Ok(()),
TextValidate::Number => v
.parse::<f64>()
.map(|_| ())
.map_err(|_| "must be a number".to_string()),
TextValidate::Regex(pat) => match regex::Regex::new(pat) {
Ok(re) if re.is_match(v) => Ok(()),
Ok(_) => Err(format!("must match /{pat}/")),
Err(_) => Ok(()),
},
},
QuestionKind::Date => chrono::NaiveDate::parse_from_str(v, "%Y-%m-%d")
.map(|_| ())
.map_err(|_| "must be a date (YYYY-MM-DD)".to_string()),
_ => Ok(()),
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QuestionOption {
pub label: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub recommended: bool,
#[serde(default)]
pub preview: Option<OptionPreview>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OptionPreview {
pub content: String,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub diff: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PendingQuestionSet {
pub turn: TurnId,
pub call_id: ToolCallId,
pub questions: Vec<Question>,
pub active: usize,
pub selections: Vec<QuestionSelection>,
pub review_cursor: usize,
pub editing_note: bool,
pub remember: bool,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct QuestionSelection {
pub cursor: usize,
pub chosen: Vec<usize>,
pub other_text: String,
pub note: String,
pub value: String,
pub order: Vec<usize>,
pub grabbed: bool,
}
impl PendingQuestionSet {
pub fn new(turn: TurnId, call_id: ToolCallId, questions: Vec<Question>) -> Self {
let selections = questions
.iter()
.map(|_| QuestionSelection::default())
.collect();
Self {
turn,
call_id,
questions,
active: 0,
selections,
review_cursor: 0,
editing_note: false,
remember: false,
}
}
pub fn skips_review(&self) -> bool {
self.questions.len() == 1 && !self.questions[0].is_multi()
}
pub fn row_count(&self, idx: usize) -> usize {
let q = &self.questions[idx];
q.options.len() + 1 + usize::from(q.is_multi())
}
pub fn other_row(&self, idx: usize) -> usize {
self.questions[idx].options.len()
}
pub fn submit_row(&self, idx: usize) -> Option<usize> {
let q = &self.questions[idx];
q.is_multi().then_some(q.options.len() + 1)
}
pub fn build_answers(&self) -> Vec<QuestionAnswer> {
self.questions
.iter()
.zip(&self.selections)
.map(|(q, sel)| {
let selected = if q.is_input() {
let v = sel.value.trim();
if v.is_empty() {
Vec::new()
} else {
vec![v.to_string()]
}
} else if q.is_rank() {
rank_order(q, sel)
.iter()
.filter_map(|&i| q.options.get(i).map(|o| o.label.clone()))
.collect()
} else {
let mut s: Vec<String> = sel
.chosen
.iter()
.filter_map(|&i| q.options.get(i).map(|o| o.label.clone()))
.collect();
let other = sel.other_text.trim();
if !other.is_empty() {
s.push(other.to_string());
}
s
};
let note = sel.note.trim();
QuestionAnswer {
header: q.header.clone(),
question: q.question.clone(),
selected,
note: (!note.is_empty()).then(|| note.to_string()),
}
})
.collect()
}
}
pub fn rank_order(q: &Question, sel: &QuestionSelection) -> Vec<usize> {
if sel.order.is_empty() {
(0..q.options.len()).collect()
} else {
sel.order.clone()
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum QuestionResolution {
Answered {
answers: Vec<QuestionAnswer>,
remember: bool,
},
Dismissed,
Reformulate,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QuestionAnswer {
pub header: String,
pub question: String,
pub selected: Vec<String>,
#[serde(default)]
pub note: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn num(min: f64, max: f64) -> QuestionKind {
QuestionKind::Number {
min: Some(min),
max: Some(max),
step: None,
slider: false,
}
}
#[test]
fn validate_number_bounds() {
assert!(validate_input(&num(0.0, 5.0), "3").is_ok());
assert!(validate_input(&num(0.0, 5.0), "9").is_err());
assert!(validate_input(&num(0.0, 5.0), "x").is_err());
assert!(validate_input(&num(0.0, 5.0), "").is_ok());
}
#[test]
fn validate_date_and_regex() {
assert!(validate_input(&QuestionKind::Date, "2026-07-07").is_ok());
assert!(validate_input(&QuestionKind::Date, "nope").is_err());
let re = QuestionKind::Text {
validate: TextValidate::Regex("^a+$".to_string()),
};
assert!(validate_input(&re, "aaa").is_ok());
assert!(validate_input(&re, "abc").is_err());
}
}