typesafe_systemone/
question.rs1use std::collections::BTreeMap;
2
3use serde::Serialize;
4use serde_json::Value;
5
6pub const NONE_OF_THE_ABOVE: &str = "none_of_the_above";
9
10pub const MAX_CHOICE_OPTIONS: usize = 255;
12
13#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
15pub struct NoulCriteria {
16 #[serde(rename = "true", skip_serializing_if = "Option::is_none")]
18 pub yes: Option<String>,
19 #[serde(rename = "false", skip_serializing_if = "Option::is_none")]
21 pub no: Option<String>,
22}
23
24#[derive(Clone, Debug, PartialEq, Serialize)]
32#[serde(tag = "type", rename_all = "lowercase")]
33#[non_exhaustive]
34pub enum Question {
35 #[non_exhaustive]
37 Noul {
38 instructions: Value,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 criteria: Option<NoulCriteria>,
43 },
44 #[non_exhaustive]
47 Choice {
48 instructions: Value,
50 criteria: BTreeMap<String, Option<String>>,
52 },
53 #[non_exhaustive]
55 Score {
56 instructions: Value,
58 criteria: Vec<String>,
60 },
61}
62
63impl Question {
64 pub fn noul(instructions: impl Into<Value>) -> Self {
66 Self::Noul {
67 instructions: instructions.into(),
68 criteria: None,
69 }
70 }
71
72 pub fn noul_with_criteria(instructions: impl Into<Value>, yes: impl Into<String>, no: impl Into<String>) -> Self {
74 Self::Noul {
75 instructions: instructions.into(),
76 criteria: Some(NoulCriteria {
77 yes: Some(yes.into()),
78 no: Some(no.into()),
79 }),
80 }
81 }
82
83 pub fn choice<K, V>(instructions: impl Into<Value>, options: impl IntoIterator<Item = (K, Option<V>)>) -> Self
85 where
86 K: Into<String>,
87 V: Into<String>,
88 {
89 Self::Choice {
90 instructions: instructions.into(),
91 criteria: options
92 .into_iter()
93 .map(|(k, v)| (k.into(), v.map(Into::into)))
94 .collect(),
95 }
96 }
97
98 pub fn choice_plain<K: Into<String>>(instructions: impl Into<Value>, options: impl IntoIterator<Item = K>) -> Self {
100 Self::choice(instructions, options.into_iter().map(|k| (k, None::<String>)))
101 }
102
103 pub fn score<L: Into<String>>(instructions: impl Into<Value>, levels: impl IntoIterator<Item = L>) -> Self {
105 Self::Score {
106 instructions: instructions.into(),
107 criteria: levels.into_iter().map(Into::into).collect(),
108 }
109 }
110
111 pub(crate) fn structural_problem(&self) -> Option<String> {
113 match self {
114 Self::Choice { criteria, .. } if criteria.is_empty() => Some("has no options".to_string()),
115 Self::Choice { criteria, .. } if criteria.len() > MAX_CHOICE_OPTIONS => Some(format!(
116 "has {} options; the API allows at most {MAX_CHOICE_OPTIONS}",
117 criteria.len()
118 )),
119 Self::Score { criteria, .. } if criteria.len() < 2 => Some("needs at least two levels".to_string()),
120 Self::Noul { .. } | Self::Choice { .. } | Self::Score { .. } => None,
121 }
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use serde_json::json;
129
130 #[test]
131 fn noul_serializes_without_criteria_when_absent() {
132 let q = Question::noul("Does this convey urgency?");
133 assert_eq!(
134 serde_json::to_value(&q).unwrap(),
135 json!({"type": "noul", "instructions": "Does this convey urgency?"})
136 );
137 }
138
139 #[test]
140 fn noul_criteria_use_true_false_keys() {
141 let q = Question::noul_with_criteria("Urgent?", "Explicitly time-sensitive", "No urgency expressed");
142 assert_eq!(
143 serde_json::to_value(&q).unwrap(),
144 json!({
145 "type": "noul",
146 "instructions": "Urgent?",
147 "criteria": {"true": "Explicitly time-sensitive", "false": "No urgency expressed"}
148 })
149 );
150 }
151
152 #[test]
153 fn choice_serializes_null_for_undescribed_options() {
154 let q = Question::choice("Which team?", [("billing", Some("Payments")), ("sales", None)]);
155 assert_eq!(
156 serde_json::to_value(&q).unwrap(),
157 json!({"type": "choice", "instructions": "Which team?", "criteria": {"billing": "Payments", "sales": null}})
158 );
159 }
160
161 #[test]
162 fn choice_over_the_option_limit_is_a_structural_problem() {
163 let q = Question::choice_plain("?", (0..=MAX_CHOICE_OPTIONS).map(|i| format!("o{i}")));
164 assert!(q.structural_problem().unwrap().contains("256 options"));
165 }
166
167 #[test]
168 fn choice_at_the_option_limit_is_well_formed() {
169 let q = Question::choice_plain("?", (0..MAX_CHOICE_OPTIONS).map(|i| format!("o{i}")));
170 assert_eq!(q.structural_problem(), None);
171 }
172
173 #[test]
174 fn empty_choice_and_short_score_are_structural_problems() {
175 assert!(Question::choice_plain::<&str>("?", []).structural_problem().is_some());
176 assert!(Question::score("?", ["one"]).structural_problem().is_some());
177 assert_eq!(Question::noul("?").structural_problem(), None);
178 }
179
180 #[test]
181 fn score_serializes_levels_in_order() {
182 let q = Question::score(json!({"rate": "frustration"}), ["Calm", "Frustrated", "Very angry"]);
183 assert_eq!(
184 serde_json::to_value(&q).unwrap(),
185 json!({"type": "score", "instructions": {"rate": "frustration"}, "criteria": ["Calm", "Frustrated", "Very angry"]})
186 );
187 }
188}