1use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18use crate::errors::Error;
19
20#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
27pub struct NoulCriteria {
28 #[serde(rename = "true", skip_serializing_if = "Option::is_none")]
30 pub true_: Option<Value>,
31 #[serde(rename = "false", skip_serializing_if = "Option::is_none")]
33 pub false_: Option<Value>,
34}
35
36impl NoulCriteria {
37 pub fn new(true_: Option<impl Into<Value>>, false_: Option<impl Into<Value>>) -> Self {
42 Self {
43 true_: true_.map(Into::into),
44 false_: false_.map(Into::into),
45 }
46 }
47}
48
49#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
61#[serde(tag = "type", rename = "noul")]
62pub struct Noul {
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub instructions: Option<Value>,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub criteria: Option<NoulCriteria>,
69}
70
71impl Noul {
72 pub fn new(instructions: impl Into<Value>) -> Self {
74 Self {
75 instructions: Some(instructions.into()),
76 criteria: None,
77 }
78 }
79
80 pub fn without_instructions() -> Self {
82 Self::default()
83 }
84
85 pub fn instructions(mut self, instructions: impl Into<Value>) -> Self {
87 self.instructions = Some(instructions.into());
88 self
89 }
90
91 pub fn criteria(
102 mut self,
103 true_: Option<impl Into<Value>>,
104 false_: Option<impl Into<Value>>,
105 ) -> Self {
106 self.criteria = Some(NoulCriteria::new(true_, false_));
107 self
108 }
109
110 pub fn criteria_value(mut self, criteria: NoulCriteria) -> Self {
112 self.criteria = Some(criteria);
113 self
114 }
115
116 pub fn criteria_true(mut self, true_: impl Into<Value>) -> Self {
119 let criteria = self.criteria.take().unwrap_or_default();
120 self.criteria = Some(NoulCriteria {
121 true_: Some(true_.into()),
122 ..criteria
123 });
124 self
125 }
126
127 pub fn criteria_false(mut self, false_: impl Into<Value>) -> Self {
130 let criteria = self.criteria.take().unwrap_or_default();
131 self.criteria = Some(NoulCriteria {
132 false_: Some(false_.into()),
133 ..criteria
134 });
135 self
136 }
137}
138
139#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
152#[serde(tag = "type", rename = "choice")]
153pub struct Choice {
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub instructions: Option<Value>,
157 pub criteria: serde_json::Map<String, Value>,
160}
161
162impl Choice {
163 pub fn new(instructions: impl Into<Value>) -> Self {
165 Self {
166 instructions: Some(instructions.into()),
167 criteria: serde_json::Map::new(),
168 }
169 }
170
171 pub fn without_instructions() -> Self {
173 Self::default()
174 }
175
176 pub fn from_options<I, S>(instructions: impl Into<Value>, options: I) -> Self
180 where
181 I: IntoIterator<Item = S>,
182 S: Into<String>,
183 {
184 let mut criteria = serde_json::Map::new();
185 for option in options {
186 criteria.insert(option.into(), Value::Null);
187 }
188 Self {
189 instructions: Some(instructions.into()),
190 criteria,
191 }
192 }
193
194 pub fn from_pairs<I, S>(instructions: impl Into<Value>, pairs: I) -> Self
199 where
200 I: IntoIterator<Item = (S, Value)>,
201 S: Into<String>,
202 {
203 let mut criteria = serde_json::Map::new();
204 for (option, description) in pairs {
205 criteria.insert(option.into(), description);
206 }
207 Self {
208 instructions: Some(instructions.into()),
209 criteria,
210 }
211 }
212
213 pub fn instructions(mut self, instructions: impl Into<Value>) -> Self {
215 self.instructions = Some(instructions.into());
216 self
217 }
218
219 pub fn option(mut self, option: impl Into<String>, description: impl Into<Value>) -> Self {
224 self.criteria.insert(option.into(), description.into());
225 self
226 }
227
228 pub fn bare_option(self, option: impl Into<String>) -> Self {
230 self.option(option, Value::Null)
231 }
232}
233
234#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
248#[serde(tag = "type", rename = "score")]
249pub struct Score {
250 #[serde(skip_serializing_if = "Option::is_none")]
252 pub instructions: Option<Value>,
253 pub criteria: Vec<Value>,
255}
256
257impl Score {
258 pub fn new<I>(instructions: impl Into<Value>, levels: I) -> Self
260 where
261 I: IntoIterator,
262 I::Item: Into<Value>,
263 {
264 Self {
265 instructions: Some(instructions.into()),
266 criteria: levels.into_iter().map(Into::into).collect(),
267 }
268 }
269
270 pub fn without_instructions<I>(levels: I) -> Self
272 where
273 I: IntoIterator,
274 I::Item: Into<Value>,
275 {
276 Self {
277 instructions: None,
278 criteria: levels.into_iter().map(Into::into).collect(),
279 }
280 }
281
282 pub fn instructions(mut self, instructions: impl Into<Value>) -> Self {
284 self.instructions = Some(instructions.into());
285 self
286 }
287}
288
289#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
300#[serde(tag = "type", rename_all = "lowercase")]
301#[non_exhaustive]
302pub enum Question {
303 Noul(Noul),
305 Choice(Choice),
307 Score(Score),
309}
310
311impl From<Noul> for Question {
312 fn from(q: Noul) -> Self {
313 Question::Noul(q)
314 }
315}
316
317impl From<Choice> for Question {
318 fn from(q: Choice) -> Self {
319 Question::Choice(q)
320 }
321}
322
323impl From<Score> for Question {
324 fn from(q: Score) -> Self {
325 Question::Score(q)
326 }
327}
328
329pub(super) fn validate(
330 state: &Value,
331 questions: &std::collections::BTreeMap<String, Question>,
332) -> Result<(), Error> {
333 if questions.is_empty() {
334 return Err(Error::InvalidRequest(
335 "At least one question is required.".into(),
336 ));
337 }
338 match state {
339 Value::String(_) | Value::Object(_) | Value::Array(_) => {}
340 Value::Number(_) => {
341 return Err(Error::InvalidRequest(
342 "state must be a JSON string, object, or array; numbers are rejected.".into(),
343 ))
344 }
345 Value::Bool(_) => {
346 return Err(Error::InvalidRequest(
347 "state must be a JSON string, object, or array; booleans are rejected.".into(),
348 ))
349 }
350 Value::Null => {
351 return Err(Error::InvalidRequest(
352 "state must be a JSON string, object, or array; null is rejected.".into(),
353 ))
354 }
355 }
356 for (name, question) in questions {
357 match question {
358 Question::Choice(choice) => {
359 if choice.criteria.is_empty() {
360 return Err(Error::InvalidRequest(format!(
361 "Choice question \"{name}\" has no options; at least one is required."
362 )));
363 }
364 }
365 Question::Score(score) => {
366 if score.criteria.len() < 2 {
367 return Err(Error::InvalidRequest(format!(
368 "Score question \"{name}\" has {} {}; at least two are required.",
369 score.criteria.len(),
370 if score.criteria.len() == 1 {
371 "criterion"
372 } else {
373 "criteria"
374 }
375 )));
376 }
377 if let Some(position) = score.criteria.iter().position(Value::is_null) {
378 return Err(Error::InvalidRequest(format!(
379 "Score question \"{name}\" has a null level at position {position}; levels must be strings, objects, or arrays."
380 )));
381 }
382 }
383 Question::Noul(_) => {}
384 }
385 }
386 Ok(())
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use serde_json::json;
393 use std::collections::BTreeMap;
394
395 #[test]
396 fn serializes_noul_with_omitted_optionals() {
397 let q = Noul::new("Is this spam?");
398 let v = serde_json::to_value(&q).unwrap();
399 assert_eq!(v, json!({"type": "noul", "instructions": "Is this spam?"}));
400 }
401
402 #[test]
403 fn serializes_noul_criteria_renames_and_omits() {
404 let q = Noul::new("Spam?").criteria(Some("Unsolicited"), None::<&str>);
405 let v = serde_json::to_value(&q).unwrap();
406 assert_eq!(
407 v,
408 json!({
409 "type": "noul",
410 "instructions": "Spam?",
411 "criteria": {"true": "Unsolicited"}
412 })
413 );
414 let q = Noul::without_instructions().criteria(None::<&str>, Some("Legit"));
415 let v = serde_json::to_value(&q).unwrap();
416 assert_eq!(v, json!({"type": "noul", "criteria": {"false": "Legit"}}));
417 }
418
419 #[test]
420 fn preserves_null_criteria_inside_noul() {
421 let q = Noul::new("Spam?").criteria(Some("Unsolicited"), Some(Value::Null));
422 let v = serde_json::to_value(&q).unwrap();
423 assert_eq!(v["criteria"], json!({"true": "Unsolicited", "false": null}));
424 }
425
426 #[test]
427 fn preserves_choice_option_order() {
428 let mut choice = Choice::new("Tone?")
429 .option("b", "Second")
430 .option("a", "First");
431 choice = choice.bare_option("z").option("m", json!({"x": 1}));
432 let v = serde_json::to_value(&choice).unwrap();
433 assert_eq!(
434 v["criteria"]
435 .as_object()
436 .unwrap()
437 .keys()
438 .collect::<Vec<_>>(),
439 vec!["b", "a", "z", "m"]
440 );
441 assert_eq!(v["criteria"]["z"], json!(null));
442 assert_eq!(v["criteria"]["m"], json!({"x": 1}));
443 }
444
445 #[test]
446 fn from_options_uses_null_descriptions_in_order() {
447 let q = Choice::from_options("Team?", ["billing", "tech", "sales"]);
448 let v = serde_json::to_value(&q).unwrap();
449 assert_eq!(
450 v["criteria"]
451 .as_object()
452 .unwrap()
453 .keys()
454 .collect::<Vec<_>>(),
455 vec!["billing", "tech", "sales"]
456 );
457 assert!(v["criteria"]["billing"].is_null());
458 }
459
460 #[test]
461 fn score_serializes_ordered_levels() {
462 let q = Score::new("Urgency?", ["Low", "High"]);
463 let v = serde_json::to_value(&q).unwrap();
464 assert_eq!(
465 v,
466 json!({"type": "score", "instructions": "Urgency?", "criteria": ["Low", "High"]})
467 );
468 }
469
470 #[test]
471 fn tag_serialization_round_trips() {
472 for question in [
473 Question::Noul(Noul::new("a")),
474 Question::Choice(Choice::new("b").option("x", "y")),
475 Question::Score(Score::new("c", ["1", "2"])),
476 ] {
477 let v = serde_json::to_value(&question).unwrap();
478 let back: Question = serde_json::from_value(v).unwrap();
479 assert_eq!(back, question);
480 }
481 }
482
483 #[test]
484 fn validation_rejects_bad_input() {
485 let mut questions = BTreeMap::new();
486 let state = json!("text");
488 assert!(validate(&state, &questions).is_err());
489 questions.insert("x".into(), Question::Noul(Noul::new("a")));
491 for bad in [json!(1), json!(true), json!(null)] {
492 let err = validate(&bad, &questions).unwrap_err();
493 assert!(matches!(err, Error::InvalidRequest(_)), "{err}");
494 }
495 questions.insert("c".into(), Question::Choice(Choice::new("c")));
497 let err = validate(&state, &questions).unwrap_err();
498 let msg = err.to_string();
499 assert!(msg.contains('c') && msg.contains("at least one"), "{msg}");
500 questions.remove("c");
502 questions.insert("s".into(), Question::Score(Score::new("s", ["only"])));
503 let err = validate(&state, &questions).unwrap_err();
504 assert!(err.to_string().contains("at least two"), "{err}");
505 let criteria: Vec<serde_json::Value> = vec!["a".into(), serde_json::Value::Null];
507 questions.insert(
508 "s".into(),
509 Question::Score(Score {
510 instructions: Some("s".into()),
511 criteria,
512 }),
513 );
514 let err = validate(&state, &questions).unwrap_err();
515 assert!(err.to_string().contains("null level"), "{err}");
516 }
517}