1use std::fmt;
4use std::marker::PhantomData;
5
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9
10use crate::answers::{AnswerKind, ChoiceAnswer, NoulAnswer, ScoreAnswer, TypedChoiceAnswer};
11use crate::error::{Error, Result};
12use crate::labels::Labels;
13use crate::types::Entry;
14
15pub const MAX_CHOICE_OPTIONS: usize = 255;
21
22pub const MAX_SCORE_LEVELS: usize = 10;
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "lowercase")]
30pub enum Question {
31 Noul(NoulQuestion),
33 Choice(ChoiceQuestion),
35 Score(ScoreQuestion),
37 #[serde(untagged)]
54 Raw(Value),
55}
56
57impl Question {
58 pub fn raw(question: impl Into<Value>) -> Self {
60 Question::Raw(question.into())
61 }
62}
63
64#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
66pub struct NoulQuestion {
67 #[serde(default)]
69 pub instructions: Entry,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub criteria: Option<NoulCriteria>,
73 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
75 pub extra: Map<String, Value>,
76}
77
78impl NoulQuestion {
79 pub fn extra(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
84 self.extra.insert(key.into(), value.into());
85 self
86 }
87}
88
89#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
91pub struct NoulCriteria {
92 #[serde(rename = "true", default, skip_serializing_if = "Option::is_none")]
94 pub when_true: Option<Entry>,
95 #[serde(rename = "false", default, skip_serializing_if = "Option::is_none")]
97 pub when_false: Option<Entry>,
98}
99
100#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
102pub struct ChoiceQuestion {
103 #[serde(default)]
105 pub instructions: Entry,
106 pub criteria: IndexMap<String, Entry>,
108 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
110 pub extra: Map<String, Value>,
111}
112
113impl ChoiceQuestion {
114 pub fn extra(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
116 self.extra.insert(key.into(), value.into());
117 self
118 }
119}
120
121#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
123pub struct ScoreQuestion {
124 #[serde(default)]
126 pub instructions: Entry,
127 pub criteria: Vec<Entry>,
129 #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
131 pub extra: Map<String, Value>,
132}
133
134impl ScoreQuestion {
135 pub fn extra(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
137 self.extra.insert(key.into(), value.into());
138 self
139 }
140}
141
142impl NoulQuestion {
143 pub fn when_true(mut self, description: impl Into<Entry>) -> Self {
145 self.criteria.get_or_insert_default().when_true = Some(description.into());
146 self
147 }
148
149 pub fn when_false(mut self, description: impl Into<Entry>) -> Self {
151 self.criteria.get_or_insert_default().when_false = Some(description.into());
152 self
153 }
154}
155
156pub fn noul(instructions: impl Into<Entry>) -> NoulQuestion {
160 NoulQuestion {
161 instructions: instructions.into(),
162 criteria: None,
163 extra: Map::new(),
164 }
165}
166
167pub fn choice<K, V>(
179 instructions: impl Into<Entry>,
180 criteria: impl IntoIterator<Item = (K, V)>,
181) -> ChoiceQuestion
182where
183 K: Into<String>,
184 V: Into<Entry>,
185{
186 ChoiceQuestion {
187 instructions: instructions.into(),
188 criteria: criteria
189 .into_iter()
190 .map(|(label, description)| (label.into(), description.into()))
191 .collect(),
192 extra: Map::new(),
193 }
194}
195
196pub fn choice_labels<K: Into<String>>(
198 instructions: impl Into<Entry>,
199 labels: impl IntoIterator<Item = K>,
200) -> ChoiceQuestion {
201 choice(
202 instructions,
203 labels.into_iter().map(|label| (label, Entry::Null)),
204 )
205}
206
207pub struct TypedChoice<L> {
211 question: ChoiceQuestion,
212 labels: PhantomData<fn() -> L>,
213}
214
215impl<L> TypedChoice<L> {
216 pub fn question(&self) -> &ChoiceQuestion {
218 &self.question
219 }
220}
221
222impl<L> Clone for TypedChoice<L> {
223 fn clone(&self) -> Self {
224 Self {
225 question: self.question.clone(),
226 labels: PhantomData,
227 }
228 }
229}
230
231impl<L> PartialEq for TypedChoice<L> {
232 fn eq(&self, other: &Self) -> bool {
233 self.question == other.question
234 }
235}
236
237impl<L> fmt::Debug for TypedChoice<L> {
238 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239 f.debug_tuple("TypedChoice").field(&self.question).finish()
240 }
241}
242
243pub fn choice_of<L: Labels>(instructions: impl Into<Entry>) -> TypedChoice<L> {
259 TypedChoice {
260 question: choice(
261 instructions,
262 L::ALL
263 .iter()
264 .map(|(_, label, description)| (*label, description.map(Entry::from))),
265 ),
266 labels: PhantomData,
267 }
268}
269
270impl<L: Labels> From<TypedChoice<L>> for Question {
271 fn from(question: TypedChoice<L>) -> Self {
272 Question::Choice(question.question)
273 }
274}
275
276impl<L: Labels> QuestionKind for TypedChoice<L> {
277 type Answer = TypedChoiceAnswer<L>;
278}
279
280pub fn score<V: Into<Entry>>(
282 instructions: impl Into<Entry>,
283 levels: impl IntoIterator<Item = V>,
284) -> ScoreQuestion {
285 ScoreQuestion {
286 instructions: instructions.into(),
287 criteria: levels.into_iter().map(Into::into).collect(),
288 extra: Map::new(),
289 }
290}
291
292impl From<NoulQuestion> for Question {
293 fn from(question: NoulQuestion) -> Self {
294 Question::Noul(question)
295 }
296}
297
298impl From<ChoiceQuestion> for Question {
299 fn from(question: ChoiceQuestion) -> Self {
300 Question::Choice(question)
301 }
302}
303
304impl From<ScoreQuestion> for Question {
305 fn from(question: ScoreQuestion) -> Self {
306 Question::Score(question)
307 }
308}
309
310pub trait QuestionKind: Into<Question> {
312 type Answer: AnswerKind;
314}
315
316impl QuestionKind for NoulQuestion {
317 type Answer = NoulAnswer;
318}
319
320impl QuestionKind for ChoiceQuestion {
321 type Answer = ChoiceAnswer;
322}
323
324impl QuestionKind for ScoreQuestion {
325 type Answer = ScoreAnswer;
326}
327
328pub struct AnswerKey<A> {
333 name: String,
334 kind: PhantomData<fn() -> A>,
335}
336
337impl<A> AnswerKey<A> {
338 pub fn new(name: impl Into<String>) -> Self {
340 Self {
341 name: name.into(),
342 kind: PhantomData,
343 }
344 }
345
346 pub fn name(&self) -> &str {
348 &self.name
349 }
350}
351
352impl<A> Clone for AnswerKey<A> {
353 fn clone(&self) -> Self {
354 Self::new(self.name.clone())
355 }
356}
357
358impl<A: AnswerKind> fmt::Debug for AnswerKey<A> {
359 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
360 f.debug_struct("AnswerKey")
361 .field("name", &self.name)
362 .field("kind", &A::TYPE)
363 .finish()
364 }
365}
366
367#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
369#[serde(transparent)]
370pub struct Questions(IndexMap<String, Question>);
371
372impl Questions {
373 pub fn new() -> Self {
375 Self::default()
376 }
377
378 pub fn add<Q: QuestionKind>(
382 &mut self,
383 name: impl Into<String>,
384 question: Q,
385 ) -> AnswerKey<Q::Answer> {
386 let name = name.into();
387 self.0.insert(name.clone(), question.into());
388 AnswerKey::new(name)
389 }
390
391 pub fn insert(
393 &mut self,
394 name: impl Into<String>,
395 question: impl Into<Question>,
396 ) -> Option<Question> {
397 self.0.insert(name.into(), question.into())
398 }
399
400 pub fn get(&self, name: &str) -> Option<&Question> {
402 self.0.get(name)
403 }
404
405 pub fn len(&self) -> usize {
407 self.0.len()
408 }
409
410 pub fn is_empty(&self) -> bool {
412 self.0.is_empty()
413 }
414
415 pub fn iter(&self) -> impl Iterator<Item = (&str, &Question)> {
417 self.0
418 .iter()
419 .map(|(name, question)| (name.as_str(), question))
420 }
421
422 pub fn validate(&self) -> Result<()> {
428 if self.0.is_empty() {
429 return Err(Error::InvalidRequest(
430 "At least one question is required.".into(),
431 ));
432 }
433 for (name, question) in &self.0 {
434 match question {
435 Question::Score(score) if score.criteria.len() < 2 => {
436 return Err(Error::InvalidRequest(format!(
437 "Score question \"{name}\" has {} criteria; at least two scores are required.",
438 score.criteria.len()
439 )));
440 }
441 Question::Score(score) if score.criteria.len() > MAX_SCORE_LEVELS => {
442 return Err(Error::InvalidRequest(format!(
443 "Score question \"{name}\" has {} criteria; at most {MAX_SCORE_LEVELS} are accepted.",
444 score.criteria.len()
445 )));
446 }
447 Question::Choice(choice) if choice.criteria.is_empty() => {
448 return Err(Error::InvalidRequest(format!(
449 "Choice question \"{name}\" has no options."
450 )));
451 }
452 Question::Choice(choice) if choice.criteria.len() > MAX_CHOICE_OPTIONS => {
453 return Err(Error::InvalidRequest(format!(
454 "Choice question \"{name}\" has {} options; at most {MAX_CHOICE_OPTIONS} are accepted.",
455 choice.criteria.len()
456 )));
457 }
458 _ => {}
459 }
460 }
461 Ok(())
462 }
463}
464
465impl<K: Into<String>, Q: Into<Question>> FromIterator<(K, Q)> for Questions {
466 fn from_iter<I: IntoIterator<Item = (K, Q)>>(iter: I) -> Self {
467 Self(
468 iter.into_iter()
469 .map(|(name, question)| (name.into(), question.into()))
470 .collect(),
471 )
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use serde_json::json;
479
480 #[test]
481 fn builders_serialize_to_the_wire_shape() {
482 let mut questions = Questions::new();
483 questions.add("urgent", noul("Does this message express urgency?"));
484 questions.add("bare", noul(Entry::Null));
485 questions.add(
486 "described",
487 noul("Refund?").when_true("asks for money back"),
488 );
489 questions.add(
490 "department",
491 choice(
492 "Which team?",
493 [("billing", Some("Payments")), ("other", None)],
494 ),
495 );
496 questions.add("tone", choice_labels("Tone?", ["calm", "angry"]));
497 questions.add(
498 "frustration",
499 score("How frustrated?", ["calm", "civil", "angry"]),
500 );
501
502 assert_eq!(
503 serde_json::to_value(&questions).unwrap(),
504 json!({
505 "urgent": {"type": "noul", "instructions": "Does this message express urgency?"},
506 "bare": {"type": "noul", "instructions": null},
507 "described": {
508 "type": "noul",
509 "instructions": "Refund?",
510 "criteria": {"true": "asks for money back"}
511 },
512 "department": {
513 "type": "choice",
514 "instructions": "Which team?",
515 "criteria": {"billing": "Payments", "other": null}
516 },
517 "tone": {
518 "type": "choice",
519 "instructions": "Tone?",
520 "criteria": {"calm": null, "angry": null}
521 },
522 "frustration": {
523 "type": "score",
524 "instructions": "How frustrated?",
525 "criteria": ["calm", "civil", "angry"]
526 }
527 })
528 );
529 }
530
531 #[test]
532 fn typed_choices_send_labels_and_descriptions_in_order() {
533 crate::labels! {
534 enum Team {
535 Billing = "billing": "Payments",
536 Other = "other",
537 }
538 }
539 let mut questions = Questions::new();
540 let key = questions.add("team", choice_of::<Team>("Which team?"));
541 assert_eq!(key.name(), "team");
542 assert_eq!(
543 serde_json::to_value(&questions).unwrap(),
544 json!({"team": {
545 "type": "choice",
546 "instructions": "Which team?",
547 "criteria": {"billing": "Payments", "other": null}
548 }})
549 );
550 }
551
552 #[test]
555 fn unmodelled_fields_and_types_are_sent_as_written() {
556 let mut questions = Questions::new();
557 questions.add("weighted", noul("Urgent?").extra("weight", 2));
558 questions.add(
559 "tone",
560 choice_labels("Tone?", ["calm", "angry"]).extra("beam_width", 4),
561 );
562 questions.add(
563 "level",
564 score("How bad?", ["low", "high"]).extra("strict", true),
565 );
566 questions.insert(
567 "ranking",
568 Question::raw(json!({"type": "rank", "criteria": ["a", "b"]})),
569 );
570
571 assert_eq!(
572 serde_json::to_value(&questions).unwrap(),
573 json!({
574 "weighted": {"type": "noul", "instructions": "Urgent?", "weight": 2},
575 "tone": {
576 "type": "choice",
577 "instructions": "Tone?",
578 "criteria": {"calm": null, "angry": null},
579 "beam_width": 4
580 },
581 "level": {
582 "type": "score",
583 "instructions": "How bad?",
584 "criteria": ["low", "high"],
585 "strict": true
586 },
587 "ranking": {"type": "rank", "criteria": ["a", "b"]}
588 })
589 );
590 assert!(questions.validate().is_ok());
592 }
593
594 #[test]
595 fn preserves_insertion_order() {
596 let questions: Questions = [("z", noul("z")), ("a", noul("a")), ("m", noul("m"))]
597 .into_iter()
598 .collect();
599 let names: Vec<_> = questions.iter().map(|(name, _)| name).collect();
600 assert_eq!(names, ["z", "a", "m"]);
601 let wire = serde_json::to_string(&questions).unwrap();
602 assert!(wire.find("\"z\"").unwrap() < wire.find("\"a\"").unwrap());
603 }
604
605 #[test]
606 fn validation_rejects_what_the_api_documents_as_invalid() {
607 let mut wide = Questions::new();
610 wide.add(
611 "team",
612 choice_labels(
613 "Which team?",
614 (0..=MAX_CHOICE_OPTIONS).map(|n| format!("team-{n}")),
615 ),
616 );
617 let err = wide.validate().unwrap_err();
618 assert_eq!(
619 err.to_string(),
620 "Choice question \"team\" has 256 options; at most 255 are accepted."
621 );
622
623 let mut exact = Questions::new();
624 exact.add(
625 "team",
626 choice_labels(
627 "Which team?",
628 (0..MAX_CHOICE_OPTIONS).map(|n| format!("team-{n}")),
629 ),
630 );
631 assert!(exact.validate().is_ok(), "255 options are allowed");
632
633 let mut empty_choice = Questions::new();
634 empty_choice.add("team", choice_labels("Which team?", Vec::<String>::new()));
635 assert_eq!(
636 empty_choice.validate().unwrap_err().to_string(),
637 "Choice question \"team\" has no options."
638 );
639
640 let mut deep = Questions::new();
641 deep.add(
642 "level",
643 score(
644 "How bad?",
645 (0..=MAX_SCORE_LEVELS).map(|n| format!("level {n}")),
646 ),
647 );
648 assert_eq!(
649 deep.validate().unwrap_err().to_string(),
650 "Score question \"level\" has 11 criteria; at most 10 are accepted."
651 );
652
653 let mut ten = Questions::new();
654 ten.add(
655 "level",
656 score(
657 "How bad?",
658 (0..MAX_SCORE_LEVELS).map(|n| format!("level {n}")),
659 ),
660 );
661 assert!(ten.validate().is_ok(), "10 levels are allowed");
662 }
663
664 #[test]
665 fn validation_rejects_empty_sets_and_short_rubrics() {
666 let err = Questions::new().validate().unwrap_err();
667 assert_eq!(err.to_string(), "At least one question is required.");
668
669 let mut questions = Questions::new();
670 questions.add("level", score("How?", ["only one"]));
671 assert_eq!(
672 questions.validate().unwrap_err().to_string(),
673 "Score question \"level\" has 1 criteria; at least two scores are required."
674 );
675
676 let mut ok = Questions::new();
677 ok.add("level", score("How?", ["low", "high"]));
678 assert!(ok.validate().is_ok());
679 }
680
681 #[test]
682 fn adding_a_duplicate_name_replaces_the_question() {
683 let mut questions = Questions::new();
684 questions.add("q", noul("first"));
685 let key = questions.add("q", choice_labels("second", ["a", "b"]));
686 assert_eq!(questions.len(), 1);
687 assert!(matches!(
688 questions.get(key.name()),
689 Some(Question::Choice(_))
690 ));
691 }
692
693 #[test]
694 fn questions_round_trip_through_json() {
695 let mut questions = Questions::new();
696 questions.add("n", noul("x").when_false("no"));
697 questions.add("s", score(Entry::Null, ["a", "b"]));
698 let json = serde_json::to_value(&questions).unwrap();
699 let back: Questions = serde_json::from_value(json).unwrap();
700 assert_eq!(back, questions);
701 }
702}