1use crate::error::{Error, Result};
4use crate::json_compat::{dumps_ascii, dumps_unicode};
5use serde_json::{Map, Value};
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
11pub enum QuestionKind {
12 Choice = 0,
13 Score = 1,
14 Noul = 2,
15}
16
17impl QuestionKind {
18 pub fn index(self) -> usize {
19 self as usize
20 }
21
22 pub fn name(self) -> &'static str {
23 match self {
24 Self::Choice => "choice",
25 Self::Score => "score",
26 Self::Noul => "noul",
27 }
28 }
29
30 pub fn parse(value: &str) -> Option<Self> {
31 match value {
32 "choice" => Some(Self::Choice),
33 "score" => Some(Self::Score),
34 "noul" => Some(Self::Noul),
35 _ => None,
36 }
37 }
38}
39
40#[derive(Clone, Debug, PartialEq)]
42pub enum Criteria {
43 Choice(Vec<(String, Value)>),
46 Score(Vec<Value>),
48 Noul {
50 false_desc: Option<Value>,
51 true_desc: Option<Value>,
52 },
53}
54
55#[derive(Clone, Debug, PartialEq)]
58pub struct Question {
59 pub kind: QuestionKind,
60 pub instructions: String,
61 pub criteria: Criteria,
62}
63
64fn is_blank(value: &Value) -> bool {
65 value.is_null() || value.as_str() == Some("")
68}
69
70pub fn render_criterion(value: &Value) -> String {
73 match value {
74 Value::String(text) => text.clone(),
75 other => dumps_unicode(other),
76 }
77}
78
79impl Question {
80 pub fn from_value(value: &Value) -> Result<Self> {
82 let object = value
83 .as_object()
84 .ok_or_else(|| Error::Question("Each question must be a dictionary".into()))?;
85
86 let kind = object
87 .get("type")
88 .and_then(Value::as_str)
89 .and_then(QuestionKind::parse)
90 .ok_or_else(|| {
91 let shown = object.get("type").map_or("None".to_string(), render_repr);
92 Error::Question(format!(
93 "Unknown question type {shown}; expected choice, score, or noul"
94 ))
95 })?;
96
97 let instructions_value = object
98 .get("instructions")
99 .ok_or_else(|| Error::Question("Question is missing instructions".into()))?;
100 let instructions = match instructions_value {
101 Value::String(text) => text.clone(),
102 other => dumps_ascii(other),
105 };
106
107 let criteria = object.get("criteria").unwrap_or(&Value::Null);
108 let criteria = match kind {
109 QuestionKind::Choice => Self::choice_criteria(criteria)?,
110 QuestionKind::Score => {
111 let items = criteria.as_array().filter(|items| !items.is_empty());
112 let items = items.ok_or_else(|| {
113 Error::Question("Score criteria must be a nonempty list".into())
114 })?;
115 Criteria::Score(items.clone())
116 }
117 QuestionKind::Noul => {
118 if criteria.is_null() {
119 Criteria::Noul {
120 false_desc: None,
121 true_desc: None,
122 }
123 } else {
124 let map = criteria.as_object().ok_or_else(|| {
125 Error::Question(
126 "Noul criteria must be a dictionary with false/true descriptions"
127 .into(),
128 )
129 })?;
130 Criteria::Noul {
131 false_desc: map.get("false").cloned(),
132 true_desc: map.get("true").cloned(),
133 }
134 }
135 }
136 };
137
138 Ok(Self {
139 kind,
140 instructions,
141 criteria,
142 })
143 }
144
145 fn choice_criteria(criteria: &Value) -> Result<Criteria> {
146 match criteria {
147 Value::Array(items) => {
150 let mut labels = Vec::with_capacity(items.len());
151 for item in items {
152 let label = item
153 .as_str()
154 .ok_or_else(|| Error::Question("Choice labels must be strings".into()))?;
155 labels.push(label.to_string());
156 }
157 let mut seen = std::collections::HashSet::with_capacity(labels.len());
158 if !labels.iter().all(|label| seen.insert(label.as_str())) {
159 return Err(Error::Question("Choice labels must be unique".into()));
160 }
161 if labels.is_empty() {
162 return Err(Error::Question(
163 "Choice criteria must be a nonempty dictionary or list".into(),
164 ));
165 }
166 Ok(Criteria::Choice(
167 labels
168 .into_iter()
169 .map(|label| (label, Value::Null))
170 .collect(),
171 ))
172 }
173 Value::Object(map) if !map.is_empty() => Ok(Criteria::Choice(
174 map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
175 )),
176 _ => Err(Error::Question(
177 "Choice criteria must be a nonempty dictionary or list".into(),
178 )),
179 }
180 }
181
182 pub fn render_options(&self) -> Vec<String> {
184 match &self.criteria {
185 Criteria::Choice(pairs) => pairs
186 .iter()
187 .map(|(label, description)| {
188 if is_blank(description) {
189 label.clone()
190 } else {
191 format!("{label}: {}", render_criterion(description))
192 }
193 })
194 .collect(),
195 Criteria::Score(levels) => levels
196 .iter()
197 .enumerate()
198 .map(|(index, level)| format!("level {index}: {}", render_criterion(level)))
199 .collect(),
200 Criteria::Noul {
201 false_desc,
202 true_desc,
203 } => {
204 let render = |value: &Option<Value>, fallback: &str| match value {
205 Some(inner) if !is_blank(inner) => render_criterion(inner),
206 _ => fallback.to_string(),
207 };
208 vec![
209 format!(
210 "false: {}",
211 render(false_desc, "no, the statement does not hold")
212 ),
213 format!("true: {}", render(true_desc, "yes, the statement holds")),
214 ]
215 }
216 }
217 }
218
219 pub fn choice_labels(&self) -> Option<Vec<String>> {
221 match &self.criteria {
222 Criteria::Choice(pairs) => Some(pairs.iter().map(|(k, _)| k.clone()).collect()),
223 _ => None,
224 }
225 }
226
227 pub fn score_legend(&self) -> Option<Map<String, Value>> {
229 match &self.criteria {
230 Criteria::Score(levels) => {
231 let mut legend = Map::new();
232 for (index, level) in levels.iter().enumerate() {
233 legend.insert(index.to_string(), level.clone());
234 }
235 Some(legend)
236 }
237 _ => None,
238 }
239 }
240}
241
242fn render_repr(value: &Value) -> String {
244 match value {
245 Value::String(text) => format!("{text:?}"),
246 Value::Null => "None".into(),
247 Value::Bool(true) => "True".into(),
248 Value::Bool(false) => "False".into(),
249 other => other.to_string(),
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use serde_json::json;
257
258 fn q(value: Value) -> Question {
259 Question::from_value(&value).expect("valid question")
260 }
261
262 #[test]
263 fn choice_list_renders_bare_labels() {
264 let question = q(json!({
265 "type": "choice", "instructions": "Pick", "criteria": ["a", "b"]
266 }));
267 assert_eq!(question.render_options(), vec!["a", "b"]);
268 }
269
270 #[test]
271 fn choice_map_renders_descriptions_and_keeps_order() {
272 let question = q(json!({
273 "type": "choice",
274 "instructions": "Pick",
275 "criteria": {"billing": "refunds", "tech": "", "zero": 0}
276 }));
277 assert_eq!(
278 question.render_options(),
279 vec!["billing: refunds", "tech", "zero: 0"]
280 );
281 }
282
283 #[test]
284 fn structured_criterion_becomes_json() {
285 let question = q(json!({
286 "type": "choice",
287 "instructions": "Pick",
288 "criteria": {"a": {"desc": "x", "n": 1}}
289 }));
290 assert_eq!(
291 question.render_options(),
292 vec![r#"a: {"desc": "x", "n": 1}"#]
293 );
294 }
295
296 #[test]
297 fn score_levels_are_numbered() {
298 let question = q(json!({
299 "type": "score", "instructions": "How bad", "criteria": ["low", "high"]
300 }));
301 assert_eq!(
302 question.render_options(),
303 vec!["level 0: low", "level 1: high"]
304 );
305 }
306
307 #[test]
308 fn noul_defaults_and_overrides() {
309 let bare = q(json!({"type": "noul", "instructions": "True?"}));
310 assert_eq!(
311 bare.render_options(),
312 vec![
313 "false: no, the statement does not hold",
314 "true: yes, the statement holds"
315 ]
316 );
317 let described = q(json!({
318 "type": "noul",
319 "instructions": "True?",
320 "criteria": {"true": "it holds"}
321 }));
322 assert_eq!(
323 described.render_options(),
324 vec!["false: no, the statement does not hold", "true: it holds"]
325 );
326 }
327
328 #[test]
329 fn rejects_bad_definitions() {
330 assert!(Question::from_value(&json!({"type": "nope", "instructions": "x"})).is_err());
331 assert!(Question::from_value(&json!({"type": "choice"})).is_err());
332 assert!(Question::from_value(&json!({
333 "type": "choice", "instructions": "x", "criteria": ["a", "a"]
334 }))
335 .is_err());
336 assert!(Question::from_value(&json!({
337 "type": "score", "instructions": "x", "criteria": []
338 }))
339 .is_err());
340 }
341
342 #[test]
343 fn non_string_instructions_use_ascii_json() {
344 let question = q(json!({
345 "type": "noul", "instructions": {"ask": "h\u{e9}llo"}
346 }));
347 assert_eq!(question.instructions, "{\"ask\": \"h\\u00e9llo\"}");
348 }
349}