1use serde::de::{Error as _, MapAccess};
12use serde::{Deserialize, Deserializer};
13use serde_json::Value;
14
15use crate::Question;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Kind {
20 Noul,
21 Choice,
22 Score,
23}
24
25impl Kind {
26 pub fn flag(self) -> &'static str {
28 match self {
29 Kind::Noul => "noul",
30 Kind::Choice => "choice",
31 Kind::Score => "score",
32 }
33 }
34}
35
36pub fn parse(kind: Kind, spec: &str) -> Result<(String, Question), String> {
38 let wrong = |why: &str| format!("--{} `{spec}`: {why}", kind.flag());
39 let (id, rest) = spec.split_once('=').ok_or_else(|| wrong("expected ID=INSTRUCTIONS"))?;
40 let id = id.trim();
41 if id.is_empty() || id.contains(char::is_whitespace) {
42 return Err(wrong("the id before `=` must be one word"));
43 }
44 let mut parts = rest.split('|').map(str::trim);
45 let instructions = parts.next().unwrap_or_default();
46 if instructions.is_empty() {
47 return Err(wrong("no instructions after `=`"));
48 }
49 let criteria: Vec<&str> = parts.collect();
50 if criteria.iter().any(|part| part.is_empty()) {
51 return Err(wrong("an empty criterion between `|`s"));
52 }
53
54 let question = match kind {
55 Kind::Noul => match criteria[..] {
56 [] => Question::noul(instructions),
57 [yes, no] => Question::noul_with_criteria(instructions, yes, no),
58 _ => return Err(wrong("a noul takes no criteria, or two: |WHAT YES MEANS|WHAT NO MEANS")),
59 },
60 Kind::Choice => {
61 if criteria.len() < 2 {
62 return Err(wrong("a choice needs at least two options: |NAME[:DESCRIPTION]|…"));
63 }
64 let mut options: Vec<(String, Value)> = Vec::new();
65 for option in criteria {
66 let (name, description) = match option.split_once(':') {
67 Some((name, description)) => (name.trim(), Value::from(description.trim())),
68 None => (option, Value::Null),
69 };
70 if name.is_empty() {
71 return Err(wrong(&format!("option `{option}` has no name before `:`")));
72 }
73 if options.iter().any(|(other, _)| other == name) {
74 return Err(wrong(&format!("option `{name}` is there twice")));
75 }
76 options.push((name.to_owned(), description));
77 }
78 Question::choice(instructions, options)
79 }
80 Kind::Score => {
81 if !(2..=10).contains(&criteria.len()) {
82 return Err(wrong("a score takes two to ten levels, lowest first: |LEVEL|LEVEL|…"));
83 }
84 Question::score(instructions, criteria)
85 }
86 };
87 Ok((id.to_owned(), question))
88}
89
90#[cfg(test)]
91mod tests {
92 use serde_json::json;
93
94 use super::*;
95
96 fn question(kind: Kind, spec: &str) -> Value {
97 let (id, question) = parse(kind, spec).unwrap();
98 json!({ id: question })
99 }
100
101 #[test]
102 fn reads_a_noul() {
103 assert_eq!(
104 question(Kind::Noul, "is_urgent=Does this message convey urgency?"),
105 json!({"is_urgent": {"type": "noul", "instructions": "Does this message convey urgency?"}})
106 );
107 assert_eq!(
108 question(Kind::Noul, "is_urgent = Is it urgent? | Explicitly time-sensitive | No urgency expressed"),
109 json!({"is_urgent": {
110 "type": "noul",
111 "instructions": "Is it urgent?",
112 "criteria": {"true": "Explicitly time-sensitive", "false": "No urgency expressed"}
113 }})
114 );
115 }
116
117 #[test]
118 fn reads_a_choice_with_and_without_descriptions() {
119 assert_eq!(
120 serde_json::to_string(&parse(Kind::Choice, "department=Which team?|sales|billing:Payments: cards, refunds").unwrap().1)
121 .unwrap(),
122 r#"{"type":"choice","instructions":"Which team?","criteria":{"sales":null,"billing":"Payments: cards, refunds"}}"#
123 );
124 }
125
126 #[test]
127 fn reads_a_score() {
128 assert_eq!(
129 question(Kind::Score, "frustration=How frustrated?|Calm|Frustrated|Very angry"),
130 json!({"frustration": {"type": "score", "instructions": "How frustrated?", "criteria": ["Calm", "Frustrated", "Very angry"]}})
131 );
132 }
133
134 #[test]
135 fn says_what_is_wrong() {
136 let error = |kind, spec| parse(kind, spec).unwrap_err();
137 assert!(error(Kind::Noul, "Is it urgent?").contains("expected ID=INSTRUCTIONS"));
138 assert!(error(Kind::Noul, "is urgent=Is it?").contains("one word"));
139 assert!(error(Kind::Noul, "a=").contains("no instructions"));
140 assert!(error(Kind::Noul, "a=Is it?|yes").contains("two"));
141 assert!(error(Kind::Choice, "a=Which?|only").contains("at least two options"));
142 assert!(error(Kind::Choice, "a=Which?|x|x:again").contains("twice"));
143 assert!(error(Kind::Choice, "a=Which?|x|:no name").contains("no name"));
144 assert!(error(Kind::Score, "a=How much?|low").contains("two to ten"));
145 assert!(error(Kind::Score, "a=How much?|low||high").contains("empty criterion"));
146 }
147}
148
149pub fn questions_file(text: &str) -> Result<Vec<(String, Question)>, String> {
152 let Ordered(questions) =
153 serde_json::from_str(text).map_err(|error| format!("expected a JSON object of question ids to questions: {error}"))?;
154 for (id, question) in &questions {
157 question.check().map_err(|why| format!("question `{id}`: {why}"))?;
158 }
159 Ok(questions)
160}
161
162struct Ordered(Vec<(String, Question)>);
166
167impl<'de> Deserialize<'de> for Ordered {
168 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Ordered, D::Error> {
169 struct Visitor;
170 impl<'de> serde::de::Visitor<'de> for Visitor {
171 type Value = Ordered;
172
173 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
174 f.write_str("an object of question ids to questions")
175 }
176
177 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Ordered, A::Error> {
178 let mut questions = Vec::new();
179 while let Some(id) = map.next_key::<String>()? {
180 let question = map.next_value().map_err(|e| A::Error::custom(format!("question `{id}`: {e}")))?;
181 questions.push((id, question));
182 }
183 Ok(Ordered(questions))
184 }
185 }
186 deserializer.deserialize_map(Visitor)
187 }
188}
189
190#[cfg(test)]
191mod file_tests {
192 use serde_json::json;
193
194 use super::questions_file;
195 use serde_json::Value;
196
197 use crate::Question;
198
199 #[test]
200 fn reads_a_questions_file_in_order() {
201 let questions = questions_file(
202 r#"{
203 "zeta": {"type": "noul", "instructions": "Is it?", "criteria": {"true": {"what": "yes"}, "false": "no"}},
204 "alpha": {"type": "choice", "instructions": "Which?", "criteria": {"b": null, "a": "first"}}
205 }"#,
206 )
207 .unwrap();
208 assert_eq!(questions[0].0, "zeta");
209 assert_eq!(questions[0].1, Question::noul_with_criteria("Is it?", json!({"what": "yes"}), "no"));
210 assert_eq!(questions[1].1, Question::choice("Which?", [("b", Value::Null), ("a", Value::from("first"))]));
211 }
212
213 #[test]
214 fn says_what_is_wrong_with_a_questions_file() {
215 assert!(questions_file("[]").unwrap_err().contains("expected a JSON object"));
216 let error = questions_file(r#"{"q": {"type": "ranking", "instructions": "?"}}"#).unwrap_err();
217 assert!(error.contains("question `q`"), "{error}");
218 let empty = questions_file(r#"{"s": {"type": "score", "instructions": "?", "criteria": []}}"#).unwrap_err();
220 assert!(empty.contains("question `s`: a score needs levels"), "{empty}");
221 let eleven =
222 questions_file(&format!(r#"{{"s": {{"type": "score", "instructions": "?", "criteria": {:?}}}}}"#, ["l"; 11])).unwrap_err();
223 assert!(eleven.contains("up to 10 levels"), "{eleven}");
224 let twice = questions_file(r#"{"c": {"type": "choice", "instructions": "?", "criteria": {"a": "", "a": "again"}}}"#).unwrap_err();
225 assert!(twice.contains("option `a` is there twice"), "{twice}");
226 }
227}