1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3
4use crate::error::Error;
5use crate::types::entry::Entry;
6
7pub const MAX_CHOICE_OPTIONS: usize = 255;
9
10pub type Questions = IndexMap<String, Question>;
12
13#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
37#[serde(tag = "type", rename_all = "lowercase")]
38pub enum Question {
39 Noul {
41 instructions: Entry,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 criteria: Option<NoulCriteria>,
46 },
47 Choice {
49 instructions: Entry,
51 criteria: IndexMap<String, Entry>,
53 },
54 Score {
56 instructions: Entry,
58 criteria: Vec<Entry>,
60 },
61}
62
63#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
65pub struct NoulCriteria {
66 #[serde(rename = "true", skip_serializing_if = "Option::is_none")]
68 pub when_true: Option<Entry>,
69 #[serde(rename = "false", skip_serializing_if = "Option::is_none")]
71 pub when_false: Option<Entry>,
72}
73
74impl Question {
75 #[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 #[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 #[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 #[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 #[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 #[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 #[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
185pub 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}