use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::error::Error;
use crate::types::entry::Entry;
pub const MAX_CHOICE_OPTIONS: usize = 255;
pub type Questions = IndexMap<String, Question>;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Question {
Noul {
instructions: Entry,
#[serde(skip_serializing_if = "Option::is_none")]
criteria: Option<NoulCriteria>,
},
Choice {
instructions: Entry,
criteria: IndexMap<String, Entry>,
},
Score {
instructions: Entry,
criteria: Vec<Entry>,
},
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct NoulCriteria {
#[serde(rename = "true", skip_serializing_if = "Option::is_none")]
pub when_true: Option<Entry>,
#[serde(rename = "false", skip_serializing_if = "Option::is_none")]
pub when_false: Option<Entry>,
}
impl Question {
#[must_use]
pub fn noul(instructions: impl Into<Entry>) -> Self {
Self::Noul {
instructions: instructions.into(),
criteria: None,
}
}
#[must_use]
pub fn choice(instructions: impl Into<Entry>) -> Self {
Self::Choice {
instructions: instructions.into(),
criteria: IndexMap::new(),
}
}
#[must_use]
pub fn score(instructions: impl Into<Entry>) -> Self {
Self::Score {
instructions: instructions.into(),
criteria: Vec::new(),
}
}
#[must_use]
pub fn when_true(self, description: impl Into<Entry>) -> Self {
match self {
Self::Noul {
instructions,
criteria,
} => {
let mut criteria = criteria.unwrap_or_default();
criteria.when_true = Some(description.into());
Self::Noul {
instructions,
criteria: Some(criteria),
}
}
other => other,
}
}
#[must_use]
pub fn when_false(self, description: impl Into<Entry>) -> Self {
match self {
Self::Noul {
instructions,
criteria,
} => {
let mut criteria = criteria.unwrap_or_default();
criteria.when_false = Some(description.into());
Self::Noul {
instructions,
criteria: Some(criteria),
}
}
other => other,
}
}
#[must_use]
pub fn option(self, key: impl Into<String>, description: impl Into<Entry>) -> Self {
match self {
Self::Choice {
instructions,
mut criteria,
} => {
criteria.insert(key.into(), description.into());
Self::Choice {
instructions,
criteria,
}
}
other => other,
}
}
#[must_use]
pub fn level(self, description: impl Into<Entry>) -> Self {
match self {
Self::Score {
instructions,
mut criteria,
} => {
criteria.push(description.into());
Self::Score {
instructions,
criteria,
}
}
other => other,
}
}
}
pub fn validate_questions(questions: &Questions) -> Result<(), Error> {
if questions.is_empty() {
return Err(Error::InvalidRequest(
"At least one question is required.".to_owned(),
));
}
for (name, question) in questions {
if name.is_empty() {
return Err(Error::InvalidRequest(
"question keys must be non-empty".to_owned(),
));
}
match question {
Question::Choice { criteria, .. } => {
let n = criteria.len();
if n < 2 {
return Err(Error::InvalidRequest(format!(
"Choice question \"{name}\" has {n} options; at least 2 are required"
)));
}
if n > MAX_CHOICE_OPTIONS {
return Err(Error::InvalidRequest(format!(
"Choice question \"{name}\" has {n} options; at most {MAX_CHOICE_OPTIONS} are allowed"
)));
}
}
Question::Score { criteria, .. } => {
let n = criteria.len();
if n < 2 {
return Err(Error::InvalidRequest(format!(
"Score question \"{name}\" has {n} criteria; at least two scores are required."
)));
}
}
Question::Noul { .. } => {}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn noul_serializes_with_criteria_keys() {
let q = Question::noul("Does this convey urgency?")
.when_true("Explicitly time-sensitive")
.when_false("No time pressure");
let value = serde_json::to_value(&q).unwrap();
assert_eq!(
value,
json!({
"type": "noul",
"instructions": "Does this convey urgency?",
"criteria": {
"true": "Explicitly time-sensitive",
"false": "No time pressure"
}
})
);
}
#[test]
fn noul_omits_empty_criteria() {
let q = Question::noul("yes?");
let value = serde_json::to_value(&q).unwrap();
assert_eq!(value.get("criteria"), None);
}
#[test]
fn choice_preserves_insertion_order() {
let q = Question::choice("Which team?")
.option("billing", "Payment issues")
.option("technical", "Bugs");
let value = serde_json::to_value(&q).unwrap();
let keys: Vec<_> = value["criteria"]
.as_object()
.unwrap()
.keys()
.cloned()
.collect();
assert_eq!(keys, ["billing", "technical"]);
}
#[test]
fn score_serializes_levels_as_array() {
let q = Question::score("How frustrated?")
.level("Calm")
.level("Frustrated")
.level("Very angry");
let value = serde_json::to_value(&q).unwrap();
assert_eq!(
value["criteria"],
json!(["Calm", "Frustrated", "Very angry"])
);
}
#[test]
fn rejects_empty_map() {
let q = Questions::new();
let err = validate_questions(&q).unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)));
}
#[test]
fn rejects_empty_key() {
let mut q = Questions::new();
q.insert(String::new(), Question::noul("x"));
let err = validate_questions(&q).unwrap_err();
assert!(format!("{err}").contains("non-empty"));
}
#[test]
fn rejects_choice_with_one_option() {
let mut q = Questions::new();
q.insert(
"dept".into(),
Question::choice("Which?").option("only", "one"),
);
let err = validate_questions(&q).unwrap_err();
assert!(format!("{err}").contains("at least 2"));
}
#[test]
fn rejects_choice_over_max_options() {
let mut criteria = IndexMap::new();
for i in 0..=MAX_CHOICE_OPTIONS {
criteria.insert(format!("k{i}"), Entry::from("d"));
}
let mut q = Questions::new();
q.insert(
"dept".into(),
Question::Choice {
instructions: Entry::from("Which?"),
criteria,
},
);
let err = validate_questions(&q).unwrap_err();
assert!(format!("{err}").contains("at most"));
}
#[test]
fn rejects_score_with_one_level() {
let mut q = Questions::new();
q.insert("s".into(), Question::score("rate").level("low"));
let err = validate_questions(&q).unwrap_err();
assert!(format!("{err}").contains("at least two"));
}
#[test]
fn accepts_valid_choice_and_score() {
let mut q = Questions::new();
q.insert(
"dept".into(),
Question::choice("Which?").option("a", "A").option("b", "B"),
);
q.insert(
"mood".into(),
Question::score("rate").level("low").level("high"),
);
validate_questions(&q).unwrap();
}
}