use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::errors::Error;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct NoulCriteria {
#[serde(rename = "true", skip_serializing_if = "Option::is_none")]
pub true_: Option<Value>,
#[serde(rename = "false", skip_serializing_if = "Option::is_none")]
pub false_: Option<Value>,
}
impl NoulCriteria {
pub fn new(true_: Option<impl Into<Value>>, false_: Option<impl Into<Value>>) -> Self {
Self {
true_: true_.map(Into::into),
false_: false_.map(Into::into),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename = "noul")]
pub struct Noul {
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub criteria: Option<NoulCriteria>,
}
impl Noul {
pub fn new(instructions: impl Into<Value>) -> Self {
Self {
instructions: Some(instructions.into()),
criteria: None,
}
}
pub fn without_instructions() -> Self {
Self::default()
}
pub fn instructions(mut self, instructions: impl Into<Value>) -> Self {
self.instructions = Some(instructions.into());
self
}
pub fn criteria(
mut self,
true_: Option<impl Into<Value>>,
false_: Option<impl Into<Value>>,
) -> Self {
self.criteria = Some(NoulCriteria::new(true_, false_));
self
}
pub fn criteria_value(mut self, criteria: NoulCriteria) -> Self {
self.criteria = Some(criteria);
self
}
pub fn criteria_true(mut self, true_: impl Into<Value>) -> Self {
let criteria = self.criteria.take().unwrap_or_default();
self.criteria = Some(NoulCriteria {
true_: Some(true_.into()),
..criteria
});
self
}
pub fn criteria_false(mut self, false_: impl Into<Value>) -> Self {
let criteria = self.criteria.take().unwrap_or_default();
self.criteria = Some(NoulCriteria {
false_: Some(false_.into()),
..criteria
});
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename = "choice")]
pub struct Choice {
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<Value>,
pub criteria: serde_json::Map<String, Value>,
}
impl Choice {
pub fn new(instructions: impl Into<Value>) -> Self {
Self {
instructions: Some(instructions.into()),
criteria: serde_json::Map::new(),
}
}
pub fn without_instructions() -> Self {
Self::default()
}
pub fn from_options<I, S>(instructions: impl Into<Value>, options: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut criteria = serde_json::Map::new();
for option in options {
criteria.insert(option.into(), Value::Null);
}
Self {
instructions: Some(instructions.into()),
criteria,
}
}
pub fn from_pairs<I, S>(instructions: impl Into<Value>, pairs: I) -> Self
where
I: IntoIterator<Item = (S, Value)>,
S: Into<String>,
{
let mut criteria = serde_json::Map::new();
for (option, description) in pairs {
criteria.insert(option.into(), description);
}
Self {
instructions: Some(instructions.into()),
criteria,
}
}
pub fn instructions(mut self, instructions: impl Into<Value>) -> Self {
self.instructions = Some(instructions.into());
self
}
pub fn option(mut self, option: impl Into<String>, description: impl Into<Value>) -> Self {
self.criteria.insert(option.into(), description.into());
self
}
pub fn bare_option(self, option: impl Into<String>) -> Self {
self.option(option, Value::Null)
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename = "score")]
pub struct Score {
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<Value>,
pub criteria: Vec<Value>,
}
impl Score {
pub fn new<I>(instructions: impl Into<Value>, levels: I) -> Self
where
I: IntoIterator,
I::Item: Into<Value>,
{
Self {
instructions: Some(instructions.into()),
criteria: levels.into_iter().map(Into::into).collect(),
}
}
pub fn without_instructions<I>(levels: I) -> Self
where
I: IntoIterator,
I::Item: Into<Value>,
{
Self {
instructions: None,
criteria: levels.into_iter().map(Into::into).collect(),
}
}
pub fn instructions(mut self, instructions: impl Into<Value>) -> Self {
self.instructions = Some(instructions.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
#[non_exhaustive]
pub enum Question {
Noul(Noul),
Choice(Choice),
Score(Score),
}
impl From<Noul> for Question {
fn from(q: Noul) -> Self {
Question::Noul(q)
}
}
impl From<Choice> for Question {
fn from(q: Choice) -> Self {
Question::Choice(q)
}
}
impl From<Score> for Question {
fn from(q: Score) -> Self {
Question::Score(q)
}
}
pub(super) fn validate(
state: &Value,
questions: &std::collections::BTreeMap<String, Question>,
) -> Result<(), Error> {
if questions.is_empty() {
return Err(Error::InvalidRequest(
"At least one question is required.".into(),
));
}
match state {
Value::String(_) | Value::Object(_) | Value::Array(_) => {}
Value::Number(_) => {
return Err(Error::InvalidRequest(
"state must be a JSON string, object, or array; numbers are rejected.".into(),
))
}
Value::Bool(_) => {
return Err(Error::InvalidRequest(
"state must be a JSON string, object, or array; booleans are rejected.".into(),
))
}
Value::Null => {
return Err(Error::InvalidRequest(
"state must be a JSON string, object, or array; null is rejected.".into(),
))
}
}
for (name, question) in questions {
match question {
Question::Choice(choice) => {
if choice.criteria.is_empty() {
return Err(Error::InvalidRequest(format!(
"Choice question \"{name}\" has no options; at least one is required."
)));
}
}
Question::Score(score) => {
if score.criteria.len() < 2 {
return Err(Error::InvalidRequest(format!(
"Score question \"{name}\" has {} {}; at least two are required.",
score.criteria.len(),
if score.criteria.len() == 1 {
"criterion"
} else {
"criteria"
}
)));
}
if let Some(position) = score.criteria.iter().position(Value::is_null) {
return Err(Error::InvalidRequest(format!(
"Score question \"{name}\" has a null level at position {position}; levels must be strings, objects, or arrays."
)));
}
}
Question::Noul(_) => {}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::BTreeMap;
#[test]
fn serializes_noul_with_omitted_optionals() {
let q = Noul::new("Is this spam?");
let v = serde_json::to_value(&q).unwrap();
assert_eq!(v, json!({"type": "noul", "instructions": "Is this spam?"}));
}
#[test]
fn serializes_noul_criteria_renames_and_omits() {
let q = Noul::new("Spam?").criteria(Some("Unsolicited"), None::<&str>);
let v = serde_json::to_value(&q).unwrap();
assert_eq!(
v,
json!({
"type": "noul",
"instructions": "Spam?",
"criteria": {"true": "Unsolicited"}
})
);
let q = Noul::without_instructions().criteria(None::<&str>, Some("Legit"));
let v = serde_json::to_value(&q).unwrap();
assert_eq!(v, json!({"type": "noul", "criteria": {"false": "Legit"}}));
}
#[test]
fn preserves_null_criteria_inside_noul() {
let q = Noul::new("Spam?").criteria(Some("Unsolicited"), Some(Value::Null));
let v = serde_json::to_value(&q).unwrap();
assert_eq!(v["criteria"], json!({"true": "Unsolicited", "false": null}));
}
#[test]
fn preserves_choice_option_order() {
let mut choice = Choice::new("Tone?")
.option("b", "Second")
.option("a", "First");
choice = choice.bare_option("z").option("m", json!({"x": 1}));
let v = serde_json::to_value(&choice).unwrap();
assert_eq!(
v["criteria"]
.as_object()
.unwrap()
.keys()
.collect::<Vec<_>>(),
vec!["b", "a", "z", "m"]
);
assert_eq!(v["criteria"]["z"], json!(null));
assert_eq!(v["criteria"]["m"], json!({"x": 1}));
}
#[test]
fn from_options_uses_null_descriptions_in_order() {
let q = Choice::from_options("Team?", ["billing", "tech", "sales"]);
let v = serde_json::to_value(&q).unwrap();
assert_eq!(
v["criteria"]
.as_object()
.unwrap()
.keys()
.collect::<Vec<_>>(),
vec!["billing", "tech", "sales"]
);
assert!(v["criteria"]["billing"].is_null());
}
#[test]
fn score_serializes_ordered_levels() {
let q = Score::new("Urgency?", ["Low", "High"]);
let v = serde_json::to_value(&q).unwrap();
assert_eq!(
v,
json!({"type": "score", "instructions": "Urgency?", "criteria": ["Low", "High"]})
);
}
#[test]
fn tag_serialization_round_trips() {
for question in [
Question::Noul(Noul::new("a")),
Question::Choice(Choice::new("b").option("x", "y")),
Question::Score(Score::new("c", ["1", "2"])),
] {
let v = serde_json::to_value(&question).unwrap();
let back: Question = serde_json::from_value(v).unwrap();
assert_eq!(back, question);
}
}
#[test]
fn validation_rejects_bad_input() {
let mut questions = BTreeMap::new();
let state = json!("text");
assert!(validate(&state, &questions).is_err());
questions.insert("x".into(), Question::Noul(Noul::new("a")));
for bad in [json!(1), json!(true), json!(null)] {
let err = validate(&bad, &questions).unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)), "{err}");
}
questions.insert("c".into(), Question::Choice(Choice::new("c")));
let err = validate(&state, &questions).unwrap_err();
let msg = err.to_string();
assert!(msg.contains('c') && msg.contains("at least one"), "{msg}");
questions.remove("c");
questions.insert("s".into(), Question::Score(Score::new("s", ["only"])));
let err = validate(&state, &questions).unwrap_err();
assert!(err.to_string().contains("at least two"), "{err}");
let criteria: Vec<serde_json::Value> = vec!["a".into(), serde_json::Value::Null];
questions.insert(
"s".into(),
Question::Score(Score {
instructions: Some("s".into()),
criteria,
}),
);
let err = validate(&state, &questions).unwrap_err();
assert!(err.to_string().contains("null level"), "{err}");
}
}