Skip to main content

typesafe/
question.rs

1//! Questions: the typed judgments to ask about a state.
2
3use serde::ser::{Serialize, SerializeMap, Serializer};
4use serde_json::Value;
5
6/// A yes/no question. The answer, a [`NoulAnswer`](crate::NoulAnswer), is the
7/// probability that the answer is yes.
8///
9/// ```
10/// use typesafe::Noul;
11///
12/// let lights = Noul::new("If the user wants to change the lights, do they want them on?")
13///     .yes("Lights on or brighter")
14///     .no("Lights off or dimmer");
15/// ```
16#[derive(Clone, Debug, PartialEq)]
17pub struct Noul {
18    instructions: Value,
19    yes: Option<Value>,
20    no: Option<Value>,
21}
22
23impl Noul {
24    /// `instructions` is a string, or a JSON object or array when structure
25    /// makes the question clearer.
26    pub fn new(instructions: impl Into<Value>) -> Self {
27        Noul {
28            instructions: instructions.into(),
29            yes: None,
30            no: None,
31        }
32    }
33
34    /// What a yes means.
35    pub fn yes(mut self, description: impl Into<Value>) -> Self {
36        self.yes = Some(description.into());
37        self
38    }
39
40    /// What a no means.
41    pub fn no(mut self, description: impl Into<Value>) -> Self {
42        self.no = Some(description.into());
43        self
44    }
45}
46
47/// A question that selects one option from a defined set. The answer is a
48/// [`ChoiceAnswer`](crate::ChoiceAnswer).
49///
50/// Options are sent in the order they were added, because the order is part of
51/// what the model reads. Include a no-match option when nothing may fit: the
52/// model always picks one of the options given.
53///
54/// ```
55/// use typesafe::Choice;
56///
57/// let team = Choice::new("Which team should own this ticket?")
58///     .option("billing", "Charges, invoices, refunds")
59///     .option("technical", "Bugs, outages, integrations")
60///     .option("unclear", "The ticket does not say enough to tell");
61///
62/// let tone = Choice::new("What is the customer's tone?").options(["calm", "frustrated", "angry"]);
63/// ```
64#[derive(Clone, Debug, PartialEq)]
65pub struct Choice {
66    instructions: Value,
67    options: Vec<(String, Value)>,
68}
69
70impl Choice {
71    /// `instructions` is a string, or a JSON object or array when structure
72    /// makes the question clearer.
73    pub fn new(instructions: impl Into<Value>) -> Self {
74        Choice {
75            instructions: instructions.into(),
76            options: Vec::new(),
77        }
78    }
79
80    /// Adds an option with a description of when it applies.
81    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    /// Adds options without descriptions, in order.
87    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    /// The option names, in the order they are sent.
98    pub fn option_names(&self) -> impl Iterator<Item = &str> {
99        self.options.iter().map(|(name, _)| name.as_str())
100    }
101}
102
103/// A question that rates the state against ordered levels, worst or least
104/// first. The answer is a [`ScoreAnswer`](crate::ScoreAnswer).
105///
106/// Each level should describe a concrete situation on its own:
107///
108/// ```
109/// use typesafe::Score;
110///
111/// let urgency = Score::new(
112///     "How urgently does this ticket need a response?",
113///     [
114///         "Can wait: a question or feedback, nothing is blocked",
115///         "Soon: a problem with a workaround",
116///         "Today: something important is broken for the customer",
117///     ],
118/// );
119/// ```
120#[derive(Clone, Debug, PartialEq)]
121pub struct Score {
122    instructions: Value,
123    levels: Vec<Value>,
124}
125
126impl Score {
127    /// At least two levels, worst or least first.
128    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/// Any one question.
141///
142/// [`Question::Raw`] holds a question as its JSON, sent as given. Its answer
143/// comes back as [`Answer::Raw`](crate::Answer::Raw). That covers question types
144/// this client predates.
145#[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/// The questions for one call, each under an id. Answers come back under the
173/// same ids.
174///
175/// Ids are never sent to the model as meaning, so each question has to carry
176/// its own. Questions in one call share one read of the state and are answered
177/// in parallel, without seeing each other's answers.
178///
179/// ```
180/// use typesafe::{Choice, Noul, Questions};
181///
182/// let questions = Questions::new()
183///     .ask("billing", Noul::new("Is this ticket about billing?"))
184///     .ask("tone", Choice::new("What is the customer's tone?").options(["calm", "angry"]));
185/// assert_eq!(questions.len(), 2);
186/// ```
187#[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    /// Adds a question, builder style.
196    pub fn ask(mut self, id: impl Into<String>, question: impl Into<Question>) -> Self {
197        self.insert(id, question);
198        self
199    }
200
201    /// Adds a question, for building in a loop.
202    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    /// Checks the questions before anything is sent. The error message names
224    /// the question at fault.
225    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
331// Wire encoding. Questions and Choice options are written as JSON objects in
332// insertion order, which a serde_json::Value would sort.
333
334impl 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}