1use serde::ser::{Serialize, SerializeMap, Serializer};
4use serde_json::Value;
5
6#[derive(Clone, Debug, PartialEq)]
17pub struct Noul {
18 instructions: Value,
19 yes: Option<Value>,
20 no: Option<Value>,
21}
22
23impl Noul {
24 pub fn new(instructions: impl Into<Value>) -> Self {
27 Noul {
28 instructions: instructions.into(),
29 yes: None,
30 no: None,
31 }
32 }
33
34 pub fn yes(mut self, description: impl Into<Value>) -> Self {
36 self.yes = Some(description.into());
37 self
38 }
39
40 pub fn no(mut self, description: impl Into<Value>) -> Self {
42 self.no = Some(description.into());
43 self
44 }
45}
46
47#[derive(Clone, Debug, PartialEq)]
65pub struct Choice {
66 instructions: Value,
67 options: Vec<(String, Value)>,
68}
69
70impl Choice {
71 pub fn new(instructions: impl Into<Value>) -> Self {
74 Choice {
75 instructions: instructions.into(),
76 options: Vec::new(),
77 }
78 }
79
80 pub fn option(mut self, name: impl Into<String>, description: impl Into<Value>) -> Self {
82 self.options.push((name.into(), description.into()));
83 self
84 }
85
86 pub fn options<I, S>(mut self, names: I) -> Self
88 where
89 I: IntoIterator<Item = S>,
90 S: Into<String>,
91 {
92 self.options
93 .extend(names.into_iter().map(|name| (name.into(), Value::Null)));
94 self
95 }
96
97 pub fn option_names(&self) -> impl Iterator<Item = &str> {
99 self.options.iter().map(|(name, _)| name.as_str())
100 }
101}
102
103#[derive(Clone, Debug, PartialEq)]
121pub struct Score {
122 instructions: Value,
123 levels: Vec<Value>,
124}
125
126impl Score {
127 pub fn new<I, L>(instructions: impl Into<Value>, levels: I) -> Self
129 where
130 I: IntoIterator<Item = L>,
131 L: Into<Value>,
132 {
133 Score {
134 instructions: instructions.into(),
135 levels: levels.into_iter().map(Into::into).collect(),
136 }
137 }
138}
139
140#[derive(Clone, Debug, PartialEq)]
146#[non_exhaustive]
147pub enum Question {
148 Noul(Noul),
149 Choice(Choice),
150 Score(Score),
151 Raw(Value),
152}
153
154impl From<Noul> for Question {
155 fn from(q: Noul) -> Self {
156 Question::Noul(q)
157 }
158}
159
160impl From<Choice> for Question {
161 fn from(q: Choice) -> Self {
162 Question::Choice(q)
163 }
164}
165
166impl From<Score> for Question {
167 fn from(q: Score) -> Self {
168 Question::Score(q)
169 }
170}
171
172#[derive(Clone, Debug, Default, PartialEq)]
188pub struct Questions(Vec<(String, Question)>);
189
190impl Questions {
191 pub fn new() -> Self {
192 Questions(Vec::new())
193 }
194
195 pub fn ask(mut self, id: impl Into<String>, question: impl Into<Question>) -> Self {
197 self.insert(id, question);
198 self
199 }
200
201 pub fn insert(&mut self, id: impl Into<String>, question: impl Into<Question>) -> &mut Self {
203 self.0.push((id.into(), question.into()));
204 self
205 }
206
207 pub fn len(&self) -> usize {
208 self.0.len()
209 }
210
211 pub fn is_empty(&self) -> bool {
212 self.0.is_empty()
213 }
214
215 pub fn get(&self, id: &str) -> Option<&Question> {
216 self.0.iter().find(|(k, _)| k == id).map(|(_, q)| q)
217 }
218
219 pub fn iter(&self) -> impl Iterator<Item = (&str, &Question)> {
220 self.0.iter().map(|(k, q)| (k.as_str(), q))
221 }
222
223 pub(crate) fn validate(&self) -> Result<(), String> {
226 if self.0.is_empty() {
227 return Err("questions must not be empty".into());
228 }
229 for (i, (id, question)) in self.0.iter().enumerate() {
230 if id.is_empty() {
231 return Err("question ids must not be empty".into());
232 }
233 if self.0[..i].iter().any(|(other, _)| other == id) {
234 return Err(format!("question ids must be unique, got {id:?} twice"));
235 }
236 validate_question(question).map_err(|problem| format!("question {id:?}: {problem}"))?;
237 }
238 Ok(())
239 }
240}
241
242impl<K: Into<String>, Q: Into<Question>> FromIterator<(K, Q)> for Questions {
243 fn from_iter<T: IntoIterator<Item = (K, Q)>>(iter: T) -> Self {
244 Questions(
245 iter.into_iter()
246 .map(|(k, q)| (k.into(), q.into()))
247 .collect(),
248 )
249 }
250}
251
252impl<K: Into<String>, Q: Into<Question>> Extend<(K, Q)> for Questions {
253 fn extend<T: IntoIterator<Item = (K, Q)>>(&mut self, iter: T) {
254 self.0
255 .extend(iter.into_iter().map(|(k, q)| (k.into(), q.into())));
256 }
257}
258
259fn validate_question(question: &Question) -> Result<(), String> {
260 match question {
261 Question::Noul(q) => {
262 check_instructions(&q.instructions)?;
263 for description in q.yes.iter().chain(&q.no) {
264 check_description(description, "Noul criteria")?;
265 }
266 }
267 Question::Choice(q) => {
268 check_instructions(&q.instructions)?;
269 if q.options.is_empty() {
270 return Err("a Choice needs at least one option".into());
271 }
272 for (i, (name, description)) in q.options.iter().enumerate() {
273 if name.is_empty() {
274 return Err("Choice options must be non-empty strings".into());
275 }
276 if q.options[..i].iter().any(|(other, _)| other == name) {
277 return Err(format!("Choice options must be unique, got {name:?} twice"));
278 }
279 check_description(description, "Choice option")?;
280 }
281 }
282 Question::Score(q) => {
283 check_instructions(&q.instructions)?;
284 if q.levels.len() < 2 {
285 return Err(format!(
286 "a Score needs at least two levels, got {}",
287 q.levels.len()
288 ));
289 }
290 for level in &q.levels {
291 if level.is_null() {
292 return Err("Score levels must describe something, got null".into());
293 }
294 check_description(level, "Score level")?;
295 }
296 }
297 Question::Raw(raw) => {
298 if !raw.get("type").is_some_and(Value::is_string) {
299 return Err("a raw question must be a JSON object with a \"type\" string".into());
300 }
301 }
302 }
303 Ok(())
304}
305
306fn check_instructions(instructions: &Value) -> Result<(), String> {
307 let valid = match instructions {
308 Value::String(s) => !s.trim().is_empty(),
309 Value::Object(o) => !o.is_empty(),
310 Value::Array(a) => !a.is_empty(),
311 _ => false,
312 };
313 if valid {
314 Ok(())
315 } else {
316 Err(format!(
317 "instructions must be a non-empty string, object or array, got {instructions}"
318 ))
319 }
320}
321
322fn check_description(description: &Value, what: &str) -> Result<(), String> {
323 match description {
324 Value::String(_) | Value::Object(_) | Value::Array(_) | Value::Null => Ok(()),
325 other => Err(format!(
326 "{what} descriptions must be strings, objects, arrays or null, got {other}"
327 )),
328 }
329}
330
331impl Serialize for Questions {
335 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
336 let mut map = serializer.serialize_map(Some(self.0.len()))?;
337 for (id, question) in &self.0 {
338 map.serialize_entry(id, question)?;
339 }
340 map.end()
341 }
342}
343
344impl Serialize for Question {
345 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
346 match self {
347 Question::Noul(q) => {
348 let criteria = q.yes.is_some() || q.no.is_some();
349 let mut map = serializer.serialize_map(Some(2 + criteria as usize))?;
350 map.serialize_entry("type", "noul")?;
351 map.serialize_entry("instructions", &q.instructions)?;
352 if criteria {
353 map.serialize_entry("criteria", &NoulCriteria(q))?;
354 }
355 map.end()
356 }
357 Question::Choice(q) => {
358 let mut map = serializer.serialize_map(Some(3))?;
359 map.serialize_entry("type", "choice")?;
360 map.serialize_entry("instructions", &q.instructions)?;
361 map.serialize_entry("criteria", &OrderedOptions(&q.options))?;
362 map.end()
363 }
364 Question::Score(q) => {
365 let mut map = serializer.serialize_map(Some(3))?;
366 map.serialize_entry("type", "score")?;
367 map.serialize_entry("instructions", &q.instructions)?;
368 map.serialize_entry("criteria", &q.levels)?;
369 map.end()
370 }
371 Question::Raw(raw) => raw.serialize(serializer),
372 }
373 }
374}
375
376struct NoulCriteria<'a>(&'a Noul);
377
378impl Serialize for NoulCriteria<'_> {
379 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
380 let mut map = serializer.serialize_map(None)?;
381 if let Some(yes) = &self.0.yes {
382 map.serialize_entry("true", yes)?;
383 }
384 if let Some(no) = &self.0.no {
385 map.serialize_entry("false", no)?;
386 }
387 map.end()
388 }
389}
390
391struct OrderedOptions<'a>(&'a [(String, Value)]);
392
393impl Serialize for OrderedOptions<'_> {
394 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
395 let mut map = serializer.serialize_map(Some(self.0.len()))?;
396 for (name, description) in self.0 {
397 map.serialize_entry(name, description)?;
398 }
399 map.end()
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use serde_json::json;
407
408 fn wire(questions: &Questions) -> String {
409 serde_json::to_string(questions).unwrap()
410 }
411
412 #[test]
413 fn encodes_each_type() {
414 let questions = Questions::new()
415 .ask("b", Noul::new("Is it billing?"))
416 .ask("l", Noul::new("On?").yes("Lights on").no("Lights off"))
417 .ask("t", Choice::new("Tone?").options(["calm", "angry"]))
418 .ask("u", Score::new("Urgent?", ["Can wait", "Today"]))
419 .ask("r", Question::Raw(json!({"type": "future", "x": 1})));
420 assert_eq!(
421 serde_json::from_str::<Value>(&wire(&questions)).unwrap(),
422 json!({
423 "b": {"type": "noul", "instructions": "Is it billing?"},
424 "l": {"type": "noul", "instructions": "On?",
425 "criteria": {"true": "Lights on", "false": "Lights off"}},
426 "t": {"type": "choice", "instructions": "Tone?",
427 "criteria": {"calm": null, "angry": null}},
428 "u": {"type": "score", "instructions": "Urgent?", "criteria": ["Can wait", "Today"]},
429 "r": {"type": "future", "x": 1},
430 })
431 );
432 }
433
434 #[test]
435 fn keeps_choice_option_order() {
436 let questions = Questions::new().ask(
437 "q",
438 Choice::new("Which?")
439 .option("zebra", "last alphabetically")
440 .options(["mango", "apple"]),
441 );
442 assert_eq!(
443 wire(&questions),
444 r#"{"q":{"type":"choice","instructions":"Which?","criteria":{"zebra":"last alphabetically","mango":null,"apple":null}}}"#
445 );
446 }
447
448 #[test]
449 fn structured_instructions_and_descriptions() {
450 let questions = Questions::new().ask(
451 "q",
452 Choice::new(json!({"task": "Pick one", "rules": ["be literal"]}))
453 .option("a", json!({"when": "always"})),
454 );
455 assert!(questions.validate().is_ok());
456 assert!(wire(&questions).contains(r#""criteria":{"a":{"when":"always"}}"#));
457 }
458
459 #[test]
460 fn only_given_noul_criteria_are_sent() {
461 let questions = Questions::new().ask("q", Noul::new("Is it?").yes("It is"));
462 assert!(wire(&questions).contains(r#""criteria":{"true":"It is"}"#));
463 }
464
465 #[test]
466 fn validation_names_the_problem() {
467 let problem = |questions: Questions| questions.validate().unwrap_err();
468
469 assert_eq!(problem(Questions::new()), "questions must not be empty");
470 assert!(problem(Questions::new().ask("q", Noul::new(" "))).contains("instructions"));
471 assert!(problem(Questions::new().ask("q", Noul::new(3))).contains("instructions"));
472 assert!(
473 problem(Questions::new().ask("q", Choice::new("Which?")))
474 .contains("at least one option")
475 );
476 assert!(
477 problem(Questions::new().ask("q", Choice::new("Which?").options(["a", "a"])))
478 .contains("unique")
479 );
480 assert!(
481 problem(Questions::new().ask("q", Choice::new("Which?").option("a", 1)))
482 .contains("descriptions")
483 );
484 assert!(
485 problem(Questions::new().ask("q", Score::new("How?", ["only one"])))
486 .contains("at least two levels")
487 );
488 assert!(
489 problem(Questions::new().ask("q", Question::Raw(json!({"no": "type"}))))
490 .contains("\"type\"")
491 );
492 let dup = Questions::new()
493 .ask("q", Noul::new("One?"))
494 .ask("q", Noul::new("Two?"));
495 assert!(problem(dup).contains("unique"));
496 assert!(
497 Questions::new()
498 .ask("q", Noul::new("Is it?"))
499 .validate()
500 .is_ok()
501 );
502 }
503
504 #[test]
505 fn collects_from_pairs() {
506 let questions: Questions = (1..=3)
507 .map(|i| {
508 (
509 format!("claim_{i}"),
510 Noul::new(format!("Is claim {i} supported?")),
511 )
512 })
513 .collect();
514 assert_eq!(questions.len(), 3);
515 assert!(questions.get("claim_2").is_some());
516 assert!(questions.validate().is_ok());
517 }
518}