Skip to main content

typesafe_rs/types/
question.rs

1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3
4use crate::error::Error;
5use crate::types::entry::Entry;
6
7/// Maximum number of Choice options accepted by client-side validation.
8pub const MAX_CHOICE_OPTIONS: usize = 255;
9
10/// Ordered map of named questions, keyed as they should appear in answers.
11pub type Questions = IndexMap<String, Question>;
12
13/// A typed System One question.
14///
15/// Build with [`Question::noul`], [`Question::choice`], and [`Question::score`].
16/// Choice needs at least two [`option`](Self::option)s; Score needs at least two
17/// [`level`](Self::level)s. Validation runs before the request is sent.
18///
19/// # Examples
20///
21/// ```
22/// use typesafe_rs::Question;
23///
24/// let urgent = Question::noul("Does this convey urgency?")
25///     .when_true("Explicitly time-sensitive")
26///     .when_false("No time pressure");
27/// let team = Question::choice("Which team?")
28///     .option("billing", "Payments")
29///     .option("technical", "Bugs");
30/// let mood = Question::score("How frustrated?")
31///     .level("Calm")
32///     .level("Frustrated")
33///     .level("Very angry");
34/// # let _ = (urgent, team, mood);
35/// ```
36#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
37#[serde(tag = "type", rename_all = "lowercase")]
38pub enum Question {
39    /// Yes/no question; the answer is a probability in `[0, 1]`.
40    Noul {
41        /// The question to evaluate.
42        instructions: Entry,
43        /// Optional descriptions of the yes and no outcomes.
44        #[serde(skip_serializing_if = "Option::is_none")]
45        criteria: Option<NoulCriteria>,
46    },
47    /// Select one named option from a set.
48    Choice {
49        /// What the model should decide.
50        instructions: Entry,
51        /// Option labels mapped to descriptions (`null` if undescribed).
52        criteria: IndexMap<String, Entry>,
53    },
54    /// Rate the state against an ordered rubric.
55    Score {
56        /// What the model should rate.
57        instructions: Entry,
58        /// Level descriptions; index is the integer score.
59        criteria: Vec<Entry>,
60    },
61}
62
63/// Optional yes/no descriptions for a Noul question.
64#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
65pub struct NoulCriteria {
66    /// Description of a yes outcome (wire name `true`).
67    #[serde(rename = "true", skip_serializing_if = "Option::is_none")]
68    pub when_true: Option<Entry>,
69    /// Description of a no outcome (wire name `false`).
70    #[serde(rename = "false", skip_serializing_if = "Option::is_none")]
71    pub when_false: Option<Entry>,
72}
73
74impl Question {
75    /// Build a Noul question.
76    #[must_use]
77    pub fn noul(instructions: impl Into<Entry>) -> Self {
78        Self::Noul {
79            instructions: instructions.into(),
80            criteria: None,
81        }
82    }
83
84    /// Build a Choice question with no options yet.
85    #[must_use]
86    pub fn choice(instructions: impl Into<Entry>) -> Self {
87        Self::Choice {
88            instructions: instructions.into(),
89            criteria: IndexMap::new(),
90        }
91    }
92
93    /// Build a Score question with no levels yet.
94    #[must_use]
95    pub fn score(instructions: impl Into<Entry>) -> Self {
96        Self::Score {
97            instructions: instructions.into(),
98            criteria: Vec::new(),
99        }
100    }
101
102    /// Describe the yes outcome of a Noul question.
103    ///
104    /// No-op if this is not a Noul question.
105    #[must_use]
106    pub fn when_true(self, description: impl Into<Entry>) -> Self {
107        match self {
108            Self::Noul {
109                instructions,
110                criteria,
111            } => {
112                let mut criteria = criteria.unwrap_or_default();
113                criteria.when_true = Some(description.into());
114                Self::Noul {
115                    instructions,
116                    criteria: Some(criteria),
117                }
118            }
119            other => other,
120        }
121    }
122
123    /// Describe the no outcome of a Noul question.
124    ///
125    /// No-op if this is not a Noul question.
126    #[must_use]
127    pub fn when_false(self, description: impl Into<Entry>) -> Self {
128        match self {
129            Self::Noul {
130                instructions,
131                criteria,
132            } => {
133                let mut criteria = criteria.unwrap_or_default();
134                criteria.when_false = Some(description.into());
135                Self::Noul {
136                    instructions,
137                    criteria: Some(criteria),
138                }
139            }
140            other => other,
141        }
142    }
143
144    /// Add a named option to a Choice question.
145    ///
146    /// No-op if this is not a Choice question.
147    #[must_use]
148    pub fn option(self, key: impl Into<String>, description: impl Into<Entry>) -> Self {
149        match self {
150            Self::Choice {
151                instructions,
152                mut criteria,
153            } => {
154                criteria.insert(key.into(), description.into());
155                Self::Choice {
156                    instructions,
157                    criteria,
158                }
159            }
160            other => other,
161        }
162    }
163
164    /// Append a rubric level to a Score question.
165    ///
166    /// No-op if this is not a Score question.
167    #[must_use]
168    pub fn level(self, description: impl Into<Entry>) -> Self {
169        match self {
170            Self::Score {
171                instructions,
172                mut criteria,
173            } => {
174                criteria.push(description.into());
175                Self::Score {
176                    instructions,
177                    criteria,
178                }
179            }
180            other => other,
181        }
182    }
183}
184
185/// Validate a question map before sending it on the wire.
186pub fn validate_questions(questions: &Questions) -> Result<(), Error> {
187    if questions.is_empty() {
188        return Err(Error::InvalidRequest(
189            "At least one question is required.".to_owned(),
190        ));
191    }
192    for (name, question) in questions {
193        if name.is_empty() {
194            return Err(Error::InvalidRequest(
195                "question keys must be non-empty".to_owned(),
196            ));
197        }
198        match question {
199            Question::Choice { criteria, .. } => {
200                let n = criteria.len();
201                if n < 2 {
202                    return Err(Error::InvalidRequest(format!(
203                        "Choice question \"{name}\" has {n} options; at least 2 are required"
204                    )));
205                }
206                if n > MAX_CHOICE_OPTIONS {
207                    return Err(Error::InvalidRequest(format!(
208                        "Choice question \"{name}\" has {n} options; at most {MAX_CHOICE_OPTIONS} are allowed"
209                    )));
210                }
211            }
212            Question::Score { criteria, .. } => {
213                let n = criteria.len();
214                if n < 2 {
215                    return Err(Error::InvalidRequest(format!(
216                        "Score question \"{name}\" has {n} criteria; at least two scores are required."
217                    )));
218                }
219            }
220            Question::Noul { .. } => {}
221        }
222    }
223    Ok(())
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use serde_json::json;
230
231    #[test]
232    fn noul_serializes_with_criteria_keys() {
233        let q = Question::noul("Does this convey urgency?")
234            .when_true("Explicitly time-sensitive")
235            .when_false("No time pressure");
236        let value = serde_json::to_value(&q).unwrap();
237        assert_eq!(
238            value,
239            json!({
240                "type": "noul",
241                "instructions": "Does this convey urgency?",
242                "criteria": {
243                    "true": "Explicitly time-sensitive",
244                    "false": "No time pressure"
245                }
246            })
247        );
248    }
249
250    #[test]
251    fn noul_omits_empty_criteria() {
252        let q = Question::noul("yes?");
253        let value = serde_json::to_value(&q).unwrap();
254        assert_eq!(value.get("criteria"), None);
255    }
256
257    #[test]
258    fn choice_preserves_insertion_order() {
259        let q = Question::choice("Which team?")
260            .option("billing", "Payment issues")
261            .option("technical", "Bugs");
262        let value = serde_json::to_value(&q).unwrap();
263        let keys: Vec<_> = value["criteria"]
264            .as_object()
265            .unwrap()
266            .keys()
267            .cloned()
268            .collect();
269        assert_eq!(keys, ["billing", "technical"]);
270    }
271
272    #[test]
273    fn score_serializes_levels_as_array() {
274        let q = Question::score("How frustrated?")
275            .level("Calm")
276            .level("Frustrated")
277            .level("Very angry");
278        let value = serde_json::to_value(&q).unwrap();
279        assert_eq!(
280            value["criteria"],
281            json!(["Calm", "Frustrated", "Very angry"])
282        );
283    }
284
285    #[test]
286    fn rejects_empty_map() {
287        let q = Questions::new();
288        let err = validate_questions(&q).unwrap_err();
289        assert!(matches!(err, Error::InvalidRequest(_)));
290    }
291
292    #[test]
293    fn rejects_empty_key() {
294        let mut q = Questions::new();
295        q.insert(String::new(), Question::noul("x"));
296        let err = validate_questions(&q).unwrap_err();
297        assert!(format!("{err}").contains("non-empty"));
298    }
299
300    #[test]
301    fn rejects_choice_with_one_option() {
302        let mut q = Questions::new();
303        q.insert(
304            "dept".into(),
305            Question::choice("Which?").option("only", "one"),
306        );
307        let err = validate_questions(&q).unwrap_err();
308        assert!(format!("{err}").contains("at least 2"));
309    }
310
311    #[test]
312    fn rejects_choice_over_max_options() {
313        let mut criteria = IndexMap::new();
314        for i in 0..=MAX_CHOICE_OPTIONS {
315            criteria.insert(format!("k{i}"), Entry::from("d"));
316        }
317        let mut q = Questions::new();
318        q.insert(
319            "dept".into(),
320            Question::Choice {
321                instructions: Entry::from("Which?"),
322                criteria,
323            },
324        );
325        let err = validate_questions(&q).unwrap_err();
326        assert!(format!("{err}").contains("at most"));
327    }
328
329    #[test]
330    fn rejects_score_with_one_level() {
331        let mut q = Questions::new();
332        q.insert("s".into(), Question::score("rate").level("low"));
333        let err = validate_questions(&q).unwrap_err();
334        assert!(format!("{err}").contains("at least two"));
335    }
336
337    #[test]
338    fn accepts_valid_choice_and_score() {
339        let mut q = Questions::new();
340        q.insert(
341            "dept".into(),
342            Question::choice("Which?").option("a", "A").option("b", "B"),
343        );
344        q.insert(
345            "mood".into(),
346            Question::score("rate").level("low").level("high"),
347        );
348        validate_questions(&q).unwrap();
349    }
350}